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%
29.8 KB · 876 lines typescript
Raw Blame History
1/**2 * Typed gateway client. Server Components call the gateway over loopback (`API_URL`);3 * client components use same-origin `/api/v1` (or `NEXT_PUBLIC_API_URL` in dev).4 * Every server helper swallows failures and returns an empty shape so pages render5 * "warming up" states instead of crashing.6 */78export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.websensor.io";910export function serverApiBase(): string {11  return process.env.API_URL ?? "http://127.0.0.1:8260";12}1314export function clientApiBase(): string {15  if (typeof window === "undefined") return serverApiBase();16  return process.env.NEXT_PUBLIC_API_URL ?? "";17}1819// ---------------------------------------------------------------------------------------20// Types (snake_case = REST rows; camelCase = WebSocket payloads)21// ---------------------------------------------------------------------------------------2223export interface EntityRef {24  id: string;25  name: string;26  type: string;27  role?: string;28}2930export interface SourceRef {31  id: string;32  name: string;33  domain: string;34  tier?: string;35  categories?: string[];36}3738export interface SensorRef {39  id: string;40  name: string;41  type: string;42  connector?: string;43  tier?: string;44}4546export interface EventItem {47  id: string;48  slug: string;49  event_type: string;50  title: string;51  summary: string;52  why_it_matters?: string | null;53  importance: number;54  confidence: number;55  novelty: number;56  categories: string[];57  keywords: string[];58  silent_change: boolean;59  evidence_label: string;60  url: string;61  detected_at: string;62  published_at?: string | null;63  observed_from?: string | null;64  processed_at?: string | null;65  published_to_feed_at?: string | null;66  detection_latency_ms?: number | null;67  processing_latency_ms?: number | null;68  cluster_id?: string | null;69  sensor_id: string;70  source_id: string;71  change_id?: string | null;72  old_snapshot_id?: string | null;73  new_snapshot_id?: string | null;74  importance_components?: Record<string, number> | null;75  processing_version?: string;76  interpretation?: Record<string, unknown> | null;77  source: SourceRef;78  sensor: SensorRef;79  entities: EntityRef[];80  cluster_size?: number | null;81  // 0.2 intelligence fields82  signal_score?: number | null;83  velocity_score?: number | null;84  impact_score?: number | null;85  anomaly_score?: number | null;86  change_class?: string | null;87  first_party?: boolean;88  country?: string | null;89  language?: string | null;90  canonical_url?: string | null;91  field_changes?: FieldChange[] | null;92  score_reasons?: ScoreReason[] | null;93  cluster?: ClusterRef | null;94}9596export interface FieldChange {97  label: string;98  kind: "price" | "percent" | "number" | "date" | "version" | "status" | "text";99  before: string | null;100  after: string | null;101  deltaPct?: number | null;102}103104export interface ScoreReason {105  sign: "+" | "-";106  text: string;107  points?: number;108}109110export interface ClusterRef {111  id: string;112  slug?: string | null;113  state?: string;114  event_count: number;115  source_count?: number;116  first_party_count?: number;117  external_count?: number;118  velocity?: number;119  lead_time_ms?: number | null;120}121122export interface LiveEvent {123  id: string;124  slug: string;125  type: string;126  title: string;127  summary: string;128  importance: number;129  confidence: number;130  novelty: number;131  silent: boolean;132  evidence: string;133  source: SourceRef;134  sensor: SensorRef;135  entities: EntityRef[];136  categories: string[];137  url: string;138  clusterId?: string | null;139  clusterSlug?: string | null;140  clusterSize?: number;141  clusterState?: string;142  detectedAt: string;143  publishedAt?: string | null;144  signal?: number;145  impact?: number;146  velocity?: number;147  firstParty?: boolean;148  country?: string | null;149  language?: string | null;150  changeClass?: string | null;151  fieldChanges?: FieldChange[];152  group?: string;153  sid?: string;154  replayed?: boolean;155}156157/** Normalize a WebSocket payload to the REST row shape used by all list components. */158export function liveToEvent(e: LiveEvent): EventItem {159  return {160    id: e.id,161    slug: e.slug,162    event_type: e.type,163    title: e.title,164    summary: e.summary,165    importance: e.importance,166    confidence: e.confidence,167    novelty: e.novelty,168    categories: e.categories ?? [],169    keywords: [],170    silent_change: Boolean(e.silent),171    evidence_label: e.evidence ?? "OBSERVED",172    url: e.url,173    detected_at: e.detectedAt,174    published_at: e.publishedAt ?? null,175    cluster_id: e.clusterId ?? null,176    sensor_id: e.sensor?.id ?? "",177    source_id: e.source?.id ?? "",178    source: e.source,179    sensor: e.sensor,180    entities: e.entities ?? [],181    cluster_size: e.clusterSize ?? 1,182    cluster: e.clusterId ? { id: e.clusterId, slug: e.clusterSlug ?? null, state: e.clusterState, event_count: e.clusterSize ?? 1, velocity: e.velocity } : null,183    signal_score: e.signal ?? null,184    velocity_score: e.velocity ?? 0,185    impact_score: e.impact ?? 0,186    change_class: e.changeClass ?? null,187    first_party: e.firstParty !== false,188    country: e.country ?? null,189    language: e.language ?? null,190    field_changes: e.fieldChanges ?? null,191  };192}193194export interface EventsPage {195  items: EventItem[];196  nextCursor: string | null;197}198199export interface CoverageSectorRow {200  sector: string;201  label: string;202  description: string | null;203  weight: number;204  members: number;205  covered: number;206  shadow_members: number;207  queued: number;208  sensors: number;209  breadth: number;210  depth: number;211  score: number;212}213export interface CoverageSummary {214  global_score: number;215  members: number;216  covered: number;217  sensors_matched: number;218  sectors: CoverageSectorRow[];219  monitored: { sources: number; sensors: number; shadow: number; countries: number };220  factory: Record<string, number>;221  method: string;222  issues: string[];223  generated_at: string;224}225export interface CoverageMemberRow {226  name: string;227  domain: string;228  country: string | null;229  importance: number;230  universe: string;231  covered: boolean;232  sensors: number;233  shadow: number;234  source_id: string | null;235  seed_status: string | null;236  depth: number;237}238export interface CoverageUniverseRow {239  key: string;240  label: string;241  description: string | null;242  provenance: string | null;243  members: number;244  covered: number;245  sensors: number;246  breadth: number;247  depth: number;248  score: number;249  items: CoverageMemberRow[];250}251export interface CoverageSectorDetail extends CoverageSectorRow {252  categories: string[];253  universes: CoverageUniverseRow[];254  by_country: { country: string; members: number; covered: number }[];255  generated_at: string;256}257258export interface Stats {259  sensors_shadow?: number;260  sources_factory?: number;261  checks_per_min?: number;262  events_per_min?: number;263  events_1h?: number;264  breaking_now?: number;265  developing_now?: number;266  sources_first_party?: number;267  countries?: number;268  not_modified_ratio_5m?: number | null;269  sources?: number;270  sensors?: number;271  entities?: number;272  snapshots?: number;273  events_total?: number;274  events_24h?: number;275  silent_24h?: number;276  breaking_24h?: number;277  changes_24h?: number;278  checks_today?: number;279  not_modified_today?: number;280  bytes_today?: number;281  checks_last_hour?: number;282  sensors_up?: number;283  sensors_degraded?: number;284  last_check_at?: string | null;285  last_event_at?: string | null;286  p50_processing_ms?: number | null;287  p50_detection_ms?: number | null;288}289290export interface TrendingItem {291  id: string;292  name: string;293  type: string;294  domain?: string | null;295  events: number;296  importance_sum: number;297  sources: number;298  silent: number;299  max_importance: number;300  prev_events: number;301  score: number;302  first_party?: number;303  confirmed?: number;304  avg_signal?: number;305  baseline_per_day?: number;306  direction?: "up" | "down" | "flat";307  entity_importance?: number;308}309310export interface Cluster {311  id: string;312  title: string;313  summary?: string | null;314  primary_event_id?: string | null;315  entity_ids: string[];316  categories: string[];317  event_count: number;318  max_importance: number;319  first_at: string;320  last_at: string;321  source?: SourceRef | null;322  events?: { id: string; slug: string; title: string; importance: number; event_type: string; detected_at: string; source_id: string; url: string; first_party?: boolean }[] | null;323  slug?: string | null;324  state?: string;325  source_count?: number;326  first_party_count?: number;327  external_count?: number;328  velocity?: number;329  lead_time_ms?: number | null;330  first_party_at?: string | null;331  first_external_at?: string | null;332  primary_slug?: string | null;333  event?: EventItem | null;334  sources?: { id: string; name: string; domain: string; first_party?: boolean }[] | null;335}336337export interface ClusterDetail {338  cluster: Cluster & { timeline?: { at: string; eventId: string; sourceId: string; sourceName?: string; sensorType?: string; firstParty: boolean; eventType: string; importance: number }[] };339  events: EventItem[];340  entities: { id: string; name: string; type: string; importance: number }[];341  propagation: { id: string; slug: string; at: string; offset_ms: number; source: SourceRef; sensor: SensorRef; first_party: boolean; event_type: string; importance: number; title: string }[];342  lead_time_ms: number | null;343  first_party_signals: number;344  external_signals: number;345}346347export interface BreakingDesk {348  breaking_now: Cluster[];349  developing: Cluster[];350  recently_confirmed: Cluster[];351  watching: EventItem[];352  generated_at: string;353}354355export interface Pulse {356  activity: { t: string; events: number; changes: number }[];357  desks: { desk: string; items: EventItem[] }[];358  rising_entities: { id: string; name: string; type: string; events_3h: number; events_prev_24h: number; acceleration: number }[];359  anomalies: { id: string; name: string; domain: string; changes_2h: number; baseline_per_day: number; activity_score: number; pct_vs_baseline: number | null }[];360  silent_changes: EventItem[];361  infrastructure: EventItem[];362  breaking: Cluster[];363  by_group_24h: Record<string, number>;364  totals: { events_1h: number; changes_1h: number; checks_5m: number; active_sources_24h: number; active_countries_24h: number };365  generated_at: string;366}367368export interface Radar {369  unusual_source_activity: { id: string; name: string; domain: string; categories: string[]; changes_3h: number; baseline_per_day: number; ratio: number }[];370  silent_clusters: { id: string; name: string; type: string; silent_24h: number; types: string[]; last_at: string }[];371  documentation_bursts: { id: string; name: string; domain: string; doc_changes_6h: number; types: string[]; last_at: string }[];372  repository_bursts: { id: string; name: string; domain: string; repo_events_6h: number; last_at: string }[];373  status_changes: EventItem[];374  developing: Cluster[];375  new_coverage: { id: string; name: string; source_id: string; source_name: string; first_event_at: string; events: number }[];376  generated_at: string;377  disclaimer: string;378}379380export interface EntityInsights {381  heatmap: { day: string; events: number; silent: number; breaking: number; max_importance: number }[];382  baseline_per_day: number;383  today: number;384  events_24h: number;385  events_prev_24h: number;386  velocity_ratio: number;387  anomaly: { score: number; ratio: number; pct: number };388  silent_24h: number;389  breaking_24h: number;390  sources_24h: number;391  most_active_sensors: { id: string; name: string; type: string; connector: string; source_id: string; events_7d: number; last_event_at: string }[];392  rank: { rank: number | null; total: number; score: number | null };393}394395export interface RankedEntity {396  id: string;397  name: string;398  type: string;399  domain?: string | null;400  importance: number;401  events_24h: number;402  events_7d: number;403  avg_signal: number;404  confirmed_ratio: number;405  sources: number;406  silent_24h: number;407  breaking_24h: number;408  last_at: string;409  baseline_per_day: number;410  rank_score: number;411  rank: number;412}413414export interface CountryRow {415  country: string;416  name: string;417  slug: string;418  flag: string;419  sources: number;420  events_24h: number;421  breaking_24h: number;422}423424export interface CountryDesk {425  country: { code: string; name: string; flag: string };426  breaking: EventItem[];427  by_category: { category: string; items: EventItem[] }[];428  sources: SourceRow[];429  by_type: { event_type: string; n: number }[];430  silent: EventItem[];431  recent: EventItem[];432  nextCursor: string | null;433}434435export interface CategoryDesk {436  channel: string;437  categories: string[];438  breaking: EventItem[];439  silent: EventItem[];440  active_sources: { id: string; name: string; domain: string; tier: string; first_party?: boolean; events_24h: number; max_importance: number }[];441  by_type: { event_type: string; n: number }[];442  trending_entities: { id: string; name: string; type: string; events_24h: number; sources: number }[];443  recent: EventItem[];444  nextCursor: string | null;445  series: { t: string; n: number }[];446}447448export interface Bookmark extends EventItem {449  bookmarked_at: string;450  note?: string | null;451}452453export interface Notification {454  id: number;455  alert_id: string;456  alert_name: string;457  event_id: string;458  channel: string;459  status: string;460  created_at: string;461  read_at?: string | null;462  event: { id: string; slug: string; title: string; importance: number; signal_score?: number | null; event_type: string; silent_change: boolean; detected_at: string; source: { id: string; name: string } };463}464465export interface Monitor {466  id: string;467  name: string;468  url: string;469  tier: string;470  health: string;471  status: string;472  enabled: boolean;473  config: Record<string, unknown>;474  next_check_at?: string | null;475  last_check_at?: string | null;476  last_change_at?: string | null;477  last_event_at?: string | null;478  last_status?: number | null;479  last_error?: string | null;480  total_runs?: number;481  raw_changes?: number;482  meaningful_changes?: number;483  created_at: string;484}485486export interface SourceRow {487  id: string;488  name: string;489  domain: string;490  homepage?: string | null;491  description?: string | null;492  categories: string[];493  tier: string;494  importance_weight?: number;495  enabled?: boolean;496  notes?: string | null;497  sensor_count?: number;498  event_count?: number;499  events_24h?: number;500  last_event_at?: string | null;501  last_check_at?: string | null;502  sensors_degraded?: number;503  robots_checked_at?: string | null;504  first_party?: boolean;505  country?: string | null;506  language?: string | null;507}508509export interface SensorRow {510  id: string;511  name: string;512  url: string;513  type: string;514  connector: string;515  tier: string;516  health: string;517  enabled: boolean;518  next_check_at?: string | null;519  last_check_at?: string | null;520  last_change_at?: string | null;521  last_event_at?: string | null;522  last_status?: number | null;523  last_error?: string | null;524  consecutive_errors?: number;525  total_runs?: number;526  total_not_modified?: number;527  raw_changes?: number;528  meaningful_changes?: number;529  avg_latency_ms?: number | null;530  base_interval_seconds?: number | null;531  has_etag?: boolean;532  has_last_modified?: boolean;533  source_id?: string;534  source_name?: string;535  domain?: string;536  config?: Record<string, unknown>;537  status?: string;538  priority?: number;539  validated_at?: string | null;540  etag?: string | null;541  last_modified?: string | null;542  checks_24h?: number;543  changes_24h?: number;544  snapshot_count?: number;545  current_interval_seconds?: number | null;546  not_modified_24h?: number;547  errors_24h?: number;548  avg_ms_24h?: number | null;549}550551export interface SourceQuality {552  sensors?: number;553  sensors_up?: number;554  avg_latency_ms?: number | null;555  raw_changes?: number;556  meaningful_changes?: number;557  total_runs?: number;558  total_not_modified?: number;559  structured_sensors?: number;560  checks_7d?: number;561  errors_7d?: number;562  avg_confidence?: number | null;563  success_rate: number;564  structured_share: number;565  usefulness: number;566  quality_score: number;567}568569export interface SourceDetail {570  source: SourceRow;571  sensors: SensorRow[];572  entities: { id: string; name: string; type: string; importance: number; event_count: number }[];573  activity: { changes_2h?: number; changes_14d?: number; events_24h?: number; events_14d?: number; baseline_changes_per_day?: number; activity_score?: number; silent_24h?: number; breaking_24h?: number };574  quality?: SourceQuality;575  discovery: { url: string; kind: string; evidence?: string | null; score?: { value?: number; itemCount?: number | null; title?: string | null } | null; status: string; found_at: string }[];576  by_type?: { event_type: string; n: number }[];577  daily?: { day: string; checks: number; not_modified: number; errors: number; raw_changes: number; events: number }[];578}579580export interface SensorDetail {581  sensor: SensorRow;582  runs: { id: string; started_at: string; finished_at?: string | null; http_status?: number | null; outcome: string; error?: string | null; duration_ms?: number | null; bytes?: number | null; fetch_method?: string | null; snapshot_id?: string | null }[];583  snapshots: SnapshotRow[];584  changes: ChangeRow[];585  events?: EventItem[];586}587588export interface SnapshotRow {589  id: string;590  url?: string;591  captured_at: string;592  http_status?: number | null;593  content_type?: string | null;594  content_length?: number | null;595  content_hash?: string;596  canonical_hash?: string;597  etag?: string | null;598  last_modified?: string | null;599  title?: string | null;600  mode?: string;601  fetch_duration_ms?: number | null;602  extraction_confidence?: number | null;603  storage_key?: string | null;604  has_raw?: boolean;605  has_change?: boolean;606  event_slug?: string | null;607}608609export interface ChangeRow {610  id: string;611  detected_at: string;612  kind: string;613  signal: number;614  noise_ratio?: number;615  magnitude?: number;616  meaningful: boolean;617  event_id?: string | null;618  old_snapshot_id?: string | null;619  new_snapshot_id?: string | null;620  heuristic_type?: string | null;621  diff?: DiffSummary;622  heuristic?: Record<string, unknown>;623  change_class?: string | null;624  field_changes?: FieldChange[] | null;625}626627export interface DiffSummary {628  kind: "text" | "json" | "list";629  added?: unknown[];630  removed?: unknown[];631  modified?: unknown[];632  changes?: { path: string; op: string; before?: unknown; after?: unknown }[];633  stats?: { added: number; removed: number; modified: number; unchangedRatio: number };634  counts?: { added: number; removed: number; modified: number };635  truncated?: boolean;636}637638export interface EventDetail {639  event: EventItem;640  related: EventItem[];641  cluster: Cluster | null;642  change: (ChangeRow & { diff: DiffSummary }) | null;643  interpretations: { version: number; model: string; created_at: string }[];644  snapshots: SnapshotRow[];645  sensor_reliability: { health?: string; success_rate?: number | null; avg_latency_ms?: number | null; total_runs?: number; raw_changes?: number; meaningful_changes?: number; last_check_at?: string | null } | null;646  history?: { id: string; slug: string; title: string; event_type: string; importance: number; silent_change: boolean; detected_at: string; field_changes?: FieldChange[] | null }[];647}648649export interface EntityRow {650  id: string;651  name: string;652  type: string;653  description?: string | null;654  domain?: string | null;655  homepage?: string | null;656  importance: number;657  categories: string[];658  parent_id?: string | null;659  event_count: number;660  last_event_at?: string | null;661  events_24h?: number;662}663664export interface EntityDetail {665  entity: EntityRow;666  parent?: { id: string; name: string; type: string } | null;667  children: EntityRow[];668  relations: { relation: string; from_id: string; to_id: string; from_name: string; to_name: string; to_type: string }[];669  sources: (SourceRef & { first_party?: boolean; country?: string | null; sensor_count?: number; events_24h?: number })[];670  aliases: string[];671  recent: EventItem[];672  nextCursor?: string | null;673  by_type: { event_type: string; n: number }[];674  insights?: EntityInsights;675  silent?: EventItem[];676  related?: { id: string; name: string; type: string; shared_events: number }[];677}678679export interface Explore {680  most_active_sources: { id: string; name: string; domain: string; first_party?: boolean; events_24h: number; max_importance: number }[];681  biggest_changes: EventItem[];682  silent_changes: EventItem[];683  clusters: Cluster[];684  unusual_activity: { id: string; name: string; domain: string; changes_2h: number; baseline_per_day: number; activity_score: number; pct_vs_baseline?: number | null }[];685  newly_detected?: EventItem[];686  confirmed_first_party?: EventItem[];687  by_type: { event_type: string; n: number }[];688  by_category: { category: string; n: number }[];689  channels: Record<string, string[]>;690  groups?: Record<string, string>;691  event_types: Record<string, string>;692}693694export interface ConnectorHealthRow {695  connector: string;696  status: string;697  runs_24h: number;698  errors_24h: number;699  success_rate?: number | null;700  avg_latency_ms?: number | null;701  changes_24h: number;702  events_24h: number;703  last_success_at?: string | null;704  last_error_at?: string | null;705  last_error?: string | null;706  http_codes: Record<string, number>;707  rate_limit_until?: string | null;708  updated_at: string;709}710711export interface HealthReport {712  connectors: ConnectorHealthRow[];713  sensors_by_health: { health: string; n: number }[];714  degraded_sensors: SensorRow[];715  noisy_sensors: { id: string; name: string; source_id: string; url: string; raw_changes: number; meaningful_changes: number; noise_ratio: number | null }[];716  daily: Record<string, number | string>[];717  live: { clients: number; published: number };718  sensors_by_status?: { status: string; n: number }[];719  sensors_by_connector?: { connector: string; sensors: number; up: number; avg_latency_ms: number | null }[];720  throughput?: { checks_5m: number; not_modified_5m: number; events_5m: number; changes_5m: number; queue_due: number; avg_latency_5m_ms: number | null; noise_filtered_24h: number; checks_per_min: number; events_per_min: number; not_modified_ratio: number | null };721  engine?: { inflight?: number; concurrency?: number; due?: number; busyHosts?: { host: string; inflight: number }[]; circuitOpen?: { host: string; failures: number; until: string }[]; at?: string; version?: string } | null;722  top_failing_domains?: { host: string; failures: number; last_error: string | null }[];723  slowest_sensors?: { id: string; name: string; source_id: string; avg_latency_ms: number; connector: string }[];724}725726export interface SearchResult {727  query: string;728  parsed?: { text: string; filters: Record<string, unknown> };729  events: EventItem[];730  entities: EntityRow[];731  sources: SourceRow[];732  urls: { url: string; domain: string; status: string; change_count: number; last_seen_at: string }[];733  clusters?: { id: string; slug: string; title: string; state: string; event_count: number; source_count: number; max_importance: number; last_at: string }[];734}735736export interface DomainTimeline {737  domain: string;738  source_id: string | null;739  urls: { url: string; status: string; first_seen_at: string; last_seen_at: string; snapshot_count: number; change_count: number; sensor_id?: string | null }[];740  events: EventItem[];741  nextCursor: string | null;742}743744export interface UrlHistory {745  url: { url: string; domain?: string; status?: string; first_seen_at?: string; last_seen_at?: string; snapshot_count?: number; change_count?: number; sensor_id?: string | null };746  history: { id: number; at: string; kind: string; snapshot_id?: string | null; change_id?: string | null; event_id?: string | null; note?: string | null; event_title?: string | null; importance?: number | null; event_type?: string | null; event_slug?: string | null; change_kind?: string | null; signal?: number | null }[];747  snapshots: SnapshotRow[];748}749750export interface CompareResult {751  a: SnapshotRow;752  b: SnapshotRow;753  before: string;754  after: string;755  diff: { unified: string; stats: { added: number; removed: number; modified: number; unchangedRatio: number }; added: string[]; removed: string[]; modified: { before: string; after: string }[] };756}757758export interface Watchlist {759  id: string;760  name: string;761  owner_token?: string;762  created_at: string;763  items: { kind: string; value: string; added_at?: string }[];764}765766export interface AlertRule {767  importance_min?: number;768  signal_min?: number;769  event_types?: string[];770  groups?: string[];771  entities?: string[];772  sources?: string[];773  keywords?: string[];774  silent_only?: boolean;775  first_party_only?: boolean;776  confirmed_only?: boolean;777  categories?: string[];778  countries?: string[];779}780781export interface Alert {782  id: string;783  name: string;784  rule: AlertRule;785  channel: string;786  channel_config?: { url?: string };787  enabled: boolean;788  created_at: string;789  last_fired_at?: string | null;790  fired_count?: number;791  fired_24h?: number;792}793794// ---------------------------------------------------------------------------------------795// Server-side fetch helpers (never throw)796// ---------------------------------------------------------------------------------------797798async function getJson<T>(path: string, fallback: T, init?: RequestInit & { revalidate?: number }): Promise<T> {799  try {800    const url = `${serverApiBase()}${path}`;801    const res = await fetch(url, { ...(init?.revalidate ? { next: { revalidate: init.revalidate } } : { cache: "no-store" }), headers: { accept: "application/json" }, signal: AbortSignal.timeout(8000) });802    if (!res.ok) return fallback;803    return (await res.json()) as T;804  } catch {805    return fallback;806  }807}808809export type EventQuery = Partial<{810  after: string;811  before: string;812  category: string;813  entity: string;814  source: string;815  domain: string;816  sensor: string;817  cluster: string;818  importance_min: number;819  confidence_min: number;820  signal_min: number;821  event_type: string;822  group: string;823  silent_change: boolean;824  first_party: boolean;825  confirmed: boolean;826  country: string;827  language: string;828  change_class: string;829  q: string;830  limit: number;831  cursor: string;832  order: "recent" | "importance" | "signal";833}>;834835export function eventQueryString(q: EventQuery): string {836  const p = new URLSearchParams();837  for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== "") p.set(k, String(v));838  const s = p.toString();839  return s ? `?${s}` : "";840}841842export const api = {843  events: (q: EventQuery = {}) => getJson<EventsPage>(`/api/v1/events${eventQueryString(q)}`, { items: [], nextCursor: null }),844  /** Signal ≥ 80 over the last 48 h, ranked (legacy list; the /breaking desk uses `breakingDesk`). */845  breaking: (limit = 60) => getJson<EventsPage>(`/api/v1/events${eventQueryString({ signal_min: 80, order: "signal", after: new Date(Date.now() - 48 * 3600e3).toISOString(), limit })}`, { items: [], nextCursor: null }),846  breakingDesk: () => getJson<BreakingDesk | null>("/api/v1/breaking", null),847  pulse: () => getJson<Pulse | null>("/api/v1/pulse", null),848  radar: () => getJson<Radar | null>("/api/v1/radar", null),849  cluster: (idOrSlug: string) => getJson<ClusterDetail | null>(`/api/v1/clusters/${encodeURIComponent(idOrSlug)}`, null),850  rank: (limit = 100) => getJson<{ items: RankedEntity[] }>(`/api/v1/entities/rank?limit=${limit}`, { items: [] }),851  countries: () => getJson<{ items: CountryRow[] }>("/api/v1/countries", { items: [] }),852  country: (code: string) => getJson<CountryDesk | null>(`/api/v1/countries/${encodeURIComponent(code)}`, null),853  categoryDesk: (channel: string) => getJson<CategoryDesk | null>(`/api/v1/categories/${encodeURIComponent(channel)}`, null),854  sensorSnapshots: (id: string, limit = 200) => getJson<{ items: SnapshotRow[] }>(`/api/v1/sensors/${encodeURIComponent(id)}/snapshots?limit=${limit}`, { items: [] }),855  eventCount: (q: EventQuery = {}) => getJson<{ count: number }>(`/api/v1/events/count${eventQueryString(q)}`, { count: 0 }),856  event: (idOrSlug: string) => getJson<EventDetail | null>(`/api/v1/events/${encodeURIComponent(idOrSlug)}`, null),857  stats: () => getJson<Stats>("/api/v1/stats", {}, { revalidate: 5 }),858  trending: (hours = 24, limit = 10) => getJson<{ items: TrendingItem[] }>(`/api/v1/trending?hours=${hours}&limit=${limit}`, { items: [] }),859  explore: () => getJson<Explore | null>("/api/v1/explore", null),860  clusters: (limit = 20, since = 72) => getJson<{ items: Cluster[] }>(`/api/v1/clusters?limit=${limit}&since=${since}`, { items: [] }),861  sources: (q: { category?: string; q?: string; country?: string; tier?: string; first_party?: string } = {}) => getJson<{ items: SourceRow[] }>(`/api/v1/sources${eventQueryString(q as EventQuery)}`, { items: [] }),862  source: (id: string) => getJson<SourceDetail | null>(`/api/v1/sources/${encodeURIComponent(id)}`, null),863  sensor: (id: string) => getJson<SensorDetail | null>(`/api/v1/sensors/${encodeURIComponent(id)}`, null),864  entities: (q: { type?: string; q?: string; limit?: number } = {}) => getJson<{ items: EntityRow[] }>(`/api/v1/entities${eventQueryString(q)}`, { items: [] }),865  entity: (id: string) => getJson<EntityDetail | null>(`/api/v1/entities/${encodeURIComponent(id)}`, null),866  entityTimeline: (id: string, cursor?: string, limit = 100) => getJson<EventsPage>(`/api/v1/entities/${encodeURIComponent(id)}/timeline${eventQueryString({ cursor, limit })}`, { items: [], nextCursor: null }),867  domain: (domain: string, cursor?: string) => getJson<DomainTimeline | null>(`/api/v1/domains/${encodeURIComponent(domain)}/timeline${eventQueryString({ cursor })}`, null),868  urlHistory: (url: string) => getJson<UrlHistory | null>(`/api/v1/urls/history?url=${encodeURIComponent(url)}`, null),869  compare: (a: string, b: string) => getJson<CompareResult | null>(`/api/v1/snapshots/compare?a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`, null),870  change: (id: string) => getJson<{ change: ChangeRow & { diff: DiffSummary; sensor_url?: string; sensor_name?: string; source_id?: string }; unified: string | null } | null>(`/api/v1/changes/${encodeURIComponent(id)}`, null),871  health: () => getJson<HealthReport | null>("/api/v1/health/connectors", null),872  coverage: () => getJson<CoverageSummary | null>("/api/v1/coverage", null, { revalidate: 60 }),873  coverageSector: (sector: string) => getJson<CoverageSectorDetail | null>(`/api/v1/coverage/${encodeURIComponent(sector)}`, null, { revalidate: 60 }),874  search: (q: string) => getJson<SearchResult>(`/api/v1/search?q=${encodeURIComponent(q)}`, { query: q, events: [], entities: [], sources: [], urls: [] }),875};876