tracks lexicons and how many times they appeared on the jetstream
1<script lang="ts">
2 import { dev } from "$app/environment";
3 import type {
4 EventRecord,
5 Events,
6 NsidCount,
7 ShowOption,
8 Since,
9 SortOption,
10 } from "$lib/types";
11 import { onMount, onDestroy } from "svelte";
12 import { get, writable } from "svelte/store";
13 import { PUBLIC_API_URL } from "$env/static/public";
14 import { fetchEvents, fetchTrackingSince } from "$lib/api";
15 import { createRegexFilter } from "$lib/filter";
16 import StatsCard from "$lib/components/StatsCard.svelte";
17 import StatusBadge from "$lib/components/StatusBadge.svelte";
18 import EventCard from "$lib/components/EventCard.svelte";
19 import FilterControls from "$lib/components/FilterControls.svelte";
20 import SortControls from "$lib/components/SortControls.svelte";
21 import BskyToggle from "$lib/components/BskyToggle.svelte";
22 import RefreshControl from "$lib/components/RefreshControl.svelte";
23 import { formatTimestamp } from "$lib/format";
24 import ShowControls from "$lib/components/ShowControls.svelte";
25
26 type Props = {
27 data: { events: Events; trackingSince: Since };
28 };
29
30 const { data }: Props = $props();
31
32 const events = writable(
33 new Map<string, EventRecord>(Object.entries(data.events.events)),
34 );
35 const eventsStart = new Map<string, EventRecord>(
36 Object.entries(data.events.events),
37 );
38 const pendingUpdates = new Map<string, EventRecord>();
39
40 let updateTimer: NodeJS.Timeout | null = null;
41 let per_second = $state(data.events.per_second);
42 let tracking_since = $state(data.trackingSince.since);
43
44 const diffEvents = (
45 oldEvents: Map<string, EventRecord>,
46 newEvents: Map<string, EventRecord>,
47 ): NsidCount[] => {
48 const nsidCounts: NsidCount[] = [];
49 for (const [nsid, event] of newEvents.entries()) {
50 const oldEvent = oldEvents.get(nsid);
51 if (oldEvent) {
52 const counts = {
53 nsid,
54 count: event.count - oldEvent.count,
55 deleted_count: event.deleted_count - oldEvent.deleted_count,
56 last_seen: event.last_seen,
57 };
58 if (counts.count > 0 || counts.deleted_count > 0)
59 nsidCounts.push(counts);
60 } else {
61 nsidCounts.push({
62 nsid,
63 ...event,
64 });
65 }
66 }
67 return nsidCounts;
68 };
69 const applyEvents = (newEvents: Record<string, EventRecord>) => {
70 events.update((map) => {
71 for (const [nsid, event] of Object.entries(newEvents)) {
72 map.set(nsid, event);
73 }
74 return map;
75 });
76 };
77
78 let error: string | null = $state(null);
79 let filterRegex = $state("");
80 let dontShowBsky = $state(false);
81 let sortBy: SortOption = $state("total");
82 let refreshRate = $state("");
83 let changedByUser = $state(false);
84 let show: ShowOption = $state("server init");
85 let eventsList: NsidCount[] = $state([]);
86 let updateEventsList = $derived((value: Map<string, EventRecord>) => {
87 switch (show) {
88 case "server init":
89 eventsList = value
90 .entries()
91 .map(([nsid, event]) => ({
92 nsid,
93 ...event,
94 }))
95 .toArray();
96 break;
97 case "stream start":
98 eventsList = diffEvents(eventsStart, value);
99 break;
100 }
101 });
102 events.subscribe((value) => updateEventsList(value));
103 let all: EventRecord = $derived(
104 eventsList.reduce(
105 (acc, event) => {
106 return {
107 last_seen:
108 acc.last_seen > event.last_seen
109 ? acc.last_seen
110 : event.last_seen,
111 count: acc.count + event.count,
112 deleted_count: acc.deleted_count + event.deleted_count,
113 };
114 },
115 {
116 last_seen: 0,
117 count: 0,
118 deleted_count: 0,
119 },
120 ),
121 );
122
123 let websocket: WebSocket | null = null;
124 let isStreamOpen = $state(false);
125 let websocketStatus = $state<
126 "connecting" | "connected" | "disconnected" | "error"
127 >("disconnected");
128 const connectToStream = async () => {
129 if (isStreamOpen) return;
130 websocketStatus = "connecting";
131 websocket = new WebSocket(
132 `${dev ? "ws" : "wss"}://${PUBLIC_API_URL}/stream_events`,
133 );
134 websocket.binaryType = "arraybuffer";
135 websocket.onopen = () => {
136 console.log("ws opened");
137 isStreamOpen = true;
138 websocketStatus = "connected";
139 };
140 websocket.onmessage = async (event) => {
141 const jsonData = JSON.parse(event.data);
142 per_second = jsonData.per_second;
143 if (refreshRate) {
144 for (const [nsid, event] of Object.entries(jsonData.events)) {
145 pendingUpdates.set(nsid, event as EventRecord);
146 }
147 } else {
148 applyEvents(jsonData.events);
149 }
150 };
151 websocket.onerror = (error) => {
152 console.error("ws error:", error);
153 websocketStatus = "error";
154 };
155 websocket.onclose = () => {
156 console.log("ws closed");
157 isStreamOpen = false;
158 websocketStatus = "disconnected";
159 };
160 };
161
162 const loadData = async () => {
163 try {
164 error = null;
165 const data = await fetchEvents();
166 per_second = data.per_second;
167 applyEvents(data.events);
168 tracking_since = (await fetchTrackingSince()).since;
169 } catch (err) {
170 error =
171 err instanceof Error
172 ? err.message
173 : "an unknown error occurred";
174 console.error("error loading data:", err);
175 }
176 };
177
178 // Update the refresh timer when refresh rate changes
179 $effect(() => {
180 if (updateTimer) {
181 clearInterval(updateTimer);
182 updateTimer = null;
183 }
184
185 if (refreshRate) {
186 const rate = parseInt(refreshRate, 10) * 1000; // Convert to milliseconds
187 if (!isNaN(rate) && rate > 0) {
188 updateTimer = setInterval(() => {
189 if (pendingUpdates.size > 0) {
190 events.update((map) => {
191 for (const [nsid, event] of pendingUpdates) {
192 map.set(nsid, event);
193 }
194 pendingUpdates.clear();
195 return map;
196 });
197 }
198 }, rate);
199 }
200 }
201 });
202
203 onMount(() => {
204 loadData();
205 connectToStream();
206 });
207
208 onDestroy(() => {
209 // Clear refresh timer
210 if (updateTimer) {
211 clearInterval(updateTimer);
212 updateTimer = null;
213 }
214 // Close WebSocket connection
215 if (websocket) {
216 websocket.close();
217 }
218 });
219
220 const sortEvents = (events: NsidCount[], sortBy: SortOption) => {
221 const sorted = [...events];
222 switch (sortBy) {
223 case "total":
224 sorted.sort(
225 (a, b) =>
226 b.count + b.deleted_count - (a.count + a.deleted_count),
227 );
228 break;
229 case "created":
230 sorted.sort((a, b) => b.count - a.count);
231 break;
232 case "deleted":
233 sorted.sort((a, b) => b.deleted_count - a.deleted_count);
234 break;
235 case "date":
236 sorted.sort((a, b) => b.last_seen - a.last_seen);
237 break;
238 }
239 return sorted;
240 };
241
242 const filterEvents = (events: NsidCount[]) => {
243 let filtered = events;
244
245 // Apply regex filter
246 if (filterRegex) {
247 const regex = createRegexFilter(filterRegex);
248 if (regex) {
249 filtered = filtered.filter((e) => regex.test(e.nsid));
250 }
251 }
252
253 // Apply app.bsky filter
254 if (dontShowBsky) {
255 filtered = filtered.filter((e) => !e.nsid.startsWith("app.bsky."));
256 }
257
258 return filtered;
259 };
260</script>
261
262<svelte:head>
263 <title>lexicon tracker</title>
264 <meta
265 name="description"
266 content="tracks bluesky jetstream events by collection"
267 />
268</svelte:head>
269
270<header
271 class="bg-white dark:bg-gray-900 border-gray-300 dark:border-gray-950 border-b mb-4 pb-2"
272>
273 <div
274 class="px-2 md:ml-[19vw] mx-auto flex flex-wrap items-center text-center"
275 >
276 <h1 class="text-4xl font-bold mr-4 text-gray-900 dark:text-gray-200">
277 lexicon tracker
278 </h1>
279 <p class="text-lg mt-1 text-gray-600 dark:text-gray-300">
280 tracks lexicons seen on the jetstream {tracking_since === 0
281 ? ""
282 : `(since: ${formatTimestamp(tracking_since)})`}
283 </p>
284 </div>
285</header>
286<div class="bg-white dark:bg-gray-900 md:max-w-[61vw] mx-auto p-2">
287 <div class="min-w-fit grid grid-cols-2 xl:grid-cols-4 gap-2 2xl:gap-6 mb-8">
288 <StatsCard
289 title="total creation"
290 value={all.count}
291 colorScheme="green"
292 />
293 <StatsCard
294 title="total deletion"
295 value={all.deleted_count}
296 colorScheme="red"
297 />
298 <StatsCard
299 title="per second"
300 value={per_second}
301 colorScheme="turqoise"
302 />
303 <StatsCard
304 title="unique collections"
305 value={eventsList.length}
306 colorScheme="orange"
307 />
308 </div>
309
310 {#if error}
311 <div
312 class="bg-red-100 dark:bg-red-900 border border-red-300 dark:border-red-700 text-red-700 dark:text-red-200 px-4 py-3 rounded-lg mb-6"
313 >
314 <p>Error: {error}</p>
315 </div>
316 {/if}
317
318 {#if eventsList.length > 0}
319 <div class="mb-8">
320 <div class="flex flex-wrap items-center gap-3 mb-3">
321 <h2 class="text-2xl font-bold text-gray-900 dark:text-gray-200">
322 seen lexicons
323 </h2>
324 <StatusBadge status={websocketStatus} />
325 </div>
326 <div class="flex flex-wrap items-center gap-1.5 mb-6">
327 <FilterControls
328 {filterRegex}
329 onFilterChange={(value) => (filterRegex = value)}
330 />
331 <BskyToggle
332 {dontShowBsky}
333 onBskyToggle={() => (dontShowBsky = !dontShowBsky)}
334 />
335 <SortControls
336 {sortBy}
337 onSortChange={(value: SortOption) => {
338 sortBy = value;
339 if (refreshRate === "" && sortBy === "date")
340 refreshRate = "2";
341 else if (refreshRate === "2" && changedByUser === false)
342 refreshRate = "";
343 }}
344 />
345 <ShowControls
346 {show}
347 onShowChange={(value: ShowOption) => {
348 show = value;
349 updateEventsList(get(events));
350 }}
351 />
352 <RefreshControl
353 {refreshRate}
354 onRefreshChange={(value) => {
355 refreshRate = value;
356 changedByUser = refreshRate !== "";
357 }}
358 />
359 </div>
360 <div
361 class="grid grid-cols-1 lg:grid-cols-2 xl:grid-cols-3 2xl:grid-cols-4 gap-4"
362 >
363 {#each sortEvents(filterEvents(eventsList), sortBy) as event, index (event.nsid)}
364 <EventCard {event} {index} />
365 {/each}
366 </div>
367 </div>
368 {:else}
369 <div class="text-center py-12 bg-gray-50 rounded-lg">
370 <div class="text-gray-400 text-4xl mb-4">📊</div>
371 <p class="text-gray-600">no events tracked yet. try refreshing?</p>
372 </div>
373 {/if}
374</div>
375
376<footer class="py-2 border-t border-gray-200 dark:border-gray-800 text-center">
377 <p class="text-gray-600 dark:text-gray-200 text-sm">
378 source code <a
379 href="https://tangled.sh/@poor.dog/nsid-tracker"
380 target="_blank"
381 rel="noopener noreferrer"
382 class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-600 underline"
383 >@poor.dog/nsid-tracker</a
384 >
385 </p>
386</footer>