SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%
10.8 KB · 254 lines tsx
Raw Blame History
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