● Zion Boggan
repos/Sightline/frontend/src/lib/api.ts
zionboggan.com ↗
363 lines · typescript
History for this file →
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
}