tracks lexicons and how many times they appeared on the jetstream
3
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add streaming, improve client by a ton

dusk 9a693fc1 24f00247

+642 -100
+30 -1
client/src/app.css
··· 1 - @import 'tailwindcss'; 1 + @import "tailwindcss"; 2 + 3 + /* Make scrollbar overlay without affecting layout */ 4 + html { 5 + scrollbar-width: thin; 6 + } 7 + 8 + /* Webkit browsers (Chrome, Safari, Edge) */ 9 + ::-webkit-scrollbar { 10 + width: 8px; 11 + } 12 + 13 + ::-webkit-scrollbar-track { 14 + background: transparent; 15 + } 16 + 17 + ::-webkit-scrollbar-thumb { 18 + background: rgba(0, 0, 0, 0.3); 19 + border-radius: 4px; 20 + } 21 + 22 + ::-webkit-scrollbar-thumb:hover { 23 + background: rgba(0, 0, 0, 0.5); 24 + } 25 + 26 + /* Ensure scrollbar doesn't take up layout space */ 27 + body { 28 + overflow-y: overlay; 29 + overflow-y: auto; /* Fallback for browsers that don't support overlay */ 30 + }
+152
client/src/lib/components/EventCard.svelte
··· 1 + <script lang="ts"> 2 + import type { EventRecord } from "$lib/types"; 3 + import { onMount, onDestroy } from "svelte"; 4 + 5 + interface Props { 6 + nsid: string; 7 + event: EventRecord; 8 + index: number; 9 + formatNumber: (num: number) => string; 10 + formatTimestamp: (timestamp: number) => string; 11 + } 12 + 13 + let { nsid, event, index, formatNumber, formatTimestamp }: Props = $props(); 14 + 15 + // Border animation state 16 + let borderThickness = $state(0); 17 + let lastEventTime = $state(0); 18 + let lastCount = $state(0); 19 + let lastDeletedCount = $state(0); 20 + let decayTimer: ReturnType<typeof setTimeout> | null = null; 21 + let isAnimating = $state(false); 22 + 23 + // Constants for border behavior 24 + const MAX_BORDER_THICKNESS = 7; // Maximum border thickness in pixels 25 + const INITIAL_THICKNESS_ADD = 2; // How much thickness to add for first/slow events 26 + const RAPID_SUCCESSION_THRESHOLD = 50; // ms - events faster than this are considered rapid 27 + const DECAY_RATE = 0.1; // How much thickness to remove per decay tick 28 + const DECAY_INTERVAL = 45; // ms between decay ticks 29 + 30 + // Track when event data changes 31 + $effect(() => { 32 + const currentTime = Date.now(); 33 + const hasChanged = 34 + event.count !== lastCount || 35 + event.deleted_count !== lastDeletedCount; 36 + 37 + if (hasChanged && (lastCount > 0 || lastDeletedCount > 0)) { 38 + const timeSinceLastUpdate = currentTime - lastEventTime; 39 + 40 + // Calculate how much thickness to add based on timing 41 + let thicknessToAdd; 42 + if (timeSinceLastUpdate < RAPID_SUCCESSION_THRESHOLD) { 43 + // Rapid succession - add less thickness with a decay factor 44 + const rapidnessFactor = Math.max( 45 + 0.1, 46 + timeSinceLastUpdate / RAPID_SUCCESSION_THRESHOLD, 47 + ); 48 + thicknessToAdd = INITIAL_THICKNESS_ADD * rapidnessFactor * 0.5; 49 + } else { 50 + // Normal/slow event - add full thickness 51 + thicknessToAdd = INITIAL_THICKNESS_ADD; 52 + } 53 + 54 + // Add thickness but cap at maximum 55 + borderThickness = Math.min( 56 + MAX_BORDER_THICKNESS, 57 + borderThickness + thicknessToAdd, 58 + ); 59 + isAnimating = true; 60 + lastEventTime = currentTime; 61 + 62 + // Start/restart continuous decay 63 + if (decayTimer) { 64 + clearTimeout(decayTimer); 65 + } 66 + decayTimer = setTimeout(startDecay, DECAY_INTERVAL); 67 + } 68 + 69 + lastCount = event.count; 70 + lastDeletedCount = event.deleted_count; 71 + }); 72 + 73 + const startDecay = () => { 74 + if (borderThickness <= 0) { 75 + isAnimating = false; 76 + decayTimer = null; 77 + return; 78 + } 79 + 80 + borderThickness = Math.max(0, borderThickness - DECAY_RATE); 81 + 82 + if (borderThickness > 0) { 83 + decayTimer = setTimeout(startDecay, DECAY_INTERVAL); 84 + } else { 85 + isAnimating = false; 86 + decayTimer = null; 87 + } 88 + }; 89 + 90 + onMount(() => { 91 + // Initialize with current values to avoid triggering animation on mount 92 + lastCount = event.count; 93 + lastDeletedCount = event.deleted_count; 94 + lastEventTime = Date.now(); 95 + 96 + // Start continuous decay immediately 97 + decayTimer = setTimeout(startDecay, DECAY_INTERVAL); 98 + }); 99 + 100 + onDestroy(() => { 101 + // Clean up decay timer 102 + if (decayTimer) { 103 + clearTimeout(decayTimer); 104 + } 105 + }); 106 + </script> 107 + 108 + <div 109 + class="mx-auto md:mx-0 bg-white border border-gray-200 rounded-lg p-2 md:p-6 hover:shadow-lg transition-all duration-200 hover:-translate-y-1 transform" 110 + class:has-activity={isAnimating} 111 + style="--border-thickness: {borderThickness}px" 112 + > 113 + <div class="flex justify-between items-start mb-3"> 114 + <div 115 + class="text-sm font-bold text-blue-600 bg-blue-100 px-3 py-1 rounded-full" 116 + > 117 + #{index + 1} 118 + </div> 119 + </div> 120 + <div class="font-mono text-sm text-gray-700 mb-2 break-all leading-relaxed"> 121 + {nsid} 122 + </div> 123 + <div class="text-lg font-bold text-green-600"> 124 + {formatNumber(event.count)} created 125 + </div> 126 + <div class="text-lg font-bold text-red-600 mb-3"> 127 + {formatNumber(event.deleted_count)} deleted 128 + </div> 129 + <div class="text-xs text-gray-500"> 130 + last: {formatTimestamp(event.last_seen)} 131 + </div> 132 + </div> 133 + 134 + <style> 135 + .has-activity { 136 + position: relative; 137 + transition: all 0.2s ease-out; 138 + } 139 + 140 + .has-activity::before { 141 + content: ""; 142 + position: absolute; 143 + top: calc(-1 * var(--border-thickness)); 144 + left: calc(-1 * var(--border-thickness)); 145 + right: calc(-1 * var(--border-thickness)); 146 + bottom: calc(-1 * var(--border-thickness)); 147 + border: var(--border-thickness) solid rgba(59, 130, 246, 0.8); 148 + border-radius: calc(0.5rem + var(--border-thickness)); 149 + pointer-events: none; 150 + transition: all 0.3s ease-out; 151 + } 152 + </style>
+44
client/src/lib/components/FilterControls.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + filterRegex: string; 4 + dontShowBsky: boolean; 5 + onFilterChange: (value: string) => void; 6 + onBskyToggle: () => void; 7 + } 8 + 9 + let { filterRegex, dontShowBsky, onFilterChange, onBskyToggle }: Props = 10 + $props(); 11 + </script> 12 + 13 + <div class="flex flex-wrap items-center gap-3 mb-6"> 14 + <div 15 + class="wsbadge !pl-2 !px-1 !mt-0 !font-normal bg-blue-100 hover:bg-blue-200 border-blue-300" 16 + > 17 + <label for="filter-regex" class="text-blue-800 mr-1"> filter: </label> 18 + <input 19 + id="filter-regex" 20 + value={filterRegex} 21 + oninput={(e) => 22 + onFilterChange((e.target as HTMLInputElement).value)} 23 + type="text" 24 + placeholder="regex..." 25 + class="bg-blue-50 text-blue-900 placeholder-blue-400 border border-blue-200 rounded-full px-1 outline-none focus:bg-white focus:border-blue-400 min-w-0 w-24" 26 + /> 27 + </div> 28 + <!-- svelte-ignore a11y_click_events_have_key_events --> 29 + <!-- svelte-ignore a11y_no_static_element_interactions --> 30 + <button 31 + onclick={onBskyToggle} 32 + class="wsbadge !mt-0 !font-normal bg-yellow-100 hover:bg-yellow-200 border-yellow-300" 33 + > 34 + <input checked={dontShowBsky} type="checkbox" /> 35 + <span class="ml-0.5"> don't show app.bsky.* </span> 36 + </button> 37 + </div> 38 + 39 + <style lang="postcss"> 40 + @reference "../../app.css"; 41 + .wsbadge { 42 + @apply text-sm font-semibold mt-1.5 px-2.5 py-0.5 rounded-full border; 43 + } 44 + </style>
+44
client/src/lib/components/StatsCard.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + title: string; 4 + value: number; 5 + colorScheme: 'green' | 'red' | 'orange'; 6 + formatNumber: (num: number) => string; 7 + } 8 + 9 + let { title, value, colorScheme, formatNumber }: Props = $props(); 10 + 11 + const colorClasses = { 12 + green: { 13 + bg: 'from-green-50 to-green-100', 14 + border: 'border-green-200', 15 + titleText: 'text-green-700', 16 + valueText: 'text-green-900' 17 + }, 18 + red: { 19 + bg: 'from-red-50 to-red-100', 20 + border: 'border-red-200', 21 + titleText: 'text-red-700', 22 + valueText: 'text-red-900' 23 + }, 24 + orange: { 25 + bg: 'from-orange-50 to-orange-100', 26 + border: 'border-orange-200', 27 + titleText: 'text-orange-700', 28 + valueText: 'text-orange-900' 29 + } 30 + }; 31 + 32 + const colors = $derived(colorClasses[colorScheme]); 33 + </script> 34 + 35 + <div 36 + class="bg-gradient-to-r {colors.bg} p-3 md:p-6 rounded-lg border {colors.border}" 37 + > 38 + <h3 class="text-base font-medium {colors.titleText} mb-2"> 39 + {title} 40 + </h3> 41 + <p class="text-3xl font-bold {colors.valueText}"> 42 + {formatNumber(value)} 43 + </p> 44 + </div>
+39
client/src/lib/components/StatusBadge.svelte
··· 1 + <script lang="ts"> 2 + interface Props { 3 + status: "connecting" | "connected" | "disconnected" | "error"; 4 + } 5 + 6 + let { status }: Props = $props(); 7 + 8 + const statusConfig = { 9 + connected: { 10 + text: "live", 11 + classes: "bg-green-100 text-green-800 border-green-200", 12 + }, 13 + connecting: { 14 + text: "connecting", 15 + classes: "bg-yellow-100 text-yellow-800 border-yellow-200", 16 + }, 17 + error: { 18 + text: "error", 19 + classes: "bg-red-100 text-red-800 border-red-200", 20 + }, 21 + disconnected: { 22 + text: "offline", 23 + classes: "bg-gray-100 text-gray-800 border-gray-200", 24 + }, 25 + }; 26 + 27 + const config = $derived(statusConfig[status]); 28 + </script> 29 + 30 + <span class="wsbadge {config.classes}"> 31 + {config.text} 32 + </span> 33 + 34 + <style lang="postcss"> 35 + @reference "../../app.css"; 36 + .wsbadge { 37 + @apply text-sm font-semibold mt-1.5 px-2.5 py-0.5 rounded-full border; 38 + } 39 + </style>
-1
client/src/lib/types.ts
··· 1 1 export type EventRecord = { 2 - nsid: string; 3 2 last_seen: number; 4 3 count: number; 5 4 deleted_count: number;
+232 -93
client/src/routes/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { dev } from "$app/environment"; 3 3 import type { EventRecord } from "$lib/types"; 4 - import { onMount } from "svelte"; 4 + import { onMount, onDestroy } from "svelte"; 5 + import { get, writable } from "svelte/store"; 6 + import StatsCard from "$lib/components/StatsCard.svelte"; 7 + import StatusBadge from "$lib/components/StatusBadge.svelte"; 8 + import EventCard from "$lib/components/EventCard.svelte"; 9 + import FilterControls from "$lib/components/FilterControls.svelte"; 10 + 11 + const events = writable(new Map<string, EventRecord>()); 12 + let eventsList: { nsid: string; event: EventRecord }[] = $state([]); 13 + events.subscribe((value) => { 14 + eventsList = value 15 + .entries() 16 + .map(([nsid, event]) => ({ 17 + nsid, 18 + event, 19 + })) 20 + .toArray(); 21 + eventsList.sort((a, b) => b.event.count - a.event.count); 22 + }); 23 + 24 + // Backpressure system 25 + let eventBuffer: { nsid: string; event: EventRecord }[] = []; 26 + let updateTimer: number | null = null; 27 + let bufferedEventsCount = $state(0); 28 + const BATCH_SIZE = 10; 29 + const UPDATE_INTERVAL = 100; // ms 5 30 6 - let events: EventRecord[] = $state([]); 31 + const flushEventBuffer = () => { 32 + if (eventBuffer.length === 0) return; 33 + 34 + events.update((map) => { 35 + for (const { nsid, event } of eventBuffer) { 36 + map.set(nsid, event); 37 + } 38 + return map; 39 + }); 40 + 41 + eventBuffer = []; 42 + bufferedEventsCount = 0; 43 + }; 44 + 45 + const scheduleUpdate = () => { 46 + if (updateTimer !== null) return; 47 + 48 + updateTimer = window.setTimeout(() => { 49 + flushEventBuffer(); 50 + updateTimer = null; 51 + }, UPDATE_INTERVAL); 52 + }; 7 53 let all: EventRecord = $derived( 8 - events.reduce( 9 - (acc, event) => { 54 + eventsList.reduce( 55 + (acc, { nsid, event }) => { 10 56 return { 11 - nsid: "*", 12 57 last_seen: 13 58 acc.last_seen > event.last_seen 14 59 ? acc.last_seen ··· 18 63 }; 19 64 }, 20 65 { 21 - nsid: "*", 22 66 last_seen: 0, 23 67 count: 0, 24 68 deleted_count: 0, ··· 26 70 ), 27 71 ); 28 72 let error: string | null = $state(null); 73 + let filterRegex = $state(""); 29 74 let dontShowBsky = $state(false); 30 75 76 + let websocket: WebSocket | null = null; 77 + let isStreamOpen = $state(false); 78 + let websocketStatus = $state< 79 + "connecting" | "connected" | "disconnected" | "error" 80 + >("disconnected"); 81 + const connectToStream = async () => { 82 + if (isStreamOpen) return; 83 + websocketStatus = "connecting"; 84 + websocket = new WebSocket( 85 + dev ? "ws://localhost:3000/stream_events" : "/stream_events", 86 + ); 87 + websocket.binaryType = "arraybuffer"; 88 + websocket.onopen = () => { 89 + console.log("ws opened"); 90 + isStreamOpen = true; 91 + websocketStatus = "connected"; 92 + }; 93 + websocket.onmessage = async (event) => { 94 + const view = new DataView(event.data); 95 + const decoder = new TextDecoder("utf-8"); 96 + const jsonStr = decoder.decode(view); 97 + const jsonData = JSON.parse(jsonStr); 98 + 99 + // Add to buffer instead of immediate update 100 + eventBuffer.push({ nsid: jsonData.nsid, event: jsonData }); 101 + bufferedEventsCount = eventBuffer.length; 102 + 103 + // If buffer is full, flush immediately 104 + if (eventBuffer.length >= BATCH_SIZE) { 105 + if (updateTimer !== null) { 106 + window.clearTimeout(updateTimer); 107 + updateTimer = null; 108 + } 109 + flushEventBuffer(); 110 + } else { 111 + // Otherwise schedule a delayed update 112 + scheduleUpdate(); 113 + } 114 + }; 115 + websocket.onerror = (error) => { 116 + console.error("ws error:", error); 117 + websocketStatus = "error"; 118 + }; 119 + websocket.onclose = () => { 120 + console.log("ws closed"); 121 + isStreamOpen = false; 122 + websocketStatus = "disconnected"; 123 + 124 + // Flush any remaining events when connection closes 125 + if (updateTimer !== null) { 126 + window.clearTimeout(updateTimer); 127 + updateTimer = null; 128 + } 129 + flushEventBuffer(); 130 + }; 131 + }; 132 + 31 133 const loadData = async () => { 32 134 try { 33 135 error = null; 34 136 35 - const response = dev 36 - ? await fetch("http://localhost:3000/events") 37 - : await fetch("/api/events"); 137 + const response = await fetch( 138 + dev ? "http://localhost:3000/events" : "/api/events", 139 + ); 38 140 if (!response.ok) { 39 141 throw new Error(`HTTP error! status: ${response.status}`); 40 142 } 41 143 42 144 const data = await response.json(); 43 - events = data.events; 145 + events.update((map) => { 146 + for (const event of data.events) { 147 + map.set(event.nsid, event); 148 + } 149 + return map; 150 + }); 44 151 } catch (err) { 45 152 error = 46 153 err instanceof Error ··· 52 159 53 160 onMount(() => { 54 161 loadData(); 162 + connectToStream(); 163 + }); 164 + 165 + onDestroy(() => { 166 + // Clean up timer and flush any remaining events 167 + if (updateTimer !== null) { 168 + window.clearTimeout(updateTimer); 169 + updateTimer = null; 170 + } 171 + flushEventBuffer(); 172 + 173 + // Close WebSocket connection 174 + if (websocket) { 175 + websocket.close(); 176 + } 55 177 }); 56 178 57 179 const formatNumber = (num: number): string => { ··· 61 183 const formatTimestamp = (timestamp: number): string => { 62 184 return new Date(timestamp / 1000).toLocaleString(); 63 185 }; 186 + 187 + const createRegexFilter = (pattern: string): RegExp | null => { 188 + if (!pattern) return null; 189 + 190 + try { 191 + // Check if pattern contains regex metacharacters 192 + const hasRegexChars = /[.*+?^${}()|[\]\\]/.test(pattern); 193 + 194 + if (hasRegexChars) { 195 + // Use as regex with case-insensitive flag 196 + return new RegExp(pattern, "i"); 197 + } else { 198 + // Smart case: case-insensitive unless pattern has uppercase 199 + const hasUppercase = /[A-Z]/.test(pattern); 200 + const flags = hasUppercase ? "" : "i"; 201 + // Escape the pattern for literal matching 202 + const escapedPattern = pattern.replace( 203 + /[.*+?^${}()|[\]\\]/g, 204 + "\\$&", 205 + ); 206 + return new RegExp(escapedPattern, flags); 207 + } 208 + } catch (e) { 209 + // Invalid regex, return null 210 + return null; 211 + } 212 + }; 213 + 214 + const filterEvents = (events: { nsid: string; event: EventRecord }[]) => { 215 + let filtered = events; 216 + 217 + // Apply regex filter 218 + if (filterRegex) { 219 + const regex = createRegexFilter(filterRegex); 220 + if (regex) { 221 + filtered = filtered.filter((e) => regex.test(e.nsid)); 222 + } 223 + } 224 + 225 + // Apply app.bsky filter 226 + if (dontShowBsky) { 227 + filtered = filtered.filter((e) => !e.nsid.startsWith("app.bsky.")); 228 + } 229 + 230 + return filtered; 231 + }; 64 232 </script> 65 233 66 234 <svelte:head> ··· 78 246 </p> 79 247 </header> 80 248 81 - <div class="mx-auto w-fit grid grid-cols-2 md:grid-cols-3 gap-5 mb-8"> 82 - <div 83 - class="bg-gradient-to-r from-green-50 to-green-100 p-3 md:p-6 rounded-lg border border-green-200" 84 - > 85 - <h3 class="text-base font-medium text-green-700 mb-2"> 86 - total creation 87 - </h3> 88 - <p class="text-3xl font-bold text-green-900"> 89 - {// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain 90 - formatNumber(all.count)} 91 - </p> 92 - </div> 93 - <div 94 - class="bg-gradient-to-r from-red-50 to-red-100 p-3 md:p-6 rounded-lg border border-red-200" 95 - > 96 - <h3 class="text-base font-medium text-red-700 mb-2"> 97 - total deletion 98 - </h3> 99 - <p class="text-3xl font-bold text-red-900"> 100 - {// eslint-disable-next-line @typescript-eslint/no-non-null-asserted-optional-chain 101 - formatNumber(all.deleted_count)} 102 - </p> 103 - </div> 104 - <div 105 - class="bg-gradient-to-r from-orange-50 to-orange-100 p-3 md:p-6 rounded-lg border border-orange-200" 106 - > 107 - <h3 class="text-base font-medium text-orange-700 mb-2"> 108 - unique collections 109 - </h3> 110 - <p class="text-3xl font-bold text-orange-900"> 111 - {formatNumber(events.length)} 112 - </p> 113 - </div> 114 - </div> 115 - 116 - <div class="w-fit flex flex-col items-center mx-auto mb-8"> 117 - <button 118 - onclick={loadData} 119 - class="w-fit bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-6 py-3 rounded-lg font-medium transition-colors" 120 - > 121 - refresh 122 - </button> 123 - <!-- svelte-ignore a11y_click_events_have_key_events --> 124 - <!-- svelte-ignore a11y_no_static_element_interactions --> 125 - <button 126 - onclick={() => (dontShowBsky = !dontShowBsky)} 127 - class="mt-2 bg-yellow-100 hover:bg-yellow-200 px-2 py-1 rounded-full" 128 - > 129 - <input bind:checked={dontShowBsky} type="checkbox" /> 130 - <span class="ml-1"> don't show app.bsky.* </span> 131 - </button> 249 + <div 250 + class="mx-auto w-fit grid grid-cols-2 md:grid-cols-3 gap-2 md:gap-5 mb-8" 251 + > 252 + <StatsCard 253 + title="total creation" 254 + value={all.count} 255 + colorScheme="green" 256 + {formatNumber} 257 + /> 258 + <StatsCard 259 + title="total deletion" 260 + value={all.deleted_count} 261 + colorScheme="red" 262 + {formatNumber} 263 + /> 264 + <StatsCard 265 + title="unique collections" 266 + value={eventsList.length} 267 + colorScheme="orange" 268 + {formatNumber} 269 + /> 132 270 </div> 133 271 134 272 {#if error} ··· 139 277 </div> 140 278 {/if} 141 279 142 - {#if events.length > 0} 280 + {#if eventsList.length > 0} 143 281 <div class="mb-8"> 144 - <h2 class="text-2xl font-bold mb-6 text-gray-900"> 145 - events by collection 146 - </h2> 282 + <div class="flex flex-wrap items-center gap-3 mb-3"> 283 + <h2 class="text-2xl font-bold text-gray-900"> 284 + events by collection 285 + </h2> 286 + <StatusBadge status={websocketStatus} /> 287 + </div> 288 + <FilterControls 289 + {filterRegex} 290 + {dontShowBsky} 291 + onFilterChange={(value) => (filterRegex = value)} 292 + onBskyToggle={() => (dontShowBsky = !dontShowBsky)} 293 + /> 147 294 <div class="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4"> 148 - {#each events.filter((e) => { 149 - return dontShowBsky ? !e.nsid.startsWith("app.bsky.") : true; 150 - }) as event, index (event.nsid)} 151 - <div 152 - class="mx-auto md:mx-0 bg-white border border-gray-200 rounded-lg md:p-6 hover:shadow-lg transition-shadow duration-200 hover:-translate-y-1 transform" 153 - > 154 - <div class="flex justify-between items-start mb-3"> 155 - <div 156 - class="text-sm font-bold text-blue-600 bg-blue-100 px-3 py-1 rounded-full" 157 - > 158 - #{index + 1} 159 - </div> 160 - </div> 161 - <div 162 - class="font-mono text-sm text-gray-700 mb-2 break-all leading-relaxed" 163 - > 164 - {event.nsid} 165 - </div> 166 - <div class="text-lg font-bold text-green-600"> 167 - {formatNumber(event.count)} created 168 - </div> 169 - <div class="text-lg font-bold text-red-600 mb-3"> 170 - {formatNumber(event.deleted_count)} deleted 171 - </div> 172 - <div class="text-xs text-gray-500"> 173 - last: {formatTimestamp(event.last_seen)} 174 - </div> 175 - </div> 295 + {#each filterEvents(eventsList) as { nsid, event }, index (nsid)} 296 + <EventCard 297 + {nsid} 298 + {event} 299 + {index} 300 + {formatNumber} 301 + {formatTimestamp} 302 + /> 176 303 {/each} 177 304 </div> 178 305 </div> ··· 183 310 </div> 184 311 {/if} 185 312 </div> 313 + 314 + <footer class="py-2 border-t border-gray-200 text-center"> 315 + <p class="text-gray-600 text-sm"> 316 + source code <a 317 + href="https://tangled.sh/@poor.dog/nsid-tracker" 318 + target="_blank" 319 + rel="noopener noreferrer" 320 + class="text-blue-600 hover:text-blue-800 underline" 321 + >@poor.dog/nsid-tracker</a 322 + > 323 + </p> 324 + </footer>
+52
server/Cargo.lock
··· 120 120 dependencies = [ 121 121 "axum-core", 122 122 "axum-macros", 123 + "base64", 123 124 "bytes", 124 125 "form_urlencoded", 125 126 "futures-util", ··· 139 140 "serde_json", 140 141 "serde_path_to_error", 141 142 "serde_urlencoded", 143 + "sha1", 142 144 "sync_wrapper", 143 145 "tokio", 146 + "tokio-tungstenite", 144 147 "tower", 145 148 "tower-layer", 146 149 "tower-service", ··· 2278 2281 "papaya", 2279 2282 "rkyv", 2280 2283 "serde", 2284 + "serde_json", 2281 2285 "smol_str", 2282 2286 "tokio", 2287 + "tower-http", 2283 2288 "tracing", 2284 2289 "tracing-subscriber", 2290 + ] 2291 + 2292 + [[package]] 2293 + name = "sha1" 2294 + version = "0.10.6" 2295 + source = "registry+https://github.com/rust-lang/crates.io-index" 2296 + checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" 2297 + dependencies = [ 2298 + "cfg-if", 2299 + "cpufeatures", 2300 + "digest", 2285 2301 ] 2286 2302 2287 2303 [[package]] ··· 2582 2598 ] 2583 2599 2584 2600 [[package]] 2601 + name = "tokio-tungstenite" 2602 + version = "0.26.2" 2603 + source = "registry+https://github.com/rust-lang/crates.io-index" 2604 + checksum = "7a9daff607c6d2bf6c16fd681ccb7eecc83e4e2cdc1ca067ffaadfca5de7f084" 2605 + dependencies = [ 2606 + "futures-util", 2607 + "log", 2608 + "tokio", 2609 + "tungstenite", 2610 + ] 2611 + 2612 + [[package]] 2585 2613 name = "tokio-util" 2586 2614 version = "0.7.15" 2587 2615 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2648 2676 "tower", 2649 2677 "tower-layer", 2650 2678 "tower-service", 2679 + "uuid", 2651 2680 ] 2652 2681 2653 2682 [[package]] ··· 2731 2760 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" 2732 2761 2733 2762 [[package]] 2763 + name = "tungstenite" 2764 + version = "0.26.2" 2765 + source = "registry+https://github.com/rust-lang/crates.io-index" 2766 + checksum = "4793cb5e56680ecbb1d843515b23b6de9a75eb04b66643e256a396d43be33c13" 2767 + dependencies = [ 2768 + "bytes", 2769 + "data-encoding", 2770 + "http", 2771 + "httparse", 2772 + "log", 2773 + "rand 0.9.1", 2774 + "sha1", 2775 + "thiserror 2.0.12", 2776 + "utf-8", 2777 + ] 2778 + 2779 + [[package]] 2734 2780 name = "typenum" 2735 2781 version = "1.18.0" 2736 2782 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 2770 2816 version = "2.1.3" 2771 2817 source = "registry+https://github.com/rust-lang/crates.io-index" 2772 2818 checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" 2819 + 2820 + [[package]] 2821 + name = "utf-8" 2822 + version = "0.7.6" 2823 + source = "registry+https://github.com/rust-lang/crates.io-index" 2824 + checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" 2773 2825 2774 2826 [[package]] 2775 2827 name = "utf8_iter"
+3 -1
server/Cargo.toml
··· 9 9 tracing-subscriber = "0.3" 10 10 tracing = "0.1" 11 11 tokio = { version = "1", features = ["full"] } 12 - axum = { version = "0.8", features = ["json"] } 12 + axum = { version = "0.8", features = ["json", "ws"] } 13 + tower-http = {version = "0.6", features = ["request-id"]} 13 14 atproto-jetstream = "0.9" 14 15 fjall = "2" 15 16 rkyv = {version = "0.8", features = ["unaligned"]} 16 17 smol_str = { version = "0.3", features = ["serde"] } 17 18 papaya = "0.2" 18 19 serde = "1" 20 + serde_json = "1.0.141"
+34 -2
server/src/api.rs
··· 1 1 use std::sync::Arc; 2 2 3 - use axum::{Json, Router, extract::State, routing::get}; 3 + use axum::{ 4 + Json, Router, 5 + extract::{State, WebSocketUpgrade, ws::Message}, 6 + response::Response, 7 + routing::get, 8 + }; 4 9 use serde::Serialize; 5 10 use smol_str::SmolStr; 6 11 7 12 use crate::{db::Db, error::AppResult}; 8 13 9 14 pub async fn serve(db: Arc<Db>) { 10 - let app = Router::new().route("/events", get(events)).with_state(db); 15 + let app = Router::new() 16 + .route("/events", get(events)) 17 + .route("/stream_events", get(stream_events)) 18 + .with_state(db); 11 19 12 20 let addr = "0.0.0.0:3000"; 13 21 let listener = tokio::net::TcpListener::bind(addr).await.unwrap(); ··· 40 48 events.sort_unstable_by(|a, b| b.count.cmp(&a.count)); 41 49 Ok(Json(Events { events })) 42 50 } 51 + 52 + async fn stream_events(db: State<Arc<Db>>, ws: WebSocketUpgrade) -> Response { 53 + ws.on_upgrade(async move |mut socket| { 54 + let mut listener = db.new_listener(); 55 + while let Ok((nsid, counts)) = listener.recv().await { 56 + let res = socket 57 + .send(Message::Binary( 58 + serde_json::to_vec(&NsidCount { 59 + nsid, 60 + count: counts.count, 61 + deleted_count: counts.deleted_count, 62 + last_seen: counts.last_seen, 63 + }) 64 + .unwrap() 65 + .into(), 66 + )) 67 + .await; 68 + if let Err(err) = res { 69 + tracing::error!("error sending event: {err}"); 70 + break; 71 + } 72 + } 73 + }) 74 + }
+12 -2
server/src/db.rs
··· 1 1 use fjall::{Config, Keyspace, Partition, PartitionCreateOptions}; 2 2 use rkyv::{Archive, Deserialize, Serialize, rancor::Error}; 3 3 use smol_str::SmolStr; 4 + use tokio::sync::broadcast; 4 5 5 6 use crate::error::{AppError, AppResult}; 6 7 7 - #[derive(Debug, Default, Archive, Deserialize, Serialize, PartialEq)] 8 + #[derive(Clone, Debug, Default, Archive, Deserialize, Serialize, PartialEq)] 8 9 #[rkyv(compare(PartialEq), derive(Debug))] 9 10 pub struct NsidCounts { 10 11 pub count: u128, ··· 24 25 inner: Keyspace, 25 26 hits: papaya::HashMap<SmolStr, Partition>, 26 27 counts: Partition, 28 + event_broadcaster: broadcast::Sender<(SmolStr, NsidCounts)>, 27 29 } 28 30 29 31 impl Db { ··· 34 36 hits: Default::default(), 35 37 counts: inner.open_partition("_counts", PartitionCreateOptions::default())?, 36 38 inner, 39 + event_broadcaster: broadcast::channel(1000).0, 37 40 }) 38 41 } 39 42 43 + pub fn new_listener(&self) -> broadcast::Receiver<(SmolStr, NsidCounts)> { 44 + self.event_broadcaster.subscribe() 45 + } 46 + 40 47 #[inline(always)] 41 48 fn run_in_nsid_tree( 42 49 &self, ··· 60 67 } else { 61 68 counts.count += 1; 62 69 } 63 - self.insert_count(nsid, counts)?; 70 + self.insert_count(nsid, counts.clone())?; 71 + if self.event_broadcaster.receiver_count() > 0 { 72 + let _ = self.event_broadcaster.send((SmolStr::new(nsid), counts)); 73 + } 64 74 Ok(()) 65 75 } 66 76