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