| 1 | |
| 2 | function resolveBase(): string { |
| 3 | const override = process.env.NEXT_PUBLIC_API_BASE; |
| 4 | if (override && override !== "http://localhost:8000/api/v1") return override; |
| 5 | if (typeof window !== "undefined") { |
| 6 | return `${window.location.protocol}//${window.location.hostname}:8000/api/v1`; |
| 7 | } |
| 8 | return override || "http://localhost:8000/api/v1"; |
| 9 | } |
| 10 | |
| 11 | const BASE = resolveBase(); |
| 12 | const TOKEN_KEY = "sightline_token"; |
| 13 | |
| 14 | export function getToken(): string | null { |
| 15 | if (typeof window === "undefined") return null; |
| 16 | return window.localStorage.getItem(TOKEN_KEY); |
| 17 | } |
| 18 | export function setToken(t: string) { |
| 19 | window.localStorage.setItem(TOKEN_KEY, t); |
| 20 | } |
| 21 | export function clearToken() { |
| 22 | window.localStorage.removeItem(TOKEN_KEY); |
| 23 | } |
| 24 | |
| 25 | async function req<T>(path: string, init: RequestInit = {}): Promise<T> { |
| 26 | const headers = new Headers(init.headers); |
| 27 | const token = getToken(); |
| 28 | if (token) headers.set("Authorization", `Bearer ${token}`); |
| 29 | if (!headers.has("Content-Type") && init.body && !(init.body instanceof FormData)) { |
| 30 | headers.set("Content-Type", "application/json"); |
| 31 | } |
| 32 | const res = await fetch(`${BASE}${path}`, { ...init, headers }); |
| 33 | if (res.status === 401) { |
| 34 | clearToken(); |
| 35 | throw new ApiError(401, "unauthorized"); |
| 36 | } |
| 37 | if (!res.ok) { |
| 38 | const body = await res.text(); |
| 39 | throw new ApiError(res.status, body || res.statusText); |
| 40 | } |
| 41 | const ct = res.headers.get("content-type") || ""; |
| 42 | return (ct.includes("application/json") ? res.json() : res.text()) as Promise<T>; |
| 43 | } |
| 44 | |
| 45 | export class ApiError extends Error { |
| 46 | constructor(public status: number, message: string) { |
| 47 | super(message); |
| 48 | } |
| 49 | } |
| 50 | |
| 51 | export interface Investigation { |
| 52 | id: string; title: string; status: string; priority: string; |
| 53 | summary: string | null; organization: string | null; created_at: string; |
| 54 | } |
| 55 | export interface PlateProfile { |
| 56 | plate_norm: string; total_reads: number; first_seen: string | null; |
| 57 | last_seen: string | null; distinct_cameras: number; |
| 58 | by_camera: { camera_id: string; count: number }[]; |
| 59 | by_hour: Record<string, number>; |
| 60 | reads: { read_ts: string; camera_id: string; confidence: number | null }[]; |
| 61 | } |
| 62 | export interface CameraMapPoint { |
| 63 | camera_id: string; name: string; lat: number; lon: number; |
| 64 | direction_facing: string | null; read_count: number; |
| 65 | } |
| 66 | export interface MovementSummary { |
| 67 | plate_norm: string; total_reads: number; distinct_cameras: number; |
| 68 | first_seen: string | null; last_seen: string | null; |
| 69 | route: { from: string; to: string; elapsed_s: number; distance_km: number | null; |
| 70 | speed_kmh: number | null; implausible: boolean }[]; |
| 71 | dwell: { camera_id: string; start: string; end: string; duration_s: number; reads: number }[]; |
| 72 | anomalies: { from_camera: string; to_camera: string; speed_kmh: number; reason: string }[]; |
| 73 | by_hour: Record<string, number>; |
| 74 | } |
| 75 | export interface WatchlistHit { |
| 76 | id: number; matched_on: string; at: string; plate_norm: string; camera_id: string; |
| 77 | } |
| 78 | export interface TimelineEntry { |
| 79 | plate_norm: string; read_ts: string; camera_id: string; |
| 80 | direction: string | null; confidence: number | null; |
| 81 | } |
| 82 | export interface ImportRow { |
| 83 | id: string; filename: string; status: string; row_count: number; created_at: string; |
| 84 | } |
| 85 | export interface ImportResult { |
| 86 | import_id: string; status?: string; inserted?: number; duplicates?: number; |
| 87 | skipped?: number; watchlist_hits?: number; deduplicated?: boolean; |
| 88 | row_count?: number; errors?: string[]; |
| 89 | } |
| 90 | export interface Source { id: string; name: string; system_type: string | null; organization: string | null; } |
| 91 | export interface Watchlist { id: string; name: string; kind: string; active: boolean; } |
| 92 | export interface WatchlistEntry { |
| 93 | id: string; plate_norm: string | null; partial_plate: string | null; |
| 94 | vehicle_desc: Record<string, string> | null; reason: string | null; expires_at: string | null; |
| 95 | } |
| 96 | export interface CameraDetail { |
| 97 | id: string; external_camera_id: string; name: string; direction_facing: string | null; |
| 98 | lat: number | null; lon: number | null; address: string | null; read_count: number; |
| 99 | } |
| 100 | export interface CoTravel { plate_norm: string; shared_sightings: number; } |
| 101 | export interface AuditEntry { |
| 102 | id: number; actor: string | null; action: string; entity_type: string | null; |
| 103 | entity_id: string | null; metadata: any; at: string; |
| 104 | } |
| 105 | export interface GraphData { |
| 106 | nodes: { id: string; type: string; label: string }[]; |
| 107 | edges: { source: string; target: string; relation: string }[]; |
| 108 | } |
| 109 | export interface AdminSettings { |
| 110 | default_retention_days: number; rate_limit_per_minute: number; |
| 111 | dedup_window_seconds: number; impossible_speed_kmh: number; |
| 112 | cotravel_window_seconds: number; environment: string; |
| 113 | } |
| 114 | export interface UserRow { id: string; email: string; full_name: string | null; role: string; is_active: boolean; } |
| 115 | export interface EvidenceItem { |
| 116 | id: string; filename: string; content_type: string | null; size_bytes: number; |
| 117 | sha256: string; source_id: string | null; description: string | null; |
| 118 | uploaded_by: string | null; created_at: string; |
| 119 | } |
| 120 | export interface EvidenceDetail extends EvidenceItem { |
| 121 | investigation_id: string; |
| 122 | chain_of_custody: { action: string; actor: string | null; at: string; metadata: any }[]; |
| 123 | } |
| 124 | export interface ApiKeyRow { |
| 125 | id: string; name: string; scopes: string[]; last_used_at: string | null; |
| 126 | revoked_at: string | null; created_at: string; |
| 127 | } |
| 128 | export interface Connector { name: string; description: string; } |
| 129 | export interface RetentionResult { |
| 130 | cutoff: string; retention_days: number; candidates: number; deleted: number; dry_run: boolean; |
| 131 | } |
| 132 | export interface StateRow { state: string; reads: number; vehicles: number; } |
| 133 | export interface Stats { |
| 134 | total_reads: number; vehicles: number; cameras: number; states: number; hotlist_reads: number; |
| 135 | } |
| 136 | export interface RecentRead { |
| 137 | plate_norm: string; read_ts: string; camera_id: string; confidence: number | null; |
| 138 | hotlist_hit: boolean; state: string | null; camera_name: string | null; |
| 139 | } |
| 140 | export interface LiveCamera { |
| 141 | id: string; name: string; operator: string | null; area: string | null; state: string | null; |
| 142 | lat: number | null; lon: number | null; online: boolean; snapshot_url: string; live_source: string | null; |
| 143 | } |
| 144 | export interface MapCamera { |
| 145 | camera_id: string; name: string; operator: string | null; state: string | null; |
| 146 | zone: string | null; direction_facing: string | null; lat: number; lon: number; |
| 147 | } |
| 148 | export interface SatelliteCam { |
| 149 | identifier: string; label: string | null; lat: number | null; lon: number | null; |
| 150 | altitude_km: number | null; constellation: string | null; period_min: number | null; observed_at: string; |
| 151 | } |
| 152 | export interface CameraGridItem { |
| 153 | id: string; name: string; operator: string | null; state: string | null; zone: string | null; |
| 154 | direction_facing: string | null; lat: number | null; lon: number | null; reads: number; |
| 155 | has_live_feed?: boolean; snapshot_url?: string | null; |
| 156 | } |
| 157 | export interface CameraStats { |
| 158 | total: number; satellite_cameras: number; |
| 159 | by_operator: { operator: string; count: number }[]; |
| 160 | by_state: { state: string; count: number }[]; |
| 161 | } |
| 162 | export interface ProfileScope { |
| 163 | mode: "consented" | "all"; consented_subjects: number; modes: string[]; description: string; |
| 164 | } |
| 165 | export interface ProfileSubject { id: string; full_name: string; consented: boolean; plates: string[]; } |
| 166 | export interface ProfileDossier { |
| 167 | scope: string; |
| 168 | subject: { id: string; full_name: string; consented: boolean; plates: string[]; |
| 169 | sex: string | null; build: string | null; hair: string | null; threat_level: string; notes: string | null }; |
| 170 | summary: { total_reads: number; first_seen: string | null; last_seen: string | null; |
| 171 | states_visited: string[]; cities_visited: string[]; distinct_cameras: number }; |
| 172 | timeline: { plate_norm: string; read_ts: string; camera: string | null; operator: string | null; |
| 173 | state: string | null; area: string | null; lat: number | null; lon: number | null }[]; |
| 174 | map_points: { lat: number; lon: number; read_ts: string; camera: string | null; state: string | null }[]; |
| 175 | detections: { read_ts: string; clothing_top: string | null; articles: string[] | null; description: string | null }[]; |
| 176 | } |
| 177 | export interface PlateDetection { |
| 178 | plate: string; plate_norm: string; detection_confidence: number; |
| 179 | ocr_confidence: number; bbox: number[]; |
| 180 | } |
| 181 | export interface VideoPlate { |
| 182 | plate_norm: string; plate: string; ocr_confidence: number; detection_confidence: number; |
| 183 | frames_seen: number; first_ts: number; last_ts: number; bbox: number[]; |
| 184 | } |
| 185 | export interface VideoResult { |
| 186 | frames_sampled: number; frames_with_detections: number; plates: VideoPlate[]; |
| 187 | ingest?: { inserted?: number }; |
| 188 | } |
| 189 | export interface OmniResults { |
| 190 | query: string; |
| 191 | plates: { plate_norm: string; state: string | null; reads: number }[]; |
| 192 | persons: { id: string; full_name: string; threat_level: string; wanted: boolean; associated_plate: string | null }[]; |
| 193 | vehicles: { plate_norm: string | null; make: string | null; model: string | null; color: string | null; body_type: string | null }[]; |
| 194 | detections: { id: number; clothing_top: string | null; clothing_bottom: string | null; articles: string[] | null; description: string | null; plate_norm: string | null; read_ts: string }[]; |
| 195 | cameras: { id: string; name: string | null; operator: string | null; state: string | null }[]; |
| 196 | } |
| 197 | |
| 198 | export const api = { |
| 199 | async login(email: string, password: string): Promise<string> { |
| 200 | const body = new URLSearchParams({ username: email, password }); |
| 201 | const res = await fetch(`${BASE}/auth/login`, { |
| 202 | method: "POST", |
| 203 | headers: { "Content-Type": "application/x-www-form-urlencoded" }, |
| 204 | body, |
| 205 | }); |
| 206 | if (!res.ok) throw new ApiError(res.status, "invalid credentials"); |
| 207 | const data = (await res.json()) as { access_token: string }; |
| 208 | setToken(data.access_token); |
| 209 | return data.access_token; |
| 210 | }, |
| 211 | me: () => req<{ id: string; email: string; role: string }>("/auth/me"), |
| 212 | investigations: () => req<Investigation[]>("/investigations"), |
| 213 | investigation: (id: string) => req<Investigation>(`/investigations/${id}`), |
| 214 | timeline: (id: string) => req<TimelineEntry[]>(`/investigations/${id}/timeline`), |
| 215 | graph: (id: string) => req<{ nodes: any[]; edges: any[] }>(`/investigations/${id}/graph`), |
| 216 | plateSearch: (q: string, partial = false) => |
| 217 | req<{ plate_norm: string; state: string; plate_raw: string }[]>( |
| 218 | `/plates/search?q=${encodeURIComponent(q)}&partial=${partial}`), |
| 219 | plateProfile: (plate: string) => req<PlateProfile>(`/plates/${plate}`), |
| 220 | movement: (plate: string) => req<MovementSummary>(`/vehicles/${plate}/movement`), |
| 221 | cameraMap: (params: { state?: string; operator?: string } = {}) => { |
| 222 | const qs = new URLSearchParams(Object.entries(params).filter(([, v]) => v) as any); |
| 223 | return req<{ count: number; cameras: MapCamera[] }>(`/cameras/map?${qs}`); |
| 224 | }, |
| 225 | cameraStats: () => req<CameraStats>("/cameras/stats"), |
| 226 | liveCameras: (params: { state?: string; q?: string; limit?: number; offset?: number } = {}) => { |
| 227 | const qs = new URLSearchParams(Object.entries(params).filter(([, v]) => v != null) as any); |
| 228 | return req<{ total: number; cameras: LiveCamera[] }>(`/cameras/live?${qs}`); |
| 229 | }, |
| 230 | snapshotUrl: (id: string) => `${BASE}/cameras/${id}/snapshot`, |
| 231 | cameraGrid: (params: { state?: string; operator?: string; q?: string; limit?: number; offset?: number } = {}) => { |
| 232 | const qs = new URLSearchParams(Object.entries(params).filter(([, v]) => v != null) as any); |
| 233 | return req<{ total: number; cameras: CameraGridItem[] }>(`/cameras/grid?${qs}`); |
| 234 | }, |
| 235 | cameraOperators: () => req<{ operator: string; count: number }[]>("/cameras/operators"), |
| 236 | satellites: () => req<{ count: number; satellites: SatelliteCam[] }>("/satellites"), |
| 237 | entityTrack: (id: string) => req<{ identifier: string; label: string | null; track: { lat: number; lon: number; observed_at: string }[] }>(`/entities/${id}/track`), |
| 238 | omniSearch: (q: string) => req<OmniResults>(`/search/omni?q=${encodeURIComponent(q)}`), |
| 239 | profileScope: () => req<ProfileScope>("/profiles/scope"), |
| 240 | setProfileScope: (mode: "consented" | "all") => |
| 241 | req<{ mode: string }>("/profiles/scope", { method: "PUT", body: JSON.stringify({ mode }) }), |
| 242 | profileSearch: (q: string) => req<{ scope: string; results: ProfileSubject[] }>(`/profiles/search?q=${encodeURIComponent(q)}`), |
| 243 | buildProfile: (id: string) => req<ProfileDossier>(`/profiles/${id}`), |
| 244 | createSubject: (full_name: string, plates: string[], consented = true) => |
| 245 | req<{ id: string }>("/profiles/subjects", { method: "POST", body: JSON.stringify({ full_name, plates, consented }) }), |
| 246 | alprRecognize: (file: File, minConf = 0.4) => { |
| 247 | const fd = new FormData(); |
| 248 | fd.append("file", file); |
| 249 | fd.append("min_conf", String(minConf)); |
| 250 | return req<{ count: number; plates: PlateDetection[] }>("/alpr/recognize", { method: "POST", body: fd }); |
| 251 | }, |
| 252 | alprRecognizeUrl: (url: string, minConf = 0.4) => |
| 253 | req<{ url: string; count: number; plates: PlateDetection[] }>("/alpr/recognize-url", { |
| 254 | method: "POST", body: JSON.stringify({ url, min_conf: minConf }), |
| 255 | }), |
| 256 | alprIngest: (file: File, opts: { camera_external_id?: string; state?: string; min_conf?: number } = {}) => { |
| 257 | const fd = new FormData(); |
| 258 | fd.append("file", file); |
| 259 | if (opts.camera_external_id) fd.append("camera_external_id", opts.camera_external_id); |
| 260 | if (opts.state) fd.append("state", opts.state); |
| 261 | fd.append("min_conf", String(opts.min_conf ?? 0.4)); |
| 262 | return req<{ recognized: number; inserted?: number; plates: PlateDetection[] }>("/alpr/ingest", { method: "POST", body: fd }); |
| 263 | }, |
| 264 | alprVideo: (file: File, opts: { sample_fps?: number; min_conf?: number; ingest?: boolean; camera_external_id?: string; state?: string } = {}) => { |
| 265 | const fd = new FormData(); |
| 266 | fd.append("file", file); |
| 267 | fd.append("sample_fps", String(opts.sample_fps ?? 2)); |
| 268 | fd.append("min_conf", String(opts.min_conf ?? 0.4)); |
| 269 | fd.append("ingest", String(opts.ingest ?? false)); |
| 270 | if (opts.camera_external_id) fd.append("camera_external_id", opts.camera_external_id); |
| 271 | if (opts.state) fd.append("state", opts.state); |
| 272 | return req<VideoResult>("/alpr/video", { method: "POST", body: fd }); |
| 273 | }, |
| 274 | rtspProbe: (url: string) => req<{ reachable: boolean; decoded: boolean; width?: number; height?: number }>("/alpr/rtsp/probe", { method: "POST", body: JSON.stringify({ url }) }), |
| 275 | rtspCapture: (body: { url: string; seconds?: number; sample_fps?: number; min_conf?: number; ingest?: boolean; camera_external_id?: string; state?: string }) => |
| 276 | req<VideoResult>("/alpr/rtsp/capture", { method: "POST", body: JSON.stringify(body) }), |
| 277 | states: () => req<StateRow[]>("/states"), |
| 278 | statePlates: (state: string) => |
| 279 | req<{ state: string; plates: { plate_norm: string; reads: number }[] }>(`/states/${state}/plates`), |
| 280 | stats: () => req<Stats>("/stats"), |
| 281 | recentReads: (limit = 25) => req<RecentRead[]>(`/ingest/recent?limit=${limit}`), |
| 282 | watchlistHits: () => req<WatchlistHit[]>("/watchlist-hits"), |
| 283 | enrichers: () => req<{ name: string; kind: string }[]>("/enrichers"), |
| 284 | runEnrichment: (enricher: string, target: string) => |
| 285 | req<{ job_id: string; status: string; result: any }>("/enrichment/run", { |
| 286 | method: "POST", body: JSON.stringify({ enricher, target }), |
| 287 | }), |
| 288 | createReport: (id: string, format = "markdown") => |
| 289 | req<{ report_id: string; report: any }>(`/investigations/${id}/reports`, { |
| 290 | method: "POST", body: JSON.stringify({ format }), |
| 291 | }), |
| 292 | |
| 293 | imports: () => req<ImportRow[]>("/imports"), |
| 294 | uploadImport: (file: File, sourceId?: string) => { |
| 295 | const fd = new FormData(); |
| 296 | fd.append("file", file); |
| 297 | if (sourceId) fd.append("source_id", sourceId); |
| 298 | return req<ImportResult>("/imports", { method: "POST", body: fd }); |
| 299 | }, |
| 300 | sources: () => req<Source[]>("/sources"), |
| 301 | createSource: (name: string, system_type = "flock") => |
| 302 | req<Source>("/sources", { method: "POST", body: JSON.stringify({ name, system_type }) }), |
| 303 | watchlists: () => req<Watchlist[]>("/watchlists"), |
| 304 | createWatchlist: (name: string, kind = "plate") => |
| 305 | req<Watchlist>("/watchlists", { method: "POST", body: JSON.stringify({ name, kind }) }), |
| 306 | watchlistEntries: (id: string) => req<WatchlistEntry[]>(`/watchlists/${id}/entries`), |
| 307 | addWatchlistEntry: (id: string, body: { plate_norm?: string; partial_plate?: string; reason?: string }) => |
| 308 | req<{ id: string; backfill_hits: number }>(`/watchlists/${id}/entries`, { |
| 309 | method: "POST", body: JSON.stringify(body), |
| 310 | }), |
| 311 | cameraDetail: (id: string) => req<CameraDetail>(`/cameras/${id}`), |
| 312 | cameraTransits: (id: string) => |
| 313 | req<{ camera_id: string; transitions: { from: string; to: string; weight: number }[] }>(`/cameras/${id}/transits`), |
| 314 | cotravel: (plate: string) => |
| 315 | req<{ plate_norm: string; window_seconds: number; candidates: CoTravel[] }>(`/vehicles/${plate}/cotravel`), |
| 316 | audit: (params: { entity_type?: string; actor?: string; limit?: number } = {}) => { |
| 317 | const qs = new URLSearchParams(Object.entries(params).filter(([, v]) => v != null) as any); |
| 318 | return req<AuditEntry[]>(`/audit?${qs.toString()}`); |
| 319 | }, |
| 320 | adminSettings: () => req<AdminSettings>("/admin/settings"), |
| 321 | users: () => req<UserRow[]>("/users"), |
| 322 | runRetention: (dryRun = true) => |
| 323 | req<RetentionResult>(`/admin/retention/run?dry_run=${dryRun}`, { method: "POST" }), |
| 324 | evidenceList: (invId: string) => req<EvidenceItem[]>(`/investigations/${invId}/evidence`), |
| 325 | evidenceDetail: (id: string) => req<EvidenceDetail>(`/evidence/${id}`), |
| 326 | uploadEvidence: (invId: string, file: File, description?: string) => { |
| 327 | const fd = new FormData(); |
| 328 | fd.append("file", file); |
| 329 | if (description) fd.append("description", description); |
| 330 | return req<{ id: string; sha256: string }>(`/investigations/${invId}/evidence`, { method: "POST", body: fd }); |
| 331 | }, |
| 332 | evidenceDownloadUrl: (id: string) => `${BASE}/evidence/${id}/download`, |
| 333 | apiKeys: () => req<ApiKeyRow[]>("/api-keys"), |
| 334 | createApiKey: (name: string) => |
| 335 | req<{ id: string; name: string; api_key: string; note: string }>("/api-keys", { |
| 336 | method: "POST", body: JSON.stringify({ name, scopes: [] }), |
| 337 | }), |
| 338 | revokeApiKey: (id: string) => req<{ id: string; revoked: boolean }>(`/api-keys/${id}`, { method: "DELETE" }), |
| 339 | connectors: () => req<Connector[]>("/connectors"), |
| 340 | runConnector: (connector: string, config: any, sourceId?: string) => |
| 341 | req<{ fetched: number; inserted?: number; note?: string }>("/connectors/run", { |
| 342 | method: "POST", body: JSON.stringify({ connector, config, source_id: sourceId }), |
| 343 | }), |
| 344 | }; |
| 345 | |
| 346 | export const API_BASE = BASE; |
| 347 | |
| 348 | export async function downloadAuthed(path: string, filename: string): Promise<void> { |
| 349 | const token = getToken(); |
| 350 | const res = await fetch(`${BASE}${path}`, { |
| 351 | headers: token ? { Authorization: `Bearer ${token}` } : {}, |
| 352 | }); |
| 353 | if (!res.ok) throw new ApiError(res.status, "download failed"); |
| 354 | const blob = await res.blob(); |
| 355 | const url = URL.createObjectURL(blob); |
| 356 | const a = document.createElement("a"); |
| 357 | a.href = url; |
| 358 | a.download = filename; |
| 359 | document.body.appendChild(a); |
| 360 | a.click(); |
| 361 | a.remove(); |
| 362 | URL.revokeObjectURL(url); |
| 363 | } |