tracks lexicons and how many times they appeared on the jetstream
at main 13 kB view raw
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>