spb/company-atlas
Public
Python 66.3%
TypeScript 22.7%
JavaScript 8.6%
HTML 1.4%
CSS 0.7%
1'use client';2import { Pause, Play, RotateCcw } from 'lucide-react';3import { useSearchParams } from 'next/navigation';4import { useCallback, useEffect, useMemo, useRef, useState } from 'react';5import { useEventDrawer } from '@/components/events/event-drawer-context';6import { EventRow } from '@/components/events/event-row';7import { LiveStatus } from '@/components/ui/live';8import { clientApi } from '@/lib/client-api';9import { cn } from '@/lib/cn';10import type { Event } from '@/lib/types';1112const MAX_ROWS = 300;1314function matches(e: Event, f: { event_type?: string; min_importance?: number; country?: string; industry?: string; min_confidence?: number }): boolean {15 if (f.event_type && e.event_type !== f.event_type) return false;16 if (f.min_importance !== undefined && (e.importance > 1 ? e.importance / 100 : e.importance) < f.min_importance) return false;17 if (f.min_confidence !== undefined && e.confidence < f.min_confidence) return false;18 if (f.country && (e.company.country ?? '').toUpperCase() !== f.country.toUpperCase()) return false;19 return true;20}2122/**23 * Live Activity Feed. Seeds with server-rendered events, then subscribes to `/api/v1/live/stream` (SSE, `event: event`).24 * New rows prepend with a soft highlight; when paused (hover or button) they buffer behind an "N new" pill.25 * Keyboard j/k moves a selection, Enter opens the evidence drawer. Filters come from the URL (`?event_type=&min_importance=…`)26 * and are applied client-side to the stream as well. Falls back to polling `/live?since=` when EventSource fails.27 */28export function LiveFeed({ initial, limit = 60, compact = false, className, showControls = true, stickyPill = true }: { initial: Event[]; limit?: number; compact?: boolean; className?: string; showControls?: boolean; stickyPill?: boolean }) {29 const sp = useSearchParams();30 const filters = useMemo(31 () => ({32 event_type: sp.get('event_type') ?? undefined,33 min_importance: sp.get('min_importance') ? Number(sp.get('min_importance')) : undefined,34 min_confidence: sp.get('min_confidence') ? Number(sp.get('min_confidence')) : undefined,35 country: sp.get('country') ?? undefined,36 industry: sp.get('industry') ?? undefined,37 }),38 [sp],39 );40 const [events, setEvents] = useState<Event[]>(() => initial.filter((e) => matches(e, filters)).slice(0, limit));41 const [buffer, setBuffer] = useState<Event[]>([]);42 const [fresh, setFresh] = useState<Set<string>>(new Set());43 const [paused, setPaused] = useState(false);44 const [hovering, setHovering] = useState(false);45 const [connected, setConnected] = useState(false);46 const [updatedAt, setUpdatedAt] = useState<number | null>(null);47 const [selected, setSelected] = useState<number>(-1);48 const seen = useRef<Set<string>>(new Set(initial.map((e) => e.id)));49 const latest = useRef<string | null>(initial[0]?.detected_at ?? null);50 const { open } = useEventDrawer();51 const listRef = useRef<HTMLUListElement>(null);52 const holding = paused || hovering;53 const holdingRef = useRef(holding);54 holdingRef.current = holding;55 const filtersRef = useRef(filters);56 filtersRef.current = filters;5758 // re-seed when filters change (server re-renders `initial` on navigation)59 useEffect(() => {60 setEvents(initial.filter((e) => matches(e, filters)).slice(0, limit));61 seen.current = new Set(initial.map((e) => e.id));62 setBuffer([]);63 }, [initial, filters, limit]);6465 const ingest = useCallback(66 (incoming: Event[]) => {67 const fresh = incoming.filter((e) => !seen.current.has(e.id) && matches(e, filtersRef.current));68 if (!fresh.length) return;69 for (const e of fresh) seen.current.add(e.id);70 const newest = fresh.map((e) => e.detected_at).sort().pop();71 if (newest && (!latest.current || newest > latest.current)) latest.current = newest;72 setUpdatedAt(Date.now());73 if (holdingRef.current) {74 setBuffer((b) => [...fresh, ...b].slice(0, MAX_ROWS));75 return;76 }77 setEvents((prev) => [...fresh, ...prev].slice(0, Math.max(limit, MAX_ROWS)));78 setFresh((s) => {79 const n = new Set(s);80 for (const e of fresh) n.add(e.id);81 return n;82 });83 setTimeout(() => setFresh((s) => {84 const n = new Set(s);85 for (const e of fresh) n.delete(e.id);86 return n;87 }), 2000);88 },89 [limit],90 );9192 // SSE with polling fallback93 useEffect(() => {94 let es: EventSource | null = null;95 let poll: ReturnType<typeof setInterval> | null = null;96 let cancelled = false;97 const qs = new URLSearchParams();98 if (latest.current) qs.set('since', latest.current);99 if (filters.event_type) qs.set('event_type', filters.event_type);100 if (filters.min_importance !== undefined) qs.set('min_importance', String(filters.min_importance));101 const startPolling = () => {102 if (poll) return;103 poll = setInterval(async () => {104 try {105 const res = await clientApi.live({ since: latest.current ?? undefined, limit: 50, event_type: filters.event_type });106 const items = Array.isArray(res) ? res : res.items;107 if (!cancelled) {108 setConnected(true);109 ingest(items ?? []);110 }111 } catch {112 if (!cancelled) setConnected(false);113 }114 }, 8000);115 };116 // Watchdog: a proxy that gzips text/event-stream buffers frames indefinitely (the API must send `no-transform`);117 // if the socket is "open" but silent for 45 s, poll as well so the feed keeps moving.118 let lastFrame = Date.now();119 const watchdog = setInterval(() => {120 if (Date.now() - lastFrame > 45_000) startPolling();121 }, 15_000);122 if (typeof EventSource !== 'undefined') {123 try {124 es = new EventSource(`/api/v1/live/stream${qs.toString() ? `?${qs}` : ''}`);125 es.addEventListener('open', () => {126 setConnected(true);127 setUpdatedAt(Date.now());128 });129 es.addEventListener('event', (m) => {130 lastFrame = Date.now();131 try {132 const e = JSON.parse((m as MessageEvent).data) as Event;133 ingest([e]);134 } catch {135 /* malformed frame */136 }137 });138 es.addEventListener('heartbeat', () => {139 lastFrame = Date.now();140 setUpdatedAt(Date.now());141 });142 es.onerror = () => {143 setConnected(false);144 // EventSource retries by itself; also start a low-frequency poll so the feed keeps moving behind proxies.145 startPolling();146 };147 } catch {148 startPolling();149 }150 } else startPolling();151 return () => {152 cancelled = true;153 es?.close();154 clearInterval(watchdog);155 if (poll) clearInterval(poll);156 };157 }, [filters, ingest]);158159 const release = () => {160 if (!buffer.length) return;161 setEvents((prev) => [...buffer, ...prev].slice(0, MAX_ROWS));162 setFresh((s) => {163 const n = new Set(s);164 for (const e of buffer) n.add(e.id);165 return n;166 });167 const ids = buffer.map((e) => e.id);168 setTimeout(() => setFresh((s) => {169 const n = new Set(s);170 for (const id of ids) n.delete(id);171 return n;172 }), 2000);173 setBuffer([]);174 };175 useEffect(() => {176 if (!holding && buffer.length) release();177 // eslint-disable-next-line react-hooks/exhaustive-deps178 }, [holding]);179180 // keyboard j/k/Enter181 useEffect(() => {182 const onKey = (e: KeyboardEvent) => {183 const t = e.target as HTMLElement | null;184 if (t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable)) return;185 if (document.querySelector('[role="dialog"]')) return;186 if (e.key === 'j' || e.key === 'k') {187 e.preventDefault();188 setSelected((s) => {189 const n = e.key === 'j' ? Math.min(events.length - 1, s + 1) : Math.max(0, s - 1);190 listRef.current?.querySelector<HTMLElement>(`[data-index="${n}"]`)?.scrollIntoView({ block: 'nearest' });191 return n;192 });193 } else if (e.key === 'Enter' && selected >= 0 && events[selected]) {194 e.preventDefault();195 open(events[selected] as Event);196 }197 };198 window.addEventListener('keydown', onKey);199 return () => window.removeEventListener('keydown', onKey);200 }, [events, selected, open]);201202 const replay = async () => {203 const since = new Date(Date.now() - 3600_000).toISOString();204 try {205 const res = await clientApi.live({ since, limit: 100, event_type: filters.event_type });206 const items = Array.isArray(res) ? res : res.items;207 ingest(items ?? []);208 } catch {209 /* ignore */210 }211 };212213 return (214 <div className={cn('relative', className)} data-live-feed onMouseEnter={() => setHovering(true)} onMouseLeave={() => setHovering(false)}>215 {showControls && (216 <div className="mb-2 flex flex-wrap items-center gap-2">217 <LiveStatus updatedAt={updatedAt} connected={connected && !holding} />218 <span className="text-xs text-ink-3">{holding ? (paused ? 'paused' : 'paused while hovering') : ''}</span>219 <div className="ml-auto flex items-center gap-1">220 <button type="button" onClick={replay} className="btn btn-sm" title="Replay events of the last hour">221 <RotateCcw className="size-3.5" aria-hidden /> Replay 1 h222 </button>223 <button type="button" onClick={() => setPaused((p) => !p)} className="btn btn-sm" aria-pressed={paused} data-pause>224 {paused ? <Play className="size-3.5" aria-hidden /> : <Pause className="size-3.5" aria-hidden />}225 {paused ? 'Resume' : 'Pause'}226 </button>227 </div>228 </div>229 )}230 {buffer.length > 0 && (231 <div className={cn('z-20 flex justify-center', stickyPill ? 'sticky top-[calc(var(--header-h)+8px)]' : '')}>232 <button type="button" onClick={() => { setPaused(false); release(); }} className="tnum rounded-full bg-accent px-3 py-1 text-xs font-medium text-accent-ink shadow-md" data-new-pill>233 {buffer.length} new {buffer.length === 1 ? 'event' : 'events'} — show234 </button>235 </div>236 )}237 {events.length === 0 ? (238 <p className="border border-dashed border-rule-strong px-4 py-10 text-center text-sm text-ink-3">No monitored evidence matches these filters yet. The feed stays connected — new events will appear here.</p>239 ) : (240 <ul ref={listRef} className="border-t border-rule" aria-live="polite" aria-relevant="additions">241 {events.slice(0, compact ? limit : MAX_ROWS).map((e, i) => (242 <li key={e.id} data-index={i} className="list-none">243 <ul>244 <EventRow event={e} fresh={fresh.has(e.id)} selected={i === selected} variant="feed" />245 </ul>246 </li>247 ))}248 </ul>249 )}250 {!compact && <p className="mt-2 text-[11px] text-ink-3">Keyboard: j / k to move · Enter to open evidence · hover pauses the stream.</p>}251 </div>252 );253}254