SPB Git forge

spb/websensor

Public
33commits 1branches 0releases
3.4 MBsize
maindefault branch
10 days agolast push
TypeScript 55.4% Python 43.2% SQL 1.2%
21.9 KB · 388 lines tsx
Raw Blame History
1import Link from "next/link";2import type { Metadata } from "next";3import { EventRow } from "@/components/event-row";4import { Badge, Bar, Chip, Empty, Flag, PageHeader, Panel, Score, StateBadge, Table, Td, Tabs, TierBadge } from "@/components/ui";5import { api, type Cluster, type EventItem } from "@/lib/api";6import { CHANNELS, SAVED_VIEWS, feedHref, fmtInt, fmtOffset, fmtScore, relTime, typeLabel } from "@/lib/format";78export const dynamic = "force-dynamic";910const TABS = [11  { key: "trending", label: "Trending" },12  { key: "breaking", label: "Breaking" },13  { key: "silent", label: "Silent" },14  { key: "newly", label: "Newly detected" },15  { key: "active", label: "Active sources" },16  { key: "unusual", label: "Unusual activity" },17  { key: "clusters", label: "Clusters" },18  { key: "entities", label: "Entities" },19  { key: "sources", label: "Sources" },20  { key: "categories", label: "Categories" },21  { key: "countries", label: "Countries" },22] as const;23type TabKey = (typeof TABS)[number]["key"];2425function tabOf(v: string | undefined): TabKey {26  return (TABS.find((t) => t.key === v)?.key ?? "trending") as TabKey;27}2829export async function generateMetadata({ searchParams }: { searchParams: Promise<{ tab?: string }> }): Promise<Metadata> {30  const { tab } = await searchParams;31  const t = TABS.find((x) => x.key === tabOf(tab))!;32  return { title: `Discover · ${t.label}`, description: "Trending entities, breaking clusters, silent changes, newly detected signals, active and anomalous sources, clusters, entities, sources, categories and countries.", alternates: { canonical: t.key === "trending" ? "/explore" : `/explore?tab=${t.key}` } };33}3435/** Discover page (spec §28): URL-driven tabs over the derived views of the event store. */36export default async function ExplorePage({ searchParams }: { searchParams: Promise<{ tab?: string }> }) {37  const sp = await searchParams;38  const tab = tabOf(sp.tab);39  const [x, trending, desk, rank, sources, countries] = await Promise.all([40    api.explore(),41    tab === "trending" ? api.trending(24, 30) : Promise.resolve({ items: [] }),42    tab === "breaking" ? api.breakingDesk() : Promise.resolve(null),43    tab === "entities" ? api.rank(50) : Promise.resolve({ items: [] }),44    tab === "sources" ? api.sources({}) : Promise.resolve({ items: [] }),45    tab === "countries" ? api.countries() : Promise.resolve({ items: [] }),46  ]);47  const counts: Partial<Record<TabKey, number>> = {48    trending: tab === "trending" ? trending.items.length : undefined,49    breaking: desk ? desk.breaking_now.length + desk.developing.length + desk.recently_confirmed.length : undefined,50    silent: x?.silent_changes?.length,51    newly: x?.newly_detected?.length,52    active: x?.most_active_sources?.length,53    unusual: x?.unusual_activity?.length,54    clusters: x?.clusters?.length,55    entities: tab === "entities" ? rank.items.length : undefined,56    sources: tab === "sources" ? sources.items.length : undefined,57    categories: x?.by_category?.length,58    countries: tab === "countries" ? countries.items.length : undefined,59  };60  return (61    <>62      <PageHeader compact kicker="Discover" title="Explore" description="Derived views over the event store: who is changing the most, what mattered most, what changed silently, and where activity is abnormal." />63      <Tabs className="mb-3" current={tab} items={TABS.map((t) => ({ key: t.key, label: t.label, href: t.key === "trending" ? "/explore" : `/explore?tab=${t.key}`, count: counts[t.key] }))} />64      <div className="mb-3 flex flex-wrap items-center gap-1">65        <span className="mr-1 text-[11px] text-fg-subtle">Saved views</span>66        {SAVED_VIEWS.map((v) => (67          <Chip key={v.key} href={feedHref(v.query, "/live")}>{v.label}</Chip>68        ))}69      </div>7071      {tab === "trending" && <TrendingTab items={trending.items} />}72      {tab === "breaking" && <BreakingTab desk={desk} />}73      {tab === "silent" && (74        <Panel title="Silent changes · 48 h" dense action={<Link href="/silent" className="text-[11px] text-fg-subtle hover:text-fg">all silent →</Link>}>75          {x?.silent_changes?.length ? x.silent_changes.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>No silent change detected in this window.</Empty>}76        </Panel>77      )}78      {tab === "newly" && (79        <Panel title="Newly detected · first observation of a page, feed or entity" dense action={<Link href="/live" className="text-[11px] text-fg-subtle hover:text-fg">live →</Link>}>80          {x?.newly_detected?.length ? x.newly_detected.map((e) => <EventRow key={e.id} ev={e} showDate />) : <Empty>Nothing newly detected in this window.</Empty>}81        </Panel>82      )}83      {tab === "active" && <ActiveTab items={x?.most_active_sources ?? []} />}84      {tab === "unusual" && <UnusualTab items={x?.unusual_activity ?? []} />}85      {tab === "clusters" && <ClustersTab items={x?.clusters ?? []} />}86      {tab === "entities" && <EntitiesTab items={rank.items} />}87      {tab === "sources" && <SourcesTab items={sources.items} />}88      {tab === "categories" && <CategoriesTab byCategory={x?.by_category ?? []} byType={x?.by_type ?? []} labels={x?.event_types ?? {}} />}89      {tab === "countries" && <CountriesTab items={countries.items} />}90    </>91  );92}9394// ---------------------------------------------------------------------------------------9596function Dir({ d }: { d?: "up" | "down" | "flat" }) {97  const c = d === "up" ? "text-signal" : d === "down" ? "text-fg-subtle" : "text-fg-muted";98  return <span className={`font-mono ${c}`} title={d === "up" ? "Rising vs. previous window" : d === "down" ? "Falling vs. previous window" : "Flat vs. previous window"}>{d === "up" ? "↑" : d === "down" ? "↓" : "→"}</span>;99}100101function TrendingTab({ items }: { items: Awaited<ReturnType<typeof api.trending>>["items"] }) {102  return (103    <Panel title="Trending entities · 24 h" dense action={<span className="text-[11px] text-fg-subtle">score = volume × acceleration × signal, vs. the previous 24 h</span>}>104      {items.length === 0 ? (105        <Empty>Trending is computed from the last 24 h of events.</Empty>106      ) : (107        <Table head={["#", "Entity", "", "Events", "Sources", "1st-party", "Silent", "Avg signal", "Score"]}>108          {items.map((t, i) => (109            <tr key={t.id} className="hover:bg-panel-2/60">110              <Td mono className="text-fg-subtle">{i + 1}</Td>111              <Td>112                <Link href={`/entity/${t.id}`} className="font-medium hover:underline">{t.name}</Link>113                <div className="truncate font-mono text-[10.5px] text-fg-subtle">{t.type}{t.domain ? ` · ${t.domain}` : ""}</div>114              </Td>115              <Td mono><Dir d={t.direction} /></Td>116              <Td mono className="whitespace-nowrap">{fmtInt(t.events)}<span className="text-fg-subtle"> / {fmtInt(t.prev_events)}</span></Td>117              <Td mono>{t.sources}</Td>118              <Td mono className={t.first_party ? "text-signal" : "text-fg-subtle"}>{t.first_party ?? 0}</Td>119              <Td mono className={t.silent ? "text-silent" : "text-fg-subtle"}>{t.silent}</Td>120              <Td mono>{fmtScore(t.avg_signal)}</Td>121              <Td>122                <div className="flex w-28 items-center gap-2">123                  <Bar value={t.score} tone={t.score >= 80 ? "hot" : t.score >= 60 ? "high" : "signal"} />124                  <span className="w-8 text-right font-mono text-[12px] font-semibold tabular">{fmtScore(t.score)}</span>125                </div>126              </Td>127            </tr>128          ))}129        </Table>130      )}131    </Panel>132  );133}134135function ClusterRows({ items, empty }: { items: Cluster[]; empty: string }) {136  if (!items.length) return <Empty>{empty}</Empty>;137  return (138    <ul className="divide-y divide-line">139      {items.map((c) => {140        const e = c.event as EventItem | null | undefined;141        return (142          <li key={c.id} className="grid grid-cols-[auto_1fr] items-start gap-x-3 px-3 py-2 text-[13px]">143            <Score value={e?.signal_score ?? c.max_importance} kind="signal" size="sm" />144            <div className="min-w-0">145              <div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-fg-subtle">146                <StateBadge state={c.state} />147                {(e?.source ?? c.source) && <span className="truncate font-mono uppercase text-fg-muted">{(e?.source ?? c.source)?.name}</span>}148                {e?.country && <Flag code={e.country} />}149                <span className="ml-auto whitespace-nowrap font-mono tabular">{relTime(c.last_at)}</span>150              </div>151              <Link href={`/cluster/${c.primary_slug ?? c.slug ?? c.id}`} className="mt-0.5 block font-medium leading-snug hover:underline">{c.title}</Link>152              <div className="mt-0.5 flex flex-wrap items-center gap-x-2 gap-y-0.5 text-[11px] text-fg-subtle">153                <span>{c.event_count} signal{c.event_count === 1 ? "" : "s"} · {c.source_count ?? 1} source{(c.source_count ?? 1) === 1 ? "" : "s"}</span>154                {(c.first_party_count ?? 0) > 0 && <span className="text-signal">{c.first_party_count} first-party</span>}155                {(c.external_count ?? 0) > 0 && <span>{c.external_count} external</span>}156                {c.lead_time_ms && c.lead_time_ms > 0 ? <span title="WebSensor lead time before the first external report">lead {fmtOffset(c.lead_time_ms).replace("+", "")}</span> : null}157                {c.categories?.slice(0, 2).map((k) => <Chip key={k} href={`/category/${k}`}>{k}</Chip>)}158              </div>159            </div>160          </li>161        );162      })}163    </ul>164  );165}166167function BreakingTab({ desk }: { desk: Awaited<ReturnType<typeof api.breakingDesk>> }) {168  const d = desk ?? { breaking_now: [], developing: [], recently_confirmed: [], watching: [] };169  return (170    <div className="grid gap-4 xl:grid-cols-2">171      <Panel title={<span className="text-hot">Breaking now <span className="font-mono text-fg-subtle">{d.breaking_now.length}</span></span>} dense action={<Link href="/breaking" className="text-[11px] text-fg-subtle hover:text-fg">desk →</Link>}>172        <ClusterRows items={d.breaking_now} empty="Nothing is breaking right now. Breaking requires strong signal, freshness and confirmation — not just recency." />173      </Panel>174      <Panel title={<span className="text-high">Developing <span className="font-mono text-fg-subtle">{d.developing.length}</span></span>} dense>175        <ClusterRows items={d.developing} empty="No developing story: signals are not accumulating fast enough anywhere." />176      </Panel>177      <Panel title={<span className="text-ok">Recently confirmed <span className="font-mono text-fg-subtle">{d.recently_confirmed.length}</span></span>} dense>178        <ClusterRows items={d.recently_confirmed} empty="No cluster reached independent confirmation in the last 48 h." />179      </Panel>180      <Panel title="Watching · high signal, not yet breaking" dense>181        {d.watching.length ? d.watching.slice(0, 12).map((e) => <EventRow key={e.id} ev={e} />) : <Empty>Nothing to watch.</Empty>}182      </Panel>183    </div>184  );185}186187function ActiveTab({ items }: { items: NonNullable<Awaited<ReturnType<typeof api.explore>>>["most_active_sources"] }) {188  const max = Math.max(1, ...items.map((s) => s.events_24h));189  return (190    <Panel title="Most active sources · 24 h" dense action={<Link href="/sources" className="text-[11px] text-fg-subtle hover:text-fg">all sources →</Link>}>191      {items.length === 0 ? (192        <Empty />193      ) : (194        <Table head={["#", "Source", "Domain", "Kind", "Events 24 h", "", "Max importance"]}>195          {items.map((s, i) => (196            <tr key={s.id} className="hover:bg-panel-2/60">197              <Td mono className="text-fg-subtle">{i + 1}</Td>198              <Td><Link href={`/source/${s.id}`} className="font-medium hover:underline">{s.name}</Link></Td>199              <Td mono><Link href={`/domain/${s.domain}`} className="text-fg-muted hover:underline">{s.domain}</Link></Td>200              <Td>{(s as { first_party?: boolean }).first_party === false ? <Badge kind="external" compact /> : <Badge kind="first-party" compact />}</Td>201              <Td mono>{fmtInt(s.events_24h)}</Td>202              <Td><div className="w-24 sm:w-40"><Bar value={s.events_24h} max={max} tone="signal" /></div></Td>203              <Td><Score value={s.max_importance} size="sm" /></Td>204            </tr>205          ))}206        </Table>207      )}208    </Panel>209  );210}211212function UnusualTab({ items }: { items: NonNullable<Awaited<ReturnType<typeof api.explore>>>["unusual_activity"] }) {213  return (214    <Panel title="Unusual activity · last 2 h vs. 14-day baseline" dense action={<Link href="/radar" className="text-[11px] text-fg-subtle hover:text-fg">radar →</Link>}>215      {items.length === 0 ? (216        <Empty>Activity anomalies compare the last 2 h of raw changes with a 14-day baseline. Nothing is far above its baseline right now.</Empty>217      ) : (218        <Table head={["Source", "Domain", "Changes 2 h", "Baseline / day", "vs. baseline", "Activity score"]}>219          {items.map((s) => {220            const tone = s.activity_score >= 70 ? "hot" : s.activity_score >= 45 ? "high" : "signal";221            return (222              <tr key={s.id} className="hover:bg-panel-2/60">223                <Td><Link href={`/source/${s.id}`} className="font-medium hover:underline">{s.name}</Link></Td>224                <Td mono><Link href={`/domain/${s.domain}`} className="text-fg-muted hover:underline">{s.domain}</Link></Td>225                <Td mono>{s.changes_2h}</Td>226                <Td mono className="text-fg-subtle">{s.baseline_per_day}</Td>227                <Td mono className={`font-semibold ${tone === "hot" ? "text-hot" : tone === "high" ? "text-high" : "text-fg-muted"}`}>{s.pct_vs_baseline !== null && s.pct_vs_baseline !== undefined ? `+${Math.min(9999, Math.round(s.pct_vs_baseline)).toLocaleString("en-US")}%` : "—"}</Td>228                <Td>229                  <div className="flex w-32 items-center gap-2">230                    <Bar value={s.activity_score} tone={tone} />231                    <span className="w-8 text-right font-mono text-[12px] font-semibold tabular">{fmtScore(s.activity_score)}</span>232                  </div>233                </Td>234              </tr>235            );236          })}237        </Table>238      )}239    </Panel>240  );241}242243function ClustersTab({ items }: { items: Cluster[] }) {244  return (245    <Panel title="Event clusters · 48 h" dense action={<Link href="/breaking" className="text-[11px] text-fg-subtle hover:text-fg">breaking desk →</Link>}>246      <ClusterRows items={items} empty="No multi-observation clusters yet. Related observations are grouped into clusters as they arrive." />247    </Panel>248  );249}250251function EntitiesTab({ items }: { items: Awaited<ReturnType<typeof api.rank>>["items"] }) {252  return (253    <Panel title="Entity rankings · 7 d" dense action={<Link href="/entities" className="text-[11px] text-fg-subtle hover:text-fg">all entities →</Link>}>254      {items.length === 0 ? (255        <Empty>Rankings appear once entities accumulate events.</Empty>256      ) : (257        <Table head={["#", "Entity", "Type", "24 h", "7 d", "Baseline / d", "Sources", "Silent", "Breaking", "Avg signal", "Confirmed", "Last", "Rank score"]}>258          {items.map((e) => (259            <tr key={e.id} className="hover:bg-panel-2/60">260              <Td mono className="text-fg-subtle">{e.rank}</Td>261              <Td>262                <Link href={`/entity/${e.id}`} className="font-medium hover:underline">{e.name}</Link>263                {e.domain && <div className="truncate font-mono text-[10.5px] text-fg-subtle">{e.domain}</div>}264              </Td>265              <Td><Chip>{e.type.replace(/_/g, " ")}</Chip></Td>266              <Td mono>{fmtInt(e.events_24h)}</Td>267              <Td mono>{fmtInt(e.events_7d)}</Td>268              <Td mono className="text-fg-subtle">{e.baseline_per_day.toFixed(1)}</Td>269              <Td mono>{e.sources}</Td>270              <Td mono className={e.silent_24h ? "text-silent" : "text-fg-subtle"}>{e.silent_24h}</Td>271              <Td mono className={e.breaking_24h ? "text-hot" : "text-fg-subtle"}>{e.breaking_24h}</Td>272              <Td mono>{fmtScore(e.avg_signal)}</Td>273              <Td mono className="text-fg-subtle">{Math.round(e.confirmed_ratio * 100)}%</Td>274              <Td mono className="text-fg-subtle">{relTime(e.last_at)}</Td>275              <Td>276                <div className="flex w-24 items-center gap-2">277                  <Bar value={e.rank_score} tone={e.rank_score >= 80 ? "hot" : e.rank_score >= 60 ? "high" : "signal"} />278                  <span className="w-7 text-right font-mono text-[12px] font-semibold tabular">{fmtScore(e.rank_score)}</span>279                </div>280              </Td>281            </tr>282          ))}283        </Table>284      )}285    </Panel>286  );287}288289function SourcesTab({ items }: { items: Awaited<ReturnType<typeof api.sources>>["items"] }) {290  const top = [...items].sort((a, b) => (b.events_24h ?? 0) - (a.events_24h ?? 0)).slice(0, 50);291  const max = Math.max(1, ...top.map((s) => s.events_24h ?? 0));292  return (293    <Panel title="Sources · top 50 by events in 24 h" dense action={<Link href="/sources" className="text-[11px] text-fg-subtle hover:text-fg">all sources →</Link>}>294      {top.length === 0 ? (295        <Empty />296      ) : (297        <Table head={["Tier", "Source", "Country", "Kind", "Categories", "Sensors", "Events 24 h", "", "Total", "Last event"]}>298          {top.map((s) => (299            <tr key={s.id} className="hover:bg-panel-2/60">300              <Td><TierBadge tier={s.tier} /></Td>301              <Td>302                <Link href={`/source/${s.id}`} className="font-medium hover:underline">{s.name}</Link>303                <div className="truncate font-mono text-[10.5px] text-fg-subtle">{s.domain}</div>304              </Td>305              <Td><Flag code={s.country} /></Td>306              <Td>{s.first_party === false ? <Badge kind="external" compact /> : <Badge kind="first-party" compact />}</Td>307              <Td><div className="flex flex-wrap gap-1">{s.categories.slice(0, 3).map((c) => <Chip key={c} href={`/category/${c}`}>{c}</Chip>)}</div></Td>308              <Td mono>{s.sensor_count ?? 0}</Td>309              <Td mono>{fmtInt(s.events_24h ?? 0)}</Td>310              <Td><div className="w-20 sm:w-32"><Bar value={s.events_24h ?? 0} max={max} /></div></Td>311              <Td mono className="text-fg-subtle">{fmtInt(s.event_count)}</Td>312              <Td mono className="text-fg-subtle">{s.last_event_at ? relTime(s.last_event_at) : "—"}</Td>313            </tr>314          ))}315        </Table>316      )}317    </Panel>318  );319}320321function CategoriesTab({ byCategory, byType, labels }: { byCategory: { category: string; n: number }[]; byType: { event_type: string; n: number }[]; labels: Record<string, string> }) {322  const maxCat = Math.max(1, ...byCategory.map((t) => t.n));323  const maxType = Math.max(1, ...byType.map((t) => t.n));324  const channelOf = (c: string): string | undefined => CHANNELS.find((ch) => ch.query.category === c)?.label;325  return (326    <div className="grid gap-4 lg:grid-cols-2">327      <Panel title="Events by category · 7 d" dense>328        {byCategory.length ? (329          <ul className="divide-y divide-line">330            {byCategory.slice(0, 30).map((t) => (331              <li key={t.category} className="grid grid-cols-[8rem_1fr_3.5rem] items-center gap-2 px-3 py-1.5 text-[12.5px] sm:grid-cols-[11rem_1fr_3.5rem]">332                <Link href={`/category/${t.category}`} className="truncate hover:underline" title={channelOf(t.category) ? `${channelOf(t.category)} desk` : t.category}>{t.category}</Link>333                <Bar value={t.n} max={maxCat} tone="signal" />334                <span className="text-right font-mono text-fg-subtle tabular">{fmtInt(t.n)}</span>335              </li>336            ))}337          </ul>338        ) : (339          <Empty />340        )}341      </Panel>342      <Panel title="Events by type · 7 d" dense>343        {byType.length ? (344          <ul className="divide-y divide-line">345            {byType.slice(0, 30).map((t) => (346              <li key={t.event_type} className="grid grid-cols-[8rem_1fr_3.5rem] items-center gap-2 px-3 py-1.5 text-[12.5px] sm:grid-cols-[11rem_1fr_3.5rem]">347                <Link href={`/live?event_type=${t.event_type}`} className="truncate hover:underline" title={labels[t.event_type] ?? typeLabel(t.event_type)}>{typeLabel(t.event_type)}</Link>348                <Bar value={t.n} max={maxType} tone="info" />349                <span className="text-right font-mono text-fg-subtle tabular">{fmtInt(t.n)}</span>350              </li>351            ))}352          </ul>353        ) : (354          <Empty />355        )}356      </Panel>357    </div>358  );359}360361function CountriesTab({ items }: { items: Awaited<ReturnType<typeof api.countries>>["items"] }) {362  return (363    <Panel title={`Countries · ${items.length}`} dense action={<Link href="/country" className="text-[11px] text-fg-subtle hover:text-fg">country desks →</Link>}>364      {items.length === 0 ? (365        <Empty>Country desks appear once sources carry a country.</Empty>366      ) : (367        <ul className="grid gap-px bg-line sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">368          {items.map((c) => (369            <li key={c.country} className="bg-panel">370              <Link href={`/country/${c.slug}`} className="flex items-center gap-3 px-3 py-2 hover:bg-panel-2/60">371                <span className="w-6 text-center text-lg leading-none" aria-hidden>{c.flag || <Flag code={c.country} />}</span>372                <span className="min-w-0 flex-1">373                  <span className="block truncate text-[13px] font-medium">{c.name}</span>374                  <span className="block font-mono text-[10.5px] text-fg-subtle">{c.country} · {c.sources} source{c.sources === 1 ? "" : "s"}</span>375                </span>376                <span className="flex flex-col items-end font-mono text-[12px] tabular">377                  <span>{fmtInt(c.events_24h)} <span className="text-[10px] text-fg-subtle">24 h</span></span>378                  <span className={c.breaking_24h ? "text-hot" : "text-fg-subtle"}>{fmtInt(c.breaking_24h)} <span className="text-[10px] text-fg-subtle">brk</span></span>379                </span>380              </Link>381            </li>382          ))}383        </ul>384      )}385    </Panel>386  );387}388