atmosphere explorer
at main 473 lines 17 kB view raw
1import { Firehose } from "@skyware/firehose"; 2import { Title } from "@solidjs/meta"; 3import { A, useLocation, useSearchParams } from "@solidjs/router"; 4import { createSignal, For, onCleanup, onMount, Show } from "solid-js"; 5import { Button } from "../../components/button"; 6import DidHoverCard from "../../components/hover-card/did"; 7import { JSONValue } from "../../components/json"; 8import { TextInput } from "../../components/text-input"; 9import { addToClipboard } from "../../utils/copy"; 10import { websocketCloseReasons } from "../../utils/websocket"; 11import { getStreamType, STREAM_CONFIGS, STREAM_TYPES, StreamType } from "./config"; 12import { StreamStats, StreamStatsPanel } from "./stats"; 13 14const LIMIT = 20; 15 16const TYPE_COLORS: Record<string, string> = { 17 create: "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300", 18 update: "bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-300", 19 delete: "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300", 20 identity: "bg-purple-100 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300", 21 account: "bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300", 22 sync: "bg-pink-100 text-pink-700 dark:bg-pink-900/30 dark:text-pink-300", 23}; 24 25const StreamRecordItem = (props: { record: any; streamType: StreamType }) => { 26 const [expanded, setExpanded] = createSignal(false); 27 const config = () => STREAM_CONFIGS[props.streamType]; 28 const info = () => config().parseRecord(props.record); 29 30 const displayType = () => { 31 const i = info(); 32 return i.type === "commit" || i.type === "link" ? i.action : i.type; 33 }; 34 35 const copyRecord = (e: MouseEvent) => { 36 e.stopPropagation(); 37 addToClipboard(JSON.stringify(props.record, null, 2)); 38 }; 39 40 return ( 41 <div class="flex flex-col gap-2"> 42 <div class="flex items-start gap-1"> 43 <button 44 type="button" 45 onclick={() => setExpanded(!expanded())} 46 class="dark:hover:bg-dark-200 flex min-w-0 flex-1 items-start gap-2 rounded p-1 text-left hover:bg-neutral-200/70" 47 > 48 <span class="mt-0.5 shrink-0 text-neutral-400 dark:text-neutral-500"> 49 {expanded() ? 50 <span class="iconify lucide--chevron-down"></span> 51 : <span class="iconify lucide--chevron-right"></span>} 52 </span> 53 <div class="flex min-w-0 flex-1 flex-col gap-0.5"> 54 <div class="flex items-center gap-x-1.5 sm:gap-x-2"> 55 <span 56 class={`shrink-0 rounded px-1.5 py-0.5 text-xs font-medium ${TYPE_COLORS[displayType()!] || "bg-neutral-200 text-neutral-700 dark:bg-neutral-700 dark:text-neutral-300"}`} 57 > 58 {displayType()} 59 </span> 60 <Show when={info().collection && info().collection !== info().type}> 61 <span class="min-w-0 truncate text-neutral-600 dark:text-neutral-300"> 62 {info().collection} 63 </span> 64 </Show> 65 <Show when={info().rkey}> 66 <span class="truncate text-neutral-400 dark:text-neutral-500">{info().rkey}</span> 67 </Show> 68 </div> 69 <div class="flex flex-col gap-x-2 gap-y-0.5 text-xs text-neutral-500 sm:flex-row sm:items-center dark:text-neutral-400"> 70 <Show when={info().did}> 71 <span class="w-fit" onclick={(e) => e.stopPropagation()}> 72 <DidHoverCard newTab did={info().did!} /> 73 </span> 74 </Show> 75 <Show when={info().time}> 76 <span>{info().time}</span> 77 </Show> 78 </div> 79 </div> 80 </button> 81 <Show when={expanded()}> 82 <button 83 type="button" 84 onclick={copyRecord} 85 class="flex size-6 shrink-0 items-center justify-center rounded text-neutral-500 transition-colors hover:bg-neutral-200 hover:text-neutral-600 active:bg-neutral-300 sm:size-7 dark:text-neutral-400 dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:active:bg-neutral-600" 86 > 87 <span class="iconify lucide--copy"></span> 88 </button> 89 </Show> 90 </div> 91 <Show when={expanded()}> 92 <div class="ml-6.5"> 93 <div class="w-full text-xs wrap-anywhere whitespace-pre-wrap md:w-2xl"> 94 <JSONValue newTab data={props.record} repo={info().did ?? ""} hideBlobs /> 95 </div> 96 </div> 97 </Show> 98 </div> 99 ); 100}; 101 102export const StreamView = () => { 103 const [searchParams, setSearchParams] = useSearchParams(); 104 const streamType = getStreamType(useLocation().pathname); 105 const config = () => STREAM_CONFIGS[streamType]; 106 107 const [records, setRecords] = createSignal<any[]>([]); 108 const [connected, setConnected] = createSignal(false); 109 const [paused, setPaused] = createSignal(false); 110 const [notice, setNotice] = createSignal(""); 111 const [parameters, setParameters] = createSignal<{ name: string; value?: string }[]>([]); 112 const [stats, setStats] = createSignal<StreamStats>({ 113 totalEvents: 0, 114 eventsPerSecond: 0, 115 eventTypes: {}, 116 collections: {}, 117 }); 118 const [currentTime, setCurrentTime] = createSignal(Date.now()); 119 120 let socket: WebSocket; 121 let firehose: Firehose; 122 let formRef!: HTMLFormElement; 123 let pendingRecords: any[] = []; 124 let rafId: number | null = null; 125 let statsIntervalId: number | null = null; 126 let statsUpdateIntervalId: number | null = null; 127 let currentSecondEventCount = 0; 128 let totalEventsCount = 0; 129 let eventTypesMap: Record<string, number> = {}; 130 let collectionsMap: Record<string, number> = {}; 131 132 const addRecord = (record: any) => { 133 currentSecondEventCount++; 134 totalEventsCount++; 135 136 const rawEventType = record.kind || record.$type || "unknown"; 137 const eventType = rawEventType.includes("#") ? rawEventType.split("#").pop() : rawEventType; 138 eventTypesMap[eventType] = (eventTypesMap[eventType] || 0) + 1; 139 140 if (eventType !== "account" && eventType !== "identity" && eventType !== "sync") { 141 const collection = 142 record.commit?.collection || 143 record.op?.path?.split("/")[0] || 144 record.link?.source || 145 "unknown"; 146 collectionsMap[collection] = (collectionsMap[collection] || 0) + 1; 147 } 148 149 if (!paused()) { 150 pendingRecords.push(record); 151 if (rafId === null) { 152 rafId = requestAnimationFrame(() => { 153 setRecords(records().concat(pendingRecords).slice(-LIMIT)); 154 pendingRecords = []; 155 rafId = null; 156 }); 157 } 158 } 159 }; 160 161 const disconnect = () => { 162 if (!config().useFirehoseLib) socket?.close(); 163 else firehose?.close(); 164 165 if (rafId !== null) { 166 cancelAnimationFrame(rafId); 167 rafId = null; 168 } 169 if (statsIntervalId !== null) { 170 clearInterval(statsIntervalId); 171 statsIntervalId = null; 172 } 173 if (statsUpdateIntervalId !== null) { 174 clearInterval(statsUpdateIntervalId); 175 statsUpdateIntervalId = null; 176 } 177 178 pendingRecords = []; 179 totalEventsCount = 0; 180 eventTypesMap = {}; 181 collectionsMap = {}; 182 setConnected(false); 183 setPaused(false); 184 setStats((prev) => ({ ...prev, eventsPerSecond: 0 })); 185 }; 186 187 const onWebsocketClose = (event: CloseEvent) => { 188 const code = event.code.toString(); 189 if (code === "1000" || code === "1005") return; 190 191 setNotice(`Connection closed: ${websocketCloseReasons[code] ?? "Unknown reason"}`); 192 disconnect(); 193 }; 194 195 const connectStream = async (formData: FormData) => { 196 setNotice(""); 197 if (connected()) { 198 disconnect(); 199 return; 200 } 201 setRecords([]); 202 203 const instance = formData.get("instance")?.toString() ?? config().defaultInstance; 204 const url = config().buildUrl(instance, formData); 205 206 // Save all form fields to URL params 207 const params: Record<string, string | undefined> = { instance }; 208 config().fields.forEach((field) => { 209 params[field.searchParam] = formData.get(field.name)?.toString(); 210 }); 211 setSearchParams(params); 212 213 // Build parameters display 214 setParameters([ 215 { name: "Instance", value: instance }, 216 ...config() 217 .fields.filter((f) => f.type !== "checkbox") 218 .map((f) => ({ name: f.label, value: formData.get(f.name)?.toString() })), 219 ...config() 220 .fields.filter((f) => f.type === "checkbox" && formData.get(f.name) === "on") 221 .map((f) => ({ name: f.label, value: "on" })), 222 ]); 223 224 setConnected(true); 225 const now = Date.now(); 226 setCurrentTime(now); 227 228 totalEventsCount = 0; 229 eventTypesMap = {}; 230 collectionsMap = {}; 231 232 setStats({ 233 connectedAt: now, 234 totalEvents: 0, 235 eventsPerSecond: 0, 236 eventTypes: {}, 237 collections: {}, 238 }); 239 240 statsUpdateIntervalId = window.setInterval(() => { 241 setStats((prev) => ({ 242 ...prev, 243 totalEvents: totalEventsCount, 244 eventTypes: { ...eventTypesMap }, 245 collections: { ...collectionsMap }, 246 })); 247 }, 50); 248 249 statsIntervalId = window.setInterval(() => { 250 setStats((prev) => ({ ...prev, eventsPerSecond: currentSecondEventCount })); 251 currentSecondEventCount = 0; 252 setCurrentTime(Date.now()); 253 }, 1000); 254 255 if (!config().useFirehoseLib) { 256 socket = new WebSocket(url); 257 socket.addEventListener("message", (event) => { 258 const rec = JSON.parse(event.data); 259 const isFilteredEvent = rec.kind === "account" || rec.kind === "identity"; 260 if (!isFilteredEvent || streamType !== "jetstream" || searchParams.allEvents === "on") 261 addRecord(rec); 262 }); 263 socket.addEventListener("close", onWebsocketClose); 264 socket.addEventListener("error", () => { 265 socket.removeEventListener("close", onWebsocketClose); 266 setNotice("Connection error"); 267 disconnect(); 268 }); 269 } else { 270 const cursor = formData.get("cursor")?.toString(); 271 firehose = new Firehose({ 272 relay: url, 273 cursor: cursor, 274 autoReconnect: false, 275 }); 276 firehose.ws.addEventListener("close", onWebsocketClose); 277 firehose.on("error", (err) => { 278 firehose.ws.removeEventListener("close", onWebsocketClose); 279 console.error(err); 280 const message = err instanceof Error ? err.message : "Unknown error"; 281 setNotice(`Connection error: ${message}`); 282 disconnect(); 283 }); 284 firehose.on("commit", (commit) => { 285 for (const op of commit.ops) { 286 addRecord({ 287 $type: commit.$type, 288 repo: commit.repo, 289 seq: commit.seq, 290 time: commit.time, 291 rev: commit.rev, 292 since: commit.since, 293 op: op, 294 }); 295 } 296 }); 297 firehose.on("identity", (identity) => addRecord(identity)); 298 firehose.on("account", (account) => addRecord(account)); 299 firehose.on("sync", (sync) => { 300 addRecord({ 301 $type: sync.$type, 302 did: sync.did, 303 rev: sync.rev, 304 seq: sync.seq, 305 time: sync.time, 306 }); 307 }); 308 firehose.start(); 309 } 310 }; 311 312 onMount(() => { 313 if (searchParams.instance) { 314 const formData = new FormData(); 315 formData.append("instance", searchParams.instance.toString()); 316 config().fields.forEach((field) => { 317 const value = searchParams[field.searchParam]; 318 if (value) formData.append(field.name, value.toString()); 319 }); 320 connectStream(formData); 321 } 322 }); 323 324 onCleanup(() => { 325 socket?.close(); 326 firehose?.close(); 327 if (rafId !== null) cancelAnimationFrame(rafId); 328 if (statsIntervalId !== null) clearInterval(statsIntervalId); 329 if (statsUpdateIntervalId !== null) clearInterval(statsUpdateIntervalId); 330 }); 331 332 return ( 333 <> 334 <Title>{config().label} - PDSls</Title> 335 <div class="flex w-full flex-col items-center gap-2"> 336 {/* Tab Navigation */} 337 <div class="flex gap-4 font-medium"> 338 <For each={STREAM_TYPES}> 339 {(type) => ( 340 <A 341 class="flex items-center gap-1 border-b-2 transition-colors" 342 inactiveClass="border-transparent not-hover:text-neutral-600 not-hover:dark:text-neutral-400" 343 href={`/${type}`} 344 > 345 {STREAM_CONFIGS[type].label} 346 </A> 347 )} 348 </For> 349 </div> 350 351 {/* Stream Description */} 352 <div class="w-full px-2 text-center"> 353 <p class="text-sm text-neutral-600 dark:text-neutral-400">{config().description}</p> 354 </div> 355 356 {/* Connection Form */} 357 <Show when={!connected()}> 358 <form ref={formRef} class="flex w-full flex-col gap-2 p-2 text-sm"> 359 <label class="flex items-center justify-end gap-x-1"> 360 <span class="min-w-21 select-none">Instance</span> 361 <TextInput 362 name="instance" 363 value={searchParams.instance ?? config().defaultInstance} 364 class="grow" 365 /> 366 </label> 367 368 <For each={config().fields}> 369 {(field) => ( 370 <label class="flex items-center justify-end gap-x-1"> 371 <Show when={field.type === "checkbox"}> 372 <input 373 type="checkbox" 374 name={field.name} 375 id={field.name} 376 checked={searchParams[field.searchParam] === "on"} 377 /> 378 </Show> 379 <span class="min-w-21 select-none">{field.label}</span> 380 <Show when={field.type === "textarea"}> 381 <textarea 382 name={field.name} 383 spellcheck={false} 384 placeholder={field.placeholder} 385 value={(searchParams[field.searchParam] as string) ?? ""} 386 class="dark:bg-dark-100 grow rounded-lg bg-white px-2 py-1 outline-1 outline-neutral-200 focus:outline-neutral-400 dark:outline-neutral-600 dark:focus:outline-neutral-400" 387 /> 388 </Show> 389 <Show when={field.type === "text"}> 390 <TextInput 391 name={field.name} 392 placeholder={field.placeholder} 393 value={(searchParams[field.searchParam] as string) ?? ""} 394 class="grow" 395 /> 396 </Show> 397 </label> 398 )} 399 </For> 400 401 <div class="flex justify-end gap-2"> 402 <Button onClick={() => connectStream(new FormData(formRef))}>Connect</Button> 403 </div> 404 </form> 405 </Show> 406 407 {/* Connected State */} 408 <Show when={connected()}> 409 <div class="flex w-full flex-col gap-2 p-2"> 410 <div class="flex flex-col gap-1 text-sm wrap-anywhere"> 411 <div class="font-semibold">Parameters</div> 412 <For each={parameters()}> 413 {(param) => ( 414 <Show when={param.value}> 415 <div class="text-sm"> 416 <div class="text-xs text-neutral-500 dark:text-neutral-400">{param.name}</div> 417 <div class="text-neutral-700 dark:text-neutral-300">{param.value}</div> 418 </div> 419 </Show> 420 )} 421 </For> 422 </div> 423 <StreamStatsPanel 424 stats={stats()} 425 currentTime={currentTime()} 426 streamType={streamType} 427 showAllEvents={searchParams.allEvents === "on"} 428 /> 429 <div class="flex justify-end gap-2"> 430 <Button 431 ontouchstart={(e) => { 432 e.preventDefault(); 433 requestAnimationFrame(() => setPaused(!paused())); 434 }} 435 onClick={() => setPaused(!paused())} 436 > 437 {paused() ? "Resume" : "Pause"} 438 </Button> 439 <Button 440 ontouchstart={(e) => { 441 e.preventDefault(); 442 requestAnimationFrame(() => disconnect()); 443 }} 444 onClick={disconnect} 445 > 446 Disconnect 447 </Button> 448 </div> 449 </div> 450 </Show> 451 452 {/* Error Notice */} 453 <Show when={notice().length}> 454 <div class="text-red-500 dark:text-red-400">{notice()}</div> 455 </Show> 456 457 {/* Records List */} 458 <Show when={connected() || records().length > 0}> 459 <div class="flex min-h-280 w-full flex-col gap-2 font-mono text-xs [overflow-anchor:auto] sm:text-sm"> 460 <For each={records().toReversed()}> 461 {(rec) => ( 462 <div class="[overflow-anchor:none]"> 463 <StreamRecordItem record={rec} streamType={streamType} /> 464 </div> 465 )} 466 </For> 467 <div class="h-px [overflow-anchor:auto]" /> 468 </div> 469 </Show> 470 </div> 471 </> 472 ); 473};