atmosphere explorer
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add stream stats

Juliet dc697184 149a8078

+533 -310
+1 -1
src/index.tsx
··· 13 13 import { RecordView } from "./views/record.tsx"; 14 14 import { RepoView } from "./views/repo.tsx"; 15 15 import { Settings } from "./views/settings.tsx"; 16 - import { StreamView } from "./views/stream.tsx"; 16 + import { StreamView } from "./views/stream"; 17 17 18 18 render( 19 19 () => (
-309
src/views/stream.tsx
··· 1 - import { Firehose } from "@skyware/firehose"; 2 - import { Title } from "@solidjs/meta"; 3 - import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 - import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 - import { Button } from "../components/button"; 6 - import { JSONValue } from "../components/json"; 7 - import { StickyOverlay } from "../components/sticky"; 8 - import { TextInput } from "../components/text-input"; 9 - 10 - const LIMIT = 25; 11 - type Parameter = { name: string; param: string | string[] | undefined }; 12 - 13 - const StreamView = () => { 14 - const [searchParams, setSearchParams] = useSearchParams(); 15 - const [parameters, setParameters] = createSignal<Parameter[]>([]); 16 - const streamType = useLocation().pathname === "/firehose" ? "firehose" : "jetstream"; 17 - const [records, setRecords] = createSignal<Array<any>>([]); 18 - const [connected, setConnected] = createSignal(false); 19 - const [notice, setNotice] = createSignal(""); 20 - let socket: WebSocket; 21 - let firehose: Firehose; 22 - let formRef!: HTMLFormElement; 23 - let pendingRecords: any[] = []; 24 - let rafId: number | null = null; 25 - 26 - const addRecord = (record: any) => { 27 - pendingRecords.push(record); 28 - if (rafId === null) { 29 - rafId = requestAnimationFrame(() => { 30 - setRecords(records().concat(pendingRecords).slice(-LIMIT)); 31 - pendingRecords = []; 32 - rafId = null; 33 - }); 34 - } 35 - }; 36 - 37 - const disconnect = () => { 38 - if (streamType === "jetstream") socket?.close(); 39 - else firehose?.close(); 40 - if (rafId !== null) { 41 - cancelAnimationFrame(rafId); 42 - rafId = null; 43 - } 44 - pendingRecords = []; 45 - setConnected(false); 46 - }; 47 - 48 - const connectSocket = async (formData: FormData) => { 49 - setNotice(""); 50 - if (connected()) { 51 - disconnect(); 52 - return; 53 - } 54 - setRecords([]); 55 - 56 - let url = ""; 57 - if (streamType === "jetstream") { 58 - url = 59 - formData.get("instance")?.toString() ?? "wss://jetstream1.us-east.bsky.network/subscribe"; 60 - url = url.concat("?"); 61 - } else { 62 - url = formData.get("instance")?.toString() ?? "wss://bsky.network"; 63 - url = url.replace("/xrpc/com.atproto.sync.subscribeRepos", ""); 64 - if (!(url.startsWith("wss://") || url.startsWith("ws://"))) url = "wss://" + url; 65 - } 66 - 67 - const collections = formData.get("collections")?.toString().split(","); 68 - collections?.forEach((collection) => { 69 - if (collection.length) url = url.concat(`wantedCollections=${collection}&`); 70 - }); 71 - 72 - const dids = formData.get("dids")?.toString().split(","); 73 - dids?.forEach((did) => { 74 - if (did.length) url = url.concat(`wantedDids=${did}&`); 75 - }); 76 - 77 - const cursor = formData.get("cursor")?.toString(); 78 - if (streamType === "jetstream") { 79 - if (cursor?.length) url = url.concat(`cursor=${cursor}`); 80 - if (url.endsWith("&")) url = url.slice(0, -1); 81 - } 82 - 83 - setSearchParams({ 84 - instance: formData.get("instance")?.toString(), 85 - collections: formData.get("collections")?.toString(), 86 - dids: formData.get("dids")?.toString(), 87 - cursor: formData.get("cursor")?.toString(), 88 - allEvents: formData.get("allEvents")?.toString(), 89 - }); 90 - 91 - setParameters([ 92 - { name: "Instance", param: formData.get("instance")?.toString() }, 93 - { name: "Collections", param: formData.get("collections")?.toString() }, 94 - { name: "DIDs", param: formData.get("dids")?.toString() }, 95 - { name: "Cursor", param: formData.get("cursor")?.toString() }, 96 - { name: "All Events", param: formData.get("allEvents")?.toString() }, 97 - ]); 98 - 99 - setConnected(true); 100 - if (streamType === "jetstream") { 101 - socket = new WebSocket(url); 102 - socket.addEventListener("message", (event) => { 103 - const rec = JSON.parse(event.data); 104 - if (searchParams.allEvents === "on" || (rec.kind !== "account" && rec.kind !== "identity")) 105 - addRecord(rec); 106 - }); 107 - socket.addEventListener("error", () => { 108 - setNotice("Connection error"); 109 - setConnected(false); 110 - }); 111 - } else { 112 - firehose = new Firehose({ 113 - relay: url, 114 - cursor: cursor, 115 - autoReconnect: false, 116 - }); 117 - firehose.on("error", (err) => { 118 - console.error(err); 119 - }); 120 - firehose.on("commit", (commit) => { 121 - for (const op of commit.ops) { 122 - const record = { 123 - $type: commit.$type, 124 - repo: commit.repo, 125 - seq: commit.seq, 126 - time: commit.time, 127 - rev: commit.rev, 128 - since: commit.since, 129 - op: op, 130 - }; 131 - addRecord(record); 132 - } 133 - }); 134 - firehose.on("identity", (identity) => { 135 - addRecord(identity); 136 - }); 137 - firehose.on("account", (account) => { 138 - addRecord(account); 139 - }); 140 - firehose.on("sync", (sync) => { 141 - const event = { 142 - $type: sync.$type, 143 - did: sync.did, 144 - rev: sync.rev, 145 - seq: sync.seq, 146 - time: sync.time, 147 - }; 148 - addRecord(event); 149 - }); 150 - firehose.start(); 151 - } 152 - }; 153 - 154 - onMount(async () => { 155 - const formData = new FormData(); 156 - if (searchParams.instance) formData.append("instance", searchParams.instance.toString()); 157 - if (searchParams.collections) 158 - formData.append("collections", searchParams.collections.toString()); 159 - if (searchParams.dids) formData.append("dids", searchParams.dids.toString()); 160 - if (searchParams.cursor) formData.append("cursor", searchParams.cursor.toString()); 161 - if (searchParams.allEvents) formData.append("allEvents", searchParams.allEvents.toString()); 162 - if (searchParams.instance) connectSocket(formData); 163 - }); 164 - 165 - onCleanup(() => { 166 - socket?.close(); 167 - if (rafId !== null) { 168 - cancelAnimationFrame(rafId); 169 - } 170 - }); 171 - 172 - return ( 173 - <> 174 - <Title>{streamType === "firehose" ? "Firehose" : "Jetstream"} - PDSls</Title> 175 - <div class="flex w-full flex-col items-center"> 176 - <div class="mb-1 flex gap-4 font-medium"> 177 - <A 178 - class="flex items-center gap-1 border-b-2" 179 - inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 180 - href="/jetstream" 181 - > 182 - Jetstream 183 - </A> 184 - <A 185 - class="flex items-center gap-1 border-b-2" 186 - inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 187 - href="/firehose" 188 - > 189 - Firehose 190 - </A> 191 - </div> 192 - <StickyOverlay> 193 - <form ref={formRef} class="flex w-full flex-col gap-1.5 text-sm"> 194 - <Show when={!connected()}> 195 - <label class="flex items-center justify-end gap-x-1"> 196 - <span class="min-w-20">Instance</span> 197 - <TextInput 198 - name="instance" 199 - value={ 200 - searchParams.instance ?? 201 - (streamType === "jetstream" ? 202 - "wss://jetstream1.us-east.bsky.network/subscribe" 203 - : "wss://bsky.network") 204 - } 205 - class="grow" 206 - /> 207 - </label> 208 - <Show when={streamType === "jetstream"}> 209 - <label class="flex items-center justify-end gap-x-1"> 210 - <span class="min-w-20">Collections</span> 211 - <textarea 212 - name="collections" 213 - spellcheck={false} 214 - placeholder="Comma-separated list of collections" 215 - value={searchParams.collections ?? ""} 216 - class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 217 - /> 218 - </label> 219 - </Show> 220 - <Show when={streamType === "jetstream"}> 221 - <label class="flex items-center justify-end gap-x-1"> 222 - <span class="min-w-20">DIDs</span> 223 - <textarea 224 - name="dids" 225 - spellcheck={false} 226 - placeholder="Comma-separated list of DIDs" 227 - value={searchParams.dids ?? ""} 228 - class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 229 - /> 230 - </label> 231 - </Show> 232 - <label class="flex items-center justify-end gap-x-1"> 233 - <span class="min-w-20">Cursor</span> 234 - <TextInput 235 - name="cursor" 236 - placeholder="Leave empty for live-tail" 237 - value={searchParams.cursor ?? ""} 238 - class="grow" 239 - /> 240 - </label> 241 - <Show when={streamType === "jetstream"}> 242 - <div class="flex items-center justify-end gap-x-1"> 243 - <input 244 - type="checkbox" 245 - name="allEvents" 246 - id="allEvents" 247 - checked={searchParams.allEvents === "on" ? true : false} 248 - /> 249 - <label for="allEvents" class="select-none"> 250 - Show account and identity events 251 - </label> 252 - </div> 253 - </Show> 254 - </Show> 255 - <Show when={connected()}> 256 - <div class="flex flex-col gap-1 wrap-anywhere"> 257 - <For each={parameters()}> 258 - {(param) => ( 259 - <Show when={param.param}> 260 - <div class="flex"> 261 - <div class="min-w-24 font-semibold">{param.name}</div> 262 - {param.param} 263 - </div> 264 - </Show> 265 - )} 266 - </For> 267 - </div> 268 - </Show> 269 - <div class="flex justify-end"> 270 - <Show when={connected()}> 271 - <button 272 - type="button" 273 - onmousedown={(e) => { 274 - e.preventDefault(); 275 - disconnect(); 276 - }} 277 - ontouchstart={(e) => { 278 - e.preventDefault(); 279 - disconnect(); 280 - }} 281 - class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 282 - > 283 - Disconnect 284 - </button> 285 - </Show> 286 - <Show when={!connected()}> 287 - <Button onClick={() => connectSocket(new FormData(formRef))}>Connect</Button> 288 - </Show> 289 - </div> 290 - </form> 291 - </StickyOverlay> 292 - <Show when={notice().length}> 293 - <div class="text-red-500 dark:text-red-400">{notice()}</div> 294 - </Show> 295 - <div class="flex w-full flex-col gap-2 divide-y-[0.5px] divide-neutral-500 font-mono text-sm wrap-anywhere whitespace-pre-wrap md:w-3xl"> 296 - <For each={records().toReversed()}> 297 - {(rec) => ( 298 - <div class="pb-2"> 299 - <JSONValue data={rec} repo={rec.did ?? rec.repo} /> 300 - </div> 301 - )} 302 - </For> 303 - </div> 304 - </div> 305 - </> 306 - ); 307 - }; 308 - 309 - export { StreamView };
+419
src/views/stream/index.tsx
··· 1 + import { Firehose } from "@skyware/firehose"; 2 + import { Title } from "@solidjs/meta"; 3 + import { A, useLocation, useSearchParams } from "@solidjs/router"; 4 + import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5 + import { Button } from "../../components/button"; 6 + import { JSONValue } from "../../components/json"; 7 + import { StickyOverlay } from "../../components/sticky"; 8 + import { TextInput } from "../../components/text-input"; 9 + import { StreamStats, StreamStatsPanel } from "./stats"; 10 + 11 + const LIMIT = 20; 12 + type Parameter = { name: string; param: string | string[] | undefined }; 13 + 14 + const StreamView = () => { 15 + const [searchParams, setSearchParams] = useSearchParams(); 16 + const [parameters, setParameters] = createSignal<Parameter[]>([]); 17 + const streamType = useLocation().pathname === "/firehose" ? "firehose" : "jetstream"; 18 + const [records, setRecords] = createSignal<Array<any>>([]); 19 + const [connected, setConnected] = createSignal(false); 20 + const [paused, setPaused] = createSignal(false); 21 + const [notice, setNotice] = createSignal(""); 22 + const [stats, setStats] = createSignal<StreamStats>({ 23 + totalEvents: 0, 24 + eventsPerSecond: 0, 25 + eventTypes: {}, 26 + collections: {}, 27 + }); 28 + const [currentTime, setCurrentTime] = createSignal(Date.now()); 29 + let socket: WebSocket; 30 + let firehose: Firehose; 31 + let formRef!: HTMLFormElement; 32 + let pendingRecords: any[] = []; 33 + let rafId: number | null = null; 34 + let statsIntervalId: number | null = null; 35 + let statsUpdateIntervalId: number | null = null; 36 + let lastSecondEventCount = 0; 37 + let currentSecondEventCount = 0; 38 + // Track stats in variables for batching 39 + let totalEventsCount = 0; 40 + let eventTypesMap: Record<string, number> = {}; 41 + let collectionsMap: Record<string, number> = {}; 42 + 43 + const addRecord = (record: any) => { 44 + currentSecondEventCount++; 45 + 46 + // Track statistics in variables (batched update) 47 + totalEventsCount++; 48 + const eventType = record.kind || record.$type || "unknown"; 49 + const collection = record.commit?.collection || record.op?.path?.split("/")[0] || "unknown"; 50 + eventTypesMap[eventType] = (eventTypesMap[eventType] || 0) + 1; 51 + collectionsMap[collection] = (collectionsMap[collection] || 0) + 1; 52 + 53 + if (!paused()) { 54 + pendingRecords.push(record); 55 + if (rafId === null) { 56 + rafId = requestAnimationFrame(() => { 57 + setRecords(records().concat(pendingRecords).slice(-LIMIT)); 58 + pendingRecords = []; 59 + rafId = null; 60 + }); 61 + } 62 + } 63 + }; 64 + 65 + const disconnect = () => { 66 + if (streamType === "jetstream") socket?.close(); 67 + else firehose?.close(); 68 + if (rafId !== null) { 69 + cancelAnimationFrame(rafId); 70 + rafId = null; 71 + } 72 + if (statsIntervalId !== null) { 73 + clearInterval(statsIntervalId); 74 + statsIntervalId = null; 75 + } 76 + if (statsUpdateIntervalId !== null) { 77 + clearInterval(statsUpdateIntervalId); 78 + statsUpdateIntervalId = null; 79 + } 80 + pendingRecords = []; 81 + totalEventsCount = 0; 82 + eventTypesMap = {}; 83 + collectionsMap = {}; 84 + setConnected(false); 85 + setPaused(false); 86 + setStats((prev) => ({ 87 + ...prev, 88 + eventsPerSecond: 0, 89 + })); 90 + }; 91 + 92 + const togglePause = () => { 93 + setPaused(!paused()); 94 + }; 95 + 96 + const connectSocket = async (formData: FormData) => { 97 + setNotice(""); 98 + if (connected()) { 99 + disconnect(); 100 + return; 101 + } 102 + setRecords([]); 103 + 104 + let url = ""; 105 + if (streamType === "jetstream") { 106 + url = 107 + formData.get("instance")?.toString() ?? "wss://jetstream1.us-east.bsky.network/subscribe"; 108 + url = url.concat("?"); 109 + } else { 110 + url = formData.get("instance")?.toString() ?? "wss://bsky.network"; 111 + url = url.replace("/xrpc/com.atproto.sync.subscribeRepos", ""); 112 + if (!(url.startsWith("wss://") || url.startsWith("ws://"))) url = "wss://" + url; 113 + } 114 + 115 + const collections = formData.get("collections")?.toString().split(","); 116 + collections?.forEach((collection) => { 117 + if (collection.length) url = url.concat(`wantedCollections=${collection}&`); 118 + }); 119 + 120 + const dids = formData.get("dids")?.toString().split(","); 121 + dids?.forEach((did) => { 122 + if (did.length) url = url.concat(`wantedDids=${did}&`); 123 + }); 124 + 125 + const cursor = formData.get("cursor")?.toString(); 126 + if (streamType === "jetstream") { 127 + if (cursor?.length) url = url.concat(`cursor=${cursor}`); 128 + if (url.endsWith("&")) url = url.slice(0, -1); 129 + } 130 + 131 + setSearchParams({ 132 + instance: formData.get("instance")?.toString(), 133 + collections: formData.get("collections")?.toString(), 134 + dids: formData.get("dids")?.toString(), 135 + cursor: formData.get("cursor")?.toString(), 136 + allEvents: formData.get("allEvents")?.toString(), 137 + }); 138 + 139 + setParameters([ 140 + { name: "Instance", param: formData.get("instance")?.toString() }, 141 + { name: "Collections", param: formData.get("collections")?.toString() }, 142 + { name: "DIDs", param: formData.get("dids")?.toString() }, 143 + { name: "Cursor", param: formData.get("cursor")?.toString() }, 144 + { name: "All Events", param: formData.get("allEvents")?.toString() }, 145 + ]); 146 + 147 + setConnected(true); 148 + const now = Date.now(); 149 + setCurrentTime(now); 150 + 151 + // Reset tracking variables 152 + totalEventsCount = 0; 153 + eventTypesMap = {}; 154 + collectionsMap = {}; 155 + 156 + setStats({ 157 + connectedAt: now, 158 + totalEvents: 0, 159 + eventsPerSecond: 0, 160 + eventTypes: {}, 161 + collections: {}, 162 + }); 163 + 164 + statsUpdateIntervalId = window.setInterval(() => { 165 + setStats((prev) => ({ 166 + ...prev, 167 + totalEvents: totalEventsCount, 168 + eventTypes: { ...eventTypesMap }, 169 + collections: { ...collectionsMap }, 170 + })); 171 + }, 50); 172 + 173 + // Calculate events/sec every second 174 + statsIntervalId = window.setInterval(() => { 175 + setStats((prev) => ({ 176 + ...prev, 177 + eventsPerSecond: currentSecondEventCount, 178 + })); 179 + lastSecondEventCount = currentSecondEventCount; 180 + currentSecondEventCount = 0; 181 + setCurrentTime(Date.now()); 182 + }, 1000); 183 + if (streamType === "jetstream") { 184 + socket = new WebSocket(url); 185 + socket.addEventListener("message", (event) => { 186 + const rec = JSON.parse(event.data); 187 + if (searchParams.allEvents === "on" || (rec.kind !== "account" && rec.kind !== "identity")) 188 + addRecord(rec); 189 + }); 190 + socket.addEventListener("error", () => { 191 + setNotice("Connection error"); 192 + disconnect(); 193 + }); 194 + } else { 195 + firehose = new Firehose({ 196 + relay: url, 197 + cursor: cursor, 198 + autoReconnect: false, 199 + }); 200 + firehose.on("error", (err) => { 201 + console.error(err); 202 + const message = err instanceof Error ? err.message : "Unknown error"; 203 + setNotice(`Connection error: ${message}`); 204 + disconnect(); 205 + }); 206 + firehose.on("commit", (commit) => { 207 + for (const op of commit.ops) { 208 + const record = { 209 + $type: commit.$type, 210 + repo: commit.repo, 211 + seq: commit.seq, 212 + time: commit.time, 213 + rev: commit.rev, 214 + since: commit.since, 215 + op: op, 216 + }; 217 + addRecord(record); 218 + } 219 + }); 220 + firehose.on("identity", (identity) => { 221 + addRecord(identity); 222 + }); 223 + firehose.on("account", (account) => { 224 + addRecord(account); 225 + }); 226 + firehose.on("sync", (sync) => { 227 + const event = { 228 + $type: sync.$type, 229 + did: sync.did, 230 + rev: sync.rev, 231 + seq: sync.seq, 232 + time: sync.time, 233 + }; 234 + addRecord(event); 235 + }); 236 + firehose.start(); 237 + } 238 + }; 239 + 240 + onMount(async () => { 241 + const formData = new FormData(); 242 + if (searchParams.instance) formData.append("instance", searchParams.instance.toString()); 243 + if (searchParams.collections) 244 + formData.append("collections", searchParams.collections.toString()); 245 + if (searchParams.dids) formData.append("dids", searchParams.dids.toString()); 246 + if (searchParams.cursor) formData.append("cursor", searchParams.cursor.toString()); 247 + if (searchParams.allEvents) formData.append("allEvents", searchParams.allEvents.toString()); 248 + if (searchParams.instance) connectSocket(formData); 249 + }); 250 + 251 + onCleanup(() => { 252 + socket?.close(); 253 + if (rafId !== null) { 254 + cancelAnimationFrame(rafId); 255 + } 256 + if (statsIntervalId !== null) { 257 + clearInterval(statsIntervalId); 258 + } 259 + if (statsUpdateIntervalId !== null) { 260 + clearInterval(statsUpdateIntervalId); 261 + } 262 + }); 263 + 264 + return ( 265 + <> 266 + <Title>{streamType === "firehose" ? "Firehose" : "Jetstream"} - PDSls</Title> 267 + <div class="flex w-full flex-col items-center"> 268 + <div class="flex gap-4 font-medium"> 269 + <A 270 + class="flex items-center gap-1 border-b-2" 271 + inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 272 + href="/jetstream" 273 + > 274 + Jetstream 275 + </A> 276 + <A 277 + class="flex items-center gap-1 border-b-2" 278 + inactiveClass="border-transparent text-neutral-600 dark:text-neutral-400 hover:border-neutral-400 dark:hover:border-neutral-600" 279 + href="/firehose" 280 + > 281 + Firehose 282 + </A> 283 + </div> 284 + <Show when={!connected()}> 285 + <form ref={formRef} class="mt-4 mb-4 flex w-full flex-col gap-1.5 px-2 text-sm"> 286 + <label class="flex items-center justify-end gap-x-1"> 287 + <span class="min-w-20">Instance</span> 288 + <TextInput 289 + name="instance" 290 + value={ 291 + searchParams.instance ?? 292 + (streamType === "jetstream" ? 293 + "wss://jetstream1.us-east.bsky.network/subscribe" 294 + : "wss://bsky.network") 295 + } 296 + class="grow" 297 + /> 298 + </label> 299 + <Show when={streamType === "jetstream"}> 300 + <label class="flex items-center justify-end gap-x-1"> 301 + <span class="min-w-20">Collections</span> 302 + <textarea 303 + name="collections" 304 + spellcheck={false} 305 + placeholder="Comma-separated list of collections" 306 + value={searchParams.collections ?? ""} 307 + class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 308 + /> 309 + </label> 310 + </Show> 311 + <Show when={streamType === "jetstream"}> 312 + <label class="flex items-center justify-end gap-x-1"> 313 + <span class="min-w-20">DIDs</span> 314 + <textarea 315 + name="dids" 316 + spellcheck={false} 317 + placeholder="Comma-separated list of DIDs" 318 + value={searchParams.dids ?? ""} 319 + class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-[1.5px] focus:outline-neutral-600 dark:outline-neutral-600 dark:focus:outline-neutral-400" 320 + /> 321 + </label> 322 + </Show> 323 + <label class="flex items-center justify-end gap-x-1"> 324 + <span class="min-w-20">Cursor</span> 325 + <TextInput 326 + name="cursor" 327 + placeholder="Leave empty for live-tail" 328 + value={searchParams.cursor ?? ""} 329 + class="grow" 330 + /> 331 + </label> 332 + <Show when={streamType === "jetstream"}> 333 + <div class="flex items-center justify-end gap-x-1"> 334 + <input 335 + type="checkbox" 336 + name="allEvents" 337 + id="allEvents" 338 + checked={searchParams.allEvents === "on" ? true : false} 339 + /> 340 + <label for="allEvents" class="select-none"> 341 + Show account and identity events 342 + </label> 343 + </div> 344 + </Show> 345 + <div class="flex justify-end gap-2"> 346 + <Button onClick={() => connectSocket(new FormData(formRef))}>Connect</Button> 347 + </div> 348 + </form> 349 + </Show> 350 + <Show when={connected()}> 351 + <StickyOverlay> 352 + <div class="flex w-full flex-col gap-3 p-1"> 353 + <div class="flex flex-col gap-1 text-sm wrap-anywhere"> 354 + <div class="font-semibold">Parameters</div> 355 + <For each={parameters()}> 356 + {(param) => ( 357 + <Show when={param.param}> 358 + <div class="text-sm"> 359 + <div class="text-xs text-neutral-500 dark:text-neutral-400"> 360 + {param.name} 361 + </div> 362 + <div class="text-neutral-700 dark:text-neutral-300">{param.param}</div> 363 + </div> 364 + </Show> 365 + )} 366 + </For> 367 + </div> 368 + <StreamStatsPanel stats={stats()} currentTime={currentTime()} /> 369 + <div class="flex justify-end gap-2"> 370 + <button 371 + type="button" 372 + onmousedown={(e) => { 373 + e.preventDefault(); 374 + togglePause(); 375 + }} 376 + ontouchstart={(e) => { 377 + e.preventDefault(); 378 + togglePause(); 379 + }} 380 + class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 381 + > 382 + {paused() ? "Resume" : "Pause"} 383 + </button> 384 + <button 385 + type="button" 386 + onmousedown={(e) => { 387 + e.preventDefault(); 388 + disconnect(); 389 + }} 390 + ontouchstart={(e) => { 391 + e.preventDefault(); 392 + disconnect(); 393 + }} 394 + class="dark:hover:bg-dark-200 dark:shadow-dark-700 dark:active:bg-dark-100 box-border flex h-7 items-center gap-1 rounded-lg border-[0.5px] border-neutral-300 bg-neutral-50 px-2 py-1.5 text-xs shadow-xs select-none hover:bg-neutral-100 active:bg-neutral-200 dark:border-neutral-700 dark:bg-neutral-800" 395 + > 396 + Disconnect 397 + </button> 398 + </div> 399 + </div> 400 + </StickyOverlay> 401 + </Show> 402 + <Show when={notice().length}> 403 + <div class="text-red-500 dark:text-red-400">{notice()}</div> 404 + </Show> 405 + <div class="flex w-full flex-col gap-2 divide-y-[0.5px] divide-neutral-500 font-mono text-sm wrap-anywhere whitespace-pre-wrap md:w-3xl"> 406 + <For each={records().toReversed()}> 407 + {(rec) => ( 408 + <div class="pb-2"> 409 + <JSONValue data={rec} repo={rec.did ?? rec.repo} /> 410 + </div> 411 + )} 412 + </For> 413 + </div> 414 + </div> 415 + </> 416 + ); 417 + }; 418 + 419 + export { StreamView };
+113
src/views/stream/stats.tsx
··· 1 + import { For, Show } from "solid-js"; 2 + 3 + export type StreamStats = { 4 + connectedAt?: number; 5 + totalEvents: number; 6 + eventsPerSecond: number; 7 + eventTypes: Record<string, number>; 8 + collections: Record<string, number>; 9 + }; 10 + 11 + const formatUptime = (ms: number) => { 12 + const seconds = Math.floor(ms / 1000); 13 + const minutes = Math.floor(seconds / 60); 14 + const hours = Math.floor(minutes / 60); 15 + 16 + if (hours > 0) { 17 + return `${hours}h ${minutes % 60}m ${seconds % 60}s`; 18 + } else if (minutes > 0) { 19 + return `${minutes}m ${seconds % 60}s`; 20 + } else { 21 + return `${seconds}s`; 22 + } 23 + }; 24 + 25 + export const StreamStatsPanel = (props: { stats: StreamStats; currentTime: number }) => { 26 + const uptime = () => (props.stats.connectedAt ? props.currentTime - props.stats.connectedAt : 0); 27 + 28 + const topCollections = () => 29 + Object.entries(props.stats.collections) 30 + .sort(([, a], [, b]) => b - a) 31 + .slice(0, 5); 32 + 33 + const topEventTypes = () => 34 + Object.entries(props.stats.eventTypes) 35 + .sort(([, a], [, b]) => b - a) 36 + .slice(0, 5); 37 + 38 + return ( 39 + <Show when={props.stats.connectedAt !== undefined}> 40 + <div class="w-full text-sm"> 41 + <div class="mb-1 font-semibold">Statistics</div> 42 + <div class="flex flex-wrap justify-between gap-x-4 gap-y-2"> 43 + <div> 44 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Uptime</div> 45 + <div class="font-mono">{formatUptime(uptime())}</div> 46 + </div> 47 + <div> 48 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Total Events</div> 49 + <div class="font-mono">{props.stats.totalEvents.toLocaleString()}</div> 50 + </div> 51 + <div> 52 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Events/sec</div> 53 + <div class="font-mono">{props.stats.eventsPerSecond.toFixed(1)}</div> 54 + </div> 55 + <div> 56 + <div class="text-xs text-neutral-500 dark:text-neutral-400">Avg/sec</div> 57 + <div class="font-mono"> 58 + {uptime() > 0 ? ((props.stats.totalEvents / uptime()) * 1000).toFixed(1) : "0.0"} 59 + </div> 60 + </div> 61 + </div> 62 + 63 + <Show when={topEventTypes().length > 0}> 64 + <div class="mt-2"> 65 + <div class="mb-1 text-xs text-neutral-500 dark:text-neutral-400">Event Types</div> 66 + <div class="grid grid-cols-[1fr_auto_auto] gap-x-5 gap-y-0.5 font-mono text-xs"> 67 + <For each={topEventTypes()}> 68 + {([type, count]) => { 69 + const percentage = ((count / props.stats.totalEvents) * 100).toFixed(1); 70 + return ( 71 + <> 72 + <span class="text-neutral-700 dark:text-neutral-300">{type}</span> 73 + <span class="text-right text-neutral-600 tabular-nums dark:text-neutral-400"> 74 + {count.toLocaleString()} 75 + </span> 76 + <span class="text-right text-neutral-400 tabular-nums dark:text-neutral-500"> 77 + {percentage}% 78 + </span> 79 + </> 80 + ); 81 + }} 82 + </For> 83 + </div> 84 + </div> 85 + </Show> 86 + 87 + <Show when={topCollections().length > 0}> 88 + <div class="mt-2"> 89 + <div class="mb-1 text-xs text-neutral-500 dark:text-neutral-400">Top Collections</div> 90 + <div class="grid grid-cols-[1fr_auto_auto] gap-x-5 gap-y-0.5 font-mono text-xs"> 91 + <For each={topCollections()}> 92 + {([collection, count]) => { 93 + const percentage = ((count / props.stats.totalEvents) * 100).toFixed(1); 94 + return ( 95 + <> 96 + <span class="text-neutral-700 dark:text-neutral-300">{collection}</span> 97 + <span class="text-right text-neutral-600 tabular-nums dark:text-neutral-400"> 98 + {count.toLocaleString()} 99 + </span> 100 + <span class="text-right text-neutral-400 tabular-nums dark:text-neutral-500"> 101 + {percentage}% 102 + </span> 103 + </> 104 + ); 105 + }} 106 + </For> 107 + </div> 108 + </div> 109 + </Show> 110 + </div> 111 + </Show> 112 + ); 113 + };