/** * Typed gateway client. Server Components call the gateway over loopback (`API_URL`); * client components use same-origin `/api/v1` (or `NEXT_PUBLIC_API_URL` in dev). * Every server helper swallows failures and returns an empty shape so pages render * "warming up" states instead of crashing. */ export const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL ?? "https://www.websensor.io"; export function serverApiBase(): string { return process.env.API_URL ?? "http://127.0.0.1:8260"; } export function clientApiBase(): string { if (typeof window === "undefined") return serverApiBase(); return process.env.NEXT_PUBLIC_API_URL ?? ""; } // --------------------------------------------------------------------------------------- // Types (snake_case = REST rows; camelCase = WebSocket payloads) // --------------------------------------------------------------------------------------- export interface EntityRef { id: string; name: string; type: string; role?: string; } export interface SourceRef { id: string; name: string; domain: string; tier?: string; categories?: string[]; } export interface SensorRef { id: string; name: string; type: string; connector?: string; tier?: string; } export interface EventItem { id: string; slug: string; event_type: string; title: string; summary: string; why_it_matters?: string | null; importance: number; confidence: number; novelty: number; categories: string[]; keywords: string[]; silent_change: boolean; evidence_label: string; url: string; detected_at: string; published_at?: string | null; observed_from?: string | null; processed_at?: string | null; published_to_feed_at?: string | null; detection_latency_ms?: number | null; processing_latency_ms?: number | null; cluster_id?: string | null; sensor_id: string; source_id: string; change_id?: string | null; old_snapshot_id?: string | null; new_snapshot_id?: string | null; importance_components?: Record | null; processing_version?: string; interpretation?: Record | null; source: SourceRef; sensor: SensorRef; entities: EntityRef[]; cluster_size?: number | null; // 0.2 intelligence fields signal_score?: number | null; velocity_score?: number | null; impact_score?: number | null; anomaly_score?: number | null; change_class?: string | null; first_party?: boolean; country?: string | null; language?: string | null; canonical_url?: string | null; field_changes?: FieldChange[] | null; score_reasons?: ScoreReason[] | null; cluster?: ClusterRef | null; } export interface FieldChange { label: string; kind: "price" | "percent" | "number" | "date" | "version" | "status" | "text"; before: string | null; after: string | null; deltaPct?: number | null; } export interface ScoreReason { sign: "+" | "-"; text: string; points?: number; } export interface ClusterRef { id: string; slug?: string | null; state?: string; event_count: number; source_count?: number; first_party_count?: number; external_count?: number; velocity?: number; lead_time_ms?: number | null; } export interface LiveEvent { id: string; slug: string; type: string; title: string; summary: string; importance: number; confidence: number; novelty: number; silent: boolean; evidence: string; source: SourceRef; sensor: SensorRef; entities: EntityRef[]; categories: string[]; url: string; clusterId?: string | null; clusterSlug?: string | null; clusterSize?: number; clusterState?: string; detectedAt: string; publishedAt?: string | null; signal?: number; impact?: number; velocity?: number; firstParty?: boolean; country?: string | null; language?: string | null; changeClass?: string | null; fieldChanges?: FieldChange[]; group?: string; sid?: string; replayed?: boolean; } /** Normalize a WebSocket payload to the REST row shape used by all list components. */ export function liveToEvent(e: LiveEvent): EventItem { return { id: e.id, slug: e.slug, event_type: e.type, title: e.title, summary: e.summary, importance: e.importance, confidence: e.confidence, novelty: e.novelty, categories: e.categories ?? [], keywords: [], silent_change: Boolean(e.silent), evidence_label: e.evidence ?? "OBSERVED", url: e.url, detected_at: e.detectedAt, published_at: e.publishedAt ?? null, cluster_id: e.clusterId ?? null, sensor_id: e.sensor?.id ?? "", source_id: e.source?.id ?? "", source: e.source, sensor: e.sensor, entities: e.entities ?? [], cluster_size: e.clusterSize ?? 1, cluster: e.clusterId ? { id: e.clusterId, slug: e.clusterSlug ?? null, state: e.clusterState, event_count: e.clusterSize ?? 1, velocity: e.velocity } : null, signal_score: e.signal ?? null, velocity_score: e.velocity ?? 0, impact_score: e.impact ?? 0, change_class: e.changeClass ?? null, first_party: e.firstParty !== false, country: e.country ?? null, language: e.language ?? null, field_changes: e.fieldChanges ?? null, }; } export interface EventsPage { items: EventItem[]; nextCursor: string | null; } export interface CoverageSectorRow { sector: string; label: string; description: string | null; weight: number; members: number; covered: number; shadow_members: number; queued: number; sensors: number; breadth: number; depth: number; score: number; } export interface CoverageSummary { global_score: number; members: number; covered: number; sensors_matched: number; sectors: CoverageSectorRow[]; monitored: { sources: number; sensors: number; shadow: number; countries: number }; factory: Record; method: string; issues: string[]; generated_at: string; } export interface CoverageMemberRow { name: string; domain: string; country: string | null; importance: number; universe: string; covered: boolean; sensors: number; shadow: number; source_id: string | null; seed_status: string | null; depth: number; } export interface CoverageUniverseRow { key: string; label: string; description: string | null; provenance: string | null; members: number; covered: number; sensors: number; breadth: number; depth: number; score: number; items: CoverageMemberRow[]; } export interface CoverageSectorDetail extends CoverageSectorRow { categories: string[]; universes: CoverageUniverseRow[]; by_country: { country: string; members: number; covered: number }[]; generated_at: string; } export interface Stats { sensors_shadow?: number; sources_factory?: number; checks_per_min?: number; events_per_min?: number; events_1h?: number; breaking_now?: number; developing_now?: number; sources_first_party?: number; countries?: number; not_modified_ratio_5m?: number | null; sources?: number; sensors?: number; entities?: number; snapshots?: number; events_total?: number; events_24h?: number; silent_24h?: number; breaking_24h?: number; changes_24h?: number; checks_today?: number; not_modified_today?: number; bytes_today?: number; checks_last_hour?: number; sensors_up?: number; sensors_degraded?: number; last_check_at?: string | null; last_event_at?: string | null; p50_processing_ms?: number | null; p50_detection_ms?: number | null; } export interface TrendingItem { id: string; name: string; type: string; domain?: string | null; events: number; importance_sum: number; sources: number; silent: number; max_importance: number; prev_events: number; score: number; first_party?: number; confirmed?: number; avg_signal?: number; baseline_per_day?: number; direction?: "up" | "down" | "flat"; entity_importance?: number; } export interface Cluster { id: string; title: string; summary?: string | null; primary_event_id?: string | null; entity_ids: string[]; categories: string[]; event_count: number; max_importance: number; first_at: string; last_at: string; source?: SourceRef | null; events?: { id: string; slug: string; title: string; importance: number; event_type: string; detected_at: string; source_id: string; url: string; first_party?: boolean }[] | null; slug?: string | null; state?: string; source_count?: number; first_party_count?: number; external_count?: number; velocity?: number; lead_time_ms?: number | null; first_party_at?: string | null; first_external_at?: string | null; primary_slug?: string | null; event?: EventItem | null; sources?: { id: string; name: string; domain: string; first_party?: boolean }[] | null; } export interface ClusterDetail { cluster: Cluster & { timeline?: { at: string; eventId: string; sourceId: string; sourceName?: string; sensorType?: string; firstParty: boolean; eventType: string; importance: number }[] }; events: EventItem[]; entities: { id: string; name: string; type: string; importance: number }[]; propagation: { id: string; slug: string; at: string; offset_ms: number; source: SourceRef; sensor: SensorRef; first_party: boolean; event_type: string; importance: number; title: string }[]; lead_time_ms: number | null; first_party_signals: number; external_signals: number; } export interface BreakingDesk { breaking_now: Cluster[]; developing: Cluster[]; recently_confirmed: Cluster[]; watching: EventItem[]; generated_at: string; } export interface Pulse { activity: { t: string; events: number; changes: number }[]; desks: { desk: string; items: EventItem[] }[]; rising_entities: { id: string; name: string; type: string; events_3h: number; events_prev_24h: number; acceleration: number }[]; anomalies: { id: string; name: string; domain: string; changes_2h: number; baseline_per_day: number; activity_score: number; pct_vs_baseline: number | null }[]; silent_changes: EventItem[]; infrastructure: EventItem[]; breaking: Cluster[]; by_group_24h: Record; totals: { events_1h: number; changes_1h: number; checks_5m: number; active_sources_24h: number; active_countries_24h: number }; generated_at: string; } export interface Radar { unusual_source_activity: { id: string; name: string; domain: string; categories: string[]; changes_3h: number; baseline_per_day: number; ratio: number }[]; silent_clusters: { id: string; name: string; type: string; silent_24h: number; types: string[]; last_at: string }[]; documentation_bursts: { id: string; name: string; domain: string; doc_changes_6h: number; types: string[]; last_at: string }[]; repository_bursts: { id: string; name: string; domain: string; repo_events_6h: number; last_at: string }[]; status_changes: EventItem[]; developing: Cluster[]; new_coverage: { id: string; name: string; source_id: string; source_name: string; first_event_at: string; events: number }[]; generated_at: string; disclaimer: string; } export interface EntityInsights { heatmap: { day: string; events: number; silent: number; breaking: number; max_importance: number }[]; baseline_per_day: number; today: number; events_24h: number; events_prev_24h: number; velocity_ratio: number; anomaly: { score: number; ratio: number; pct: number }; silent_24h: number; breaking_24h: number; sources_24h: number; most_active_sensors: { id: string; name: string; type: string; connector: string; source_id: string; events_7d: number; last_event_at: string }[]; rank: { rank: number | null; total: number; score: number | null }; } export interface RankedEntity { id: string; name: string; type: string; domain?: string | null; importance: number; events_24h: number; events_7d: number; avg_signal: number; confirmed_ratio: number; sources: number; silent_24h: number; breaking_24h: number; last_at: string; baseline_per_day: number; rank_score: number; rank: number; } export interface CountryRow { country: string; name: string; slug: string; flag: string; sources: number; events_24h: number; breaking_24h: number; } export interface CountryDesk { country: { code: string; name: string; flag: string }; breaking: EventItem[]; by_category: { category: string; items: EventItem[] }[]; sources: SourceRow[]; by_type: { event_type: string; n: number }[]; silent: EventItem[]; recent: EventItem[]; nextCursor: string | null; } export interface CategoryDesk { channel: string; categories: string[]; breaking: EventItem[]; silent: EventItem[]; active_sources: { id: string; name: string; domain: string; tier: string; first_party?: boolean; events_24h: number; max_importance: number }[]; by_type: { event_type: string; n: number }[]; trending_entities: { id: string; name: string; type: string; events_24h: number; sources: number }[]; recent: EventItem[]; nextCursor: string | null; series: { t: string; n: number }[]; } export interface Bookmark extends EventItem { bookmarked_at: string; note?: string | null; } export interface Notification { id: number; alert_id: string; alert_name: string; event_id: string; channel: string; status: string; created_at: string; read_at?: string | null; 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 } }; } export interface Monitor { id: string; name: string; url: string; tier: string; health: string; status: string; enabled: boolean; config: Record; next_check_at?: string | null; last_check_at?: string | null; last_change_at?: string | null; last_event_at?: string | null; last_status?: number | null; last_error?: string | null; total_runs?: number; raw_changes?: number; meaningful_changes?: number; created_at: string; } export interface SourceRow { id: string; name: string; domain: string; homepage?: string | null; description?: string | null; categories: string[]; tier: string; importance_weight?: number; enabled?: boolean; notes?: string | null; sensor_count?: number; event_count?: number; events_24h?: number; last_event_at?: string | null; last_check_at?: string | null; sensors_degraded?: number; robots_checked_at?: string | null; first_party?: boolean; country?: string | null; language?: string | null; } export interface SensorRow { id: string; name: string; url: string; type: string; connector: string; tier: string; health: string; enabled: boolean; next_check_at?: string | null; last_check_at?: string | null; last_change_at?: string | null; last_event_at?: string | null; last_status?: number | null; last_error?: string | null; consecutive_errors?: number; total_runs?: number; total_not_modified?: number; raw_changes?: number; meaningful_changes?: number; avg_latency_ms?: number | null; base_interval_seconds?: number | null; has_etag?: boolean; has_last_modified?: boolean; source_id?: string; source_name?: string; domain?: string; config?: Record; status?: string; priority?: number; validated_at?: string | null; etag?: string | null; last_modified?: string | null; checks_24h?: number; changes_24h?: number; snapshot_count?: number; current_interval_seconds?: number | null; not_modified_24h?: number; errors_24h?: number; avg_ms_24h?: number | null; } export interface SourceQuality { sensors?: number; sensors_up?: number; avg_latency_ms?: number | null; raw_changes?: number; meaningful_changes?: number; total_runs?: number; total_not_modified?: number; structured_sensors?: number; checks_7d?: number; errors_7d?: number; avg_confidence?: number | null; success_rate: number; structured_share: number; usefulness: number; quality_score: number; } export interface SourceDetail { source: SourceRow; sensors: SensorRow[]; entities: { id: string; name: string; type: string; importance: number; event_count: number }[]; 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 }; quality?: SourceQuality; discovery: { url: string; kind: string; evidence?: string | null; score?: { value?: number; itemCount?: number | null; title?: string | null } | null; status: string; found_at: string }[]; by_type?: { event_type: string; n: number }[]; daily?: { day: string; checks: number; not_modified: number; errors: number; raw_changes: number; events: number }[]; } export interface SensorDetail { sensor: SensorRow; 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 }[]; snapshots: SnapshotRow[]; changes: ChangeRow[]; events?: EventItem[]; } export interface SnapshotRow { id: string; url?: string; captured_at: string; http_status?: number | null; content_type?: string | null; content_length?: number | null; content_hash?: string; canonical_hash?: string; etag?: string | null; last_modified?: string | null; title?: string | null; mode?: string; fetch_duration_ms?: number | null; extraction_confidence?: number | null; storage_key?: string | null; has_raw?: boolean; has_change?: boolean; event_slug?: string | null; } export interface ChangeRow { id: string; detected_at: string; kind: string; signal: number; noise_ratio?: number; magnitude?: number; meaningful: boolean; event_id?: string | null; old_snapshot_id?: string | null; new_snapshot_id?: string | null; heuristic_type?: string | null; diff?: DiffSummary; heuristic?: Record; change_class?: string | null; field_changes?: FieldChange[] | null; } export interface DiffSummary { kind: "text" | "json" | "list"; added?: unknown[]; removed?: unknown[]; modified?: unknown[]; changes?: { path: string; op: string; before?: unknown; after?: unknown }[]; stats?: { added: number; removed: number; modified: number; unchangedRatio: number }; counts?: { added: number; removed: number; modified: number }; truncated?: boolean; } export interface EventDetail { event: EventItem; related: EventItem[]; cluster: Cluster | null; change: (ChangeRow & { diff: DiffSummary }) | null; interpretations: { version: number; model: string; created_at: string }[]; snapshots: SnapshotRow[]; 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; history?: { id: string; slug: string; title: string; event_type: string; importance: number; silent_change: boolean; detected_at: string; field_changes?: FieldChange[] | null }[]; } export interface EntityRow { id: string; name: string; type: string; description?: string | null; domain?: string | null; homepage?: string | null; importance: number; categories: string[]; parent_id?: string | null; event_count: number; last_event_at?: string | null; events_24h?: number; } export interface EntityDetail { entity: EntityRow; parent?: { id: string; name: string; type: string } | null; children: EntityRow[]; relations: { relation: string; from_id: string; to_id: string; from_name: string; to_name: string; to_type: string }[]; sources: (SourceRef & { first_party?: boolean; country?: string | null; sensor_count?: number; events_24h?: number })[]; aliases: string[]; recent: EventItem[]; nextCursor?: string | null; by_type: { event_type: string; n: number }[]; insights?: EntityInsights; silent?: EventItem[]; related?: { id: string; name: string; type: string; shared_events: number }[]; } export interface Explore { most_active_sources: { id: string; name: string; domain: string; first_party?: boolean; events_24h: number; max_importance: number }[]; biggest_changes: EventItem[]; silent_changes: EventItem[]; clusters: Cluster[]; unusual_activity: { id: string; name: string; domain: string; changes_2h: number; baseline_per_day: number; activity_score: number; pct_vs_baseline?: number | null }[]; newly_detected?: EventItem[]; confirmed_first_party?: EventItem[]; by_type: { event_type: string; n: number }[]; by_category: { category: string; n: number }[]; channels: Record; groups?: Record; event_types: Record; } export interface ConnectorHealthRow { connector: string; status: string; runs_24h: number; errors_24h: number; success_rate?: number | null; avg_latency_ms?: number | null; changes_24h: number; events_24h: number; last_success_at?: string | null; last_error_at?: string | null; last_error?: string | null; http_codes: Record; rate_limit_until?: string | null; updated_at: string; } export interface HealthReport { connectors: ConnectorHealthRow[]; sensors_by_health: { health: string; n: number }[]; degraded_sensors: SensorRow[]; noisy_sensors: { id: string; name: string; source_id: string; url: string; raw_changes: number; meaningful_changes: number; noise_ratio: number | null }[]; daily: Record[]; live: { clients: number; published: number }; sensors_by_status?: { status: string; n: number }[]; sensors_by_connector?: { connector: string; sensors: number; up: number; avg_latency_ms: number | null }[]; 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 }; engine?: { inflight?: number; concurrency?: number; due?: number; busyHosts?: { host: string; inflight: number }[]; circuitOpen?: { host: string; failures: number; until: string }[]; at?: string; version?: string } | null; top_failing_domains?: { host: string; failures: number; last_error: string | null }[]; slowest_sensors?: { id: string; name: string; source_id: string; avg_latency_ms: number; connector: string }[]; } export interface SearchResult { query: string; parsed?: { text: string; filters: Record }; events: EventItem[]; entities: EntityRow[]; sources: SourceRow[]; urls: { url: string; domain: string; status: string; change_count: number; last_seen_at: string }[]; clusters?: { id: string; slug: string; title: string; state: string; event_count: number; source_count: number; max_importance: number; last_at: string }[]; } export interface DomainTimeline { domain: string; source_id: string | null; urls: { url: string; status: string; first_seen_at: string; last_seen_at: string; snapshot_count: number; change_count: number; sensor_id?: string | null }[]; events: EventItem[]; nextCursor: string | null; } export interface UrlHistory { 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 }; 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 }[]; snapshots: SnapshotRow[]; } export interface CompareResult { a: SnapshotRow; b: SnapshotRow; before: string; after: string; diff: { unified: string; stats: { added: number; removed: number; modified: number; unchangedRatio: number }; added: string[]; removed: string[]; modified: { before: string; after: string }[] }; } export interface Watchlist { id: string; name: string; owner_token?: string; created_at: string; items: { kind: string; value: string; added_at?: string }[]; } export interface AlertRule { importance_min?: number; signal_min?: number; event_types?: string[]; groups?: string[]; entities?: string[]; sources?: string[]; keywords?: string[]; silent_only?: boolean; first_party_only?: boolean; confirmed_only?: boolean; categories?: string[]; countries?: string[]; } export interface Alert { id: string; name: string; rule: AlertRule; channel: string; channel_config?: { url?: string }; enabled: boolean; created_at: string; last_fired_at?: string | null; fired_count?: number; fired_24h?: number; } // --------------------------------------------------------------------------------------- // Server-side fetch helpers (never throw) // --------------------------------------------------------------------------------------- async function getJson(path: string, fallback: T, init?: RequestInit & { revalidate?: number }): Promise { try { const url = `${serverApiBase()}${path}`; const res = await fetch(url, { ...(init?.revalidate ? { next: { revalidate: init.revalidate } } : { cache: "no-store" }), headers: { accept: "application/json" }, signal: AbortSignal.timeout(8000) }); if (!res.ok) return fallback; return (await res.json()) as T; } catch { return fallback; } } export type EventQuery = Partial<{ after: string; before: string; category: string; entity: string; source: string; domain: string; sensor: string; cluster: string; importance_min: number; confidence_min: number; signal_min: number; event_type: string; group: string; silent_change: boolean; first_party: boolean; confirmed: boolean; country: string; language: string; change_class: string; q: string; limit: number; cursor: string; order: "recent" | "importance" | "signal"; }>; export function eventQueryString(q: EventQuery): string { const p = new URLSearchParams(); for (const [k, v] of Object.entries(q)) if (v !== undefined && v !== null && v !== "") p.set(k, String(v)); const s = p.toString(); return s ? `?${s}` : ""; } export const api = { events: (q: EventQuery = {}) => getJson(`/api/v1/events${eventQueryString(q)}`, { items: [], nextCursor: null }), /** Signal ≥ 80 over the last 48 h, ranked (legacy list; the /breaking desk uses `breakingDesk`). */ breaking: (limit = 60) => getJson(`/api/v1/events${eventQueryString({ signal_min: 80, order: "signal", after: new Date(Date.now() - 48 * 3600e3).toISOString(), limit })}`, { items: [], nextCursor: null }), breakingDesk: () => getJson("/api/v1/breaking", null), pulse: () => getJson("/api/v1/pulse", null), radar: () => getJson("/api/v1/radar", null), cluster: (idOrSlug: string) => getJson(`/api/v1/clusters/${encodeURIComponent(idOrSlug)}`, null), rank: (limit = 100) => getJson<{ items: RankedEntity[] }>(`/api/v1/entities/rank?limit=${limit}`, { items: [] }), countries: () => getJson<{ items: CountryRow[] }>("/api/v1/countries", { items: [] }), country: (code: string) => getJson(`/api/v1/countries/${encodeURIComponent(code)}`, null), categoryDesk: (channel: string) => getJson(`/api/v1/categories/${encodeURIComponent(channel)}`, null), sensorSnapshots: (id: string, limit = 200) => getJson<{ items: SnapshotRow[] }>(`/api/v1/sensors/${encodeURIComponent(id)}/snapshots?limit=${limit}`, { items: [] }), eventCount: (q: EventQuery = {}) => getJson<{ count: number }>(`/api/v1/events/count${eventQueryString(q)}`, { count: 0 }), event: (idOrSlug: string) => getJson(`/api/v1/events/${encodeURIComponent(idOrSlug)}`, null), stats: () => getJson("/api/v1/stats", {}, { revalidate: 5 }), trending: (hours = 24, limit = 10) => getJson<{ items: TrendingItem[] }>(`/api/v1/trending?hours=${hours}&limit=${limit}`, { items: [] }), explore: () => getJson("/api/v1/explore", null), clusters: (limit = 20, since = 72) => getJson<{ items: Cluster[] }>(`/api/v1/clusters?limit=${limit}&since=${since}`, { items: [] }), sources: (q: { category?: string; q?: string; country?: string; tier?: string; first_party?: string } = {}) => getJson<{ items: SourceRow[] }>(`/api/v1/sources${eventQueryString(q as EventQuery)}`, { items: [] }), source: (id: string) => getJson(`/api/v1/sources/${encodeURIComponent(id)}`, null), sensor: (id: string) => getJson(`/api/v1/sensors/${encodeURIComponent(id)}`, null), entities: (q: { type?: string; q?: string; limit?: number } = {}) => getJson<{ items: EntityRow[] }>(`/api/v1/entities${eventQueryString(q)}`, { items: [] }), entity: (id: string) => getJson(`/api/v1/entities/${encodeURIComponent(id)}`, null), entityTimeline: (id: string, cursor?: string, limit = 100) => getJson(`/api/v1/entities/${encodeURIComponent(id)}/timeline${eventQueryString({ cursor, limit })}`, { items: [], nextCursor: null }), domain: (domain: string, cursor?: string) => getJson(`/api/v1/domains/${encodeURIComponent(domain)}/timeline${eventQueryString({ cursor })}`, null), urlHistory: (url: string) => getJson(`/api/v1/urls/history?url=${encodeURIComponent(url)}`, null), compare: (a: string, b: string) => getJson(`/api/v1/snapshots/compare?a=${encodeURIComponent(a)}&b=${encodeURIComponent(b)}`, null), 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), health: () => getJson("/api/v1/health/connectors", null), coverage: () => getJson("/api/v1/coverage", null, { revalidate: 60 }), coverageSector: (sector: string) => getJson(`/api/v1/coverage/${encodeURIComponent(sector)}`, null, { revalidate: 60 }), search: (q: string) => getJson(`/api/v1/search?q=${encodeURIComponent(q)}`, { query: q, events: [], entities: [], sources: [], urls: [] }), };