forked from
pds.ls/pdsls
atmosphere explorer
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};