Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| import { apiTyped } from '../lib/apiClient' | |
| export type BatchJobsTotals = { | |
| batches: number | |
| running: number | |
| states: number | |
| states_planned: number | |
| states_started: number | |
| states_completed: number | |
| processed_jurisdictions: number | |
| failed_jurisdictions: number | |
| remaining_jurisdictions: number | |
| videos_ok: number | |
| videos_fail: number | |
| videos_attempted: number | |
| files_transcripts: number | |
| files_transcripts_disk: number | |
| transcript_hours: number | |
| bronze_download_rows: number | |
| files_analysis: number | |
| files_reports: number | |
| /** Analyses summarised in the last 24h (file mtime within the rolling window). */ | |
| files_analysis_recent?: number | |
| /** Reports generated in the last 24h (file mtime within the rolling window). */ | |
| files_reports_recent?: number | |
| /** Analysis errors stamped in the last 24h (bronze policy_analysis_error). */ | |
| files_analysis_errors_recent?: number | |
| /** Report errors stamped in the last 24h (bronze policy_report_error). */ | |
| files_reports_errors_recent?: number | |
| /** Most recent transcript-download stamp (all time, ISO) — "Last transcript" ago card. */ | |
| last_transcript_at?: string | |
| /** Most recent analysis stamp (all time, ISO) — drives the "Last analysis" ago card. */ | |
| last_analysis_at?: string | |
| /** Most recent report stamp (all time, ISO) — drives the "Last report" ago card. */ | |
| last_report_at?: string | |
| } | |
| export type BatchVideoResult = { | |
| video_id: string | |
| title: string | |
| status: string | |
| error: string | |
| transcript_source: string | |
| finished_at: string | |
| duration_seconds?: number | null | |
| } | |
| export type BatchJurisdictionRun = { | |
| state_code: string | |
| jurisdiction_id: string | |
| jurisdiction_name: string | |
| status: string | |
| started_at: string | |
| updated_at?: string | |
| finished_at: string | |
| elapsed_seconds: number | |
| exit_code: number | |
| stats: Record<string, number> | |
| videos: BatchVideoResult[] | |
| file_counts: Record<string, number> | |
| current_video_id?: string | |
| current_video_title?: string | |
| current_video_started_at?: string | |
| } | |
| export type BatchJob = { | |
| batch_id: string | |
| step: string | |
| status: string | |
| started_at: string | |
| updated_at: string | |
| finished_at: string | |
| config: Record<string, unknown> | |
| summary: Record<string, unknown> | |
| jurisdictions: BatchJurisdictionRun[] | |
| } | |
| export type PipelineStage = 'discover' | 'videos' | 'transcripts' | 'analyses' | 'reports' | |
| /** Per-entity split of a stage (discover: counties vs municipalities). Sums to the row. */ | |
| export type StageBreakdownEntry = { | |
| entity: string | |
| done: number | |
| total: number | |
| failed: number | |
| } | |
| /** One (scope, stage) row. ``scope`` is a 2-letter state code or ``ALL`` (rollup). */ | |
| export type StageReportRow = { | |
| scope: string | |
| stage: PipelineStage | string | |
| done: number | |
| total: number | |
| failed: number | |
| last_at: string | |
| /** Optional per-entity split (only the discover stage today). */ | |
| breakdown?: StageBreakdownEntry[] | |
| } | |
| export type StageTiming = { | |
| avg_seconds?: number | null | |
| last_path?: string | |
| last_at?: string | |
| } | |
| export type StageReport = { | |
| states: string[] | |
| rows: StageReportRow[] | |
| /** Per-stage cadence + last file: keyed by stage name. */ | |
| timing?: Record<string, StageTiming> | |
| } | |
| export type LaunchLog = { | |
| step: string | |
| path: string | |
| lines: string[] | |
| /** Best-effort "current item" line parsed from the log tail. */ | |
| current: string | |
| current_since: string | |
| } | |
| export async function fetchLaunchLog(step: string): Promise<LaunchLog> { | |
| const r = await fetch( | |
| `/api/batch-jobs/launch/log?step=${encodeURIComponent(step)}&lines=150`, | |
| { signal: AbortSignal.timeout(10_000) }, | |
| ) | |
| if (!r.ok) throw new Error(`HTTP ${r.status}`) | |
| return (await r.json()) as LaunchLog | |
| } | |
| export type BatchJobsDashboardPayload = { | |
| generated_at: string | |
| /** Latest batch/jurisdiction progress timestamp (not API build time). */ | |
| last_activity_at?: string | |
| totals: BatchJobsTotals | |
| batches: BatchJob[] | |
| /** Long-format per-state pipeline coverage (scope×stage rows). */ | |
| stage_report?: StageReport | |
| source?: 'database' | 'files' | |
| /** ``summary`` = metrics only; ``full`` = all per-video rows included. */ | |
| detail?: 'summary' | 'full' | string | |
| } | |
| export type LaunchStatus = { | |
| enabled: boolean | |
| busy: boolean | |
| running: number | |
| /** Steps currently running — different steps can run concurrently. */ | |
| running_steps: string[] | |
| /** Live but stalled (>1h no activity) — re-launchable. */ | |
| stalled_steps: string[] | |
| steps: string[] | |
| } | |
| export type LaunchResult = { | |
| launched: boolean | |
| pid?: number | null | |
| step: string | |
| states: string[] | |
| log: string | |
| detail: string | |
| } | |
| export async function fetchLaunchStatus(): Promise<LaunchStatus> { | |
| const r = await fetch('/api/batch-jobs/launch', { | |
| signal: AbortSignal.timeout(10_000), | |
| }) | |
| if (!r.ok) throw new Error(`HTTP ${r.status}`) | |
| return (await r.json()) as LaunchStatus | |
| } | |
| export async function launchPipeline(body: { | |
| step: string | |
| states?: string[] | |
| n?: number | |
| parallel?: number | |
| }): Promise<LaunchResult> { | |
| const r = await fetch('/api/batch-jobs/launch', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body), | |
| signal: AbortSignal.timeout(15_000), | |
| }) | |
| if (!r.ok) { | |
| let detail = `HTTP ${r.status}` | |
| try { | |
| const j = (await r.json()) as { error?: string; detail?: string } | |
| if (j?.error || j?.detail) detail = (j.error || j.detail) as string | |
| } catch { | |
| /* ignore */ | |
| } | |
| throw new Error(detail) | |
| } | |
| return (await r.json()) as LaunchResult | |
| } | |
| export type StopResult = { | |
| stopped: number | |
| steps: string[] | |
| pids: number[] | |
| detail: string | |
| } | |
| /** Stop running pipeline launch(es). Omit `step` to stop everything. */ | |
| export async function stopPipeline(body: { | |
| step?: string | |
| force?: boolean | |
| } = {}): Promise<StopResult> { | |
| const r = await fetch('/api/batch-jobs/launch/stop', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify(body), | |
| signal: AbortSignal.timeout(15_000), | |
| }) | |
| if (!r.ok) { | |
| let detail = `HTTP ${r.status}` | |
| try { | |
| const j = (await r.json()) as { error?: string; detail?: string } | |
| if (j?.error || j?.detail) detail = (j.error || j.detail) as string | |
| } catch { | |
| /* ignore */ | |
| } | |
| throw new Error(detail) | |
| } | |
| return (await r.json()) as StopResult | |
| } | |
| export type BatchJobsDetailLevel = 'summary' | 'standard' | 'full' | |
| type ApiErrorBody = { detail?: string | { msg?: string }[] } | |
| /** User-visible message from failed ``api.get`` (includes FastAPI ``detail``). */ | |
| export function batchJobsFetchErrorMessage(error: unknown): string { | |
| const err = error as { | |
| message?: string | |
| response?: { data?: ApiErrorBody; status?: number; statusText?: string } | |
| } | |
| const detail = err.response?.data?.detail | |
| if (typeof detail === 'string' && detail.trim()) { | |
| return detail | |
| } | |
| if (Array.isArray(detail)) { | |
| const text = detail.map((d) => d?.msg).filter(Boolean).join('; ') | |
| if (text) return text | |
| } | |
| if (err.response?.status) { | |
| return `HTTP ${err.response.status}: ${err.response.statusText ?? 'Error'}` | |
| } | |
| return err.message ?? 'unknown error' | |
| } | |
| export async function fetchBatchJobsDashboard( | |
| refreshFiles = false, | |
| detail: BatchJobsDetailLevel = 'summary', | |
| ): Promise<BatchJobsDashboardPayload> { | |
| const { data, error } = await apiTyped.GET('/api/batch-jobs/', { | |
| params: { | |
| query: { | |
| refresh_files: refreshFiles, | |
| enrich_bronze: false, | |
| detail, | |
| batch_limit: 25, | |
| }, | |
| }, | |
| signal: AbortSignal.timeout(30_000), | |
| }) | |
| if (error) throw error | |
| if (data == null) { | |
| // openapi-fetch resolved with neither data nor error — e.g. an empty body | |
| // or a 30s-aborted request. Returning undefined here makes React Query emit | |
| // a cryptic "data is undefined" error; throw a real message instead. | |
| throw new Error( | |
| 'Batch jobs API returned an empty response (the API may be down, restarting, or slow — try restarting it and reloading).', | |
| ) | |
| } | |
| return data as BatchJobsDashboardPayload | |
| } | |
| export type FailedVideosListPayload = { | |
| rows: Array<{ | |
| batch_id: string | |
| batch_step: string | |
| state_code: string | |
| jurisdiction_id: string | |
| jurisdiction_name: string | |
| video: BatchVideoResult | |
| }> | |
| total_fail_in_summaries: number | |
| truncated: boolean | |
| } | |
| export async function fetchFailedVideos( | |
| batchId?: string, | |
| limit = 500, | |
| ): Promise<FailedVideosListPayload> { | |
| const { data, error } = await apiTyped.GET('/api/batch-jobs/failed-videos', { | |
| params: { | |
| query: { ...(batchId ? { batch_id: batchId } : {}), limit }, | |
| }, | |
| signal: AbortSignal.timeout(60_000), | |
| }) | |
| if (error) throw error | |
| return data as FailedVideosListPayload | |
| } | |
| export async function fetchBatchJurisdictions( | |
| batchId: string, | |
| stateCode: string, | |
| ): Promise<BatchJurisdictionRun[]> { | |
| const { data, error } = await apiTyped.GET( | |
| '/api/batch-jobs/{batch_id}/jurisdictions', | |
| { params: { path: { batch_id: batchId }, query: { state: stateCode.toUpperCase() } } }, | |
| ) | |
| if (error) throw error | |
| return (data.jurisdictions ?? []) as BatchJurisdictionRun[] | |
| } | |
| export async function fetchBatchJobDetail( | |
| batchId: string, | |
| includeVideos: 'all' | 'failed_only' | 'none' | 'running' = 'all', | |
| ): Promise<BatchJob> { | |
| const { data, error } = await apiTyped.GET('/api/batch-jobs/{batch_id}', { | |
| params: { | |
| path: { batch_id: batchId }, | |
| query: { enrich_bronze: false, include_videos: includeVideos }, | |
| }, | |
| }) | |
| if (error) throw error | |
| return data as BatchJob | |
| } | |
| /** Attach on-demand jurisdiction rows to a summary-only batch row. */ | |
| export function mergeBatchJurisdictions( | |
| batch: BatchJob, | |
| jurisdictions: BatchJurisdictionRun[], | |
| ): BatchJob { | |
| return { ...batch, jurisdictions } | |
| } | |
| /** Merge jurisdiction rows (and videos) from a detail fetch into cached dashboard data. */ | |
| export function mergeBatchIntoDashboard( | |
| prev: BatchJobsDashboardPayload | undefined, | |
| batch: BatchJob, | |
| ): BatchJobsDashboardPayload | undefined { | |
| if (!prev) return prev | |
| const batches = prev.batches.map((b) => | |
| b.batch_id === batch.batch_id ? { ...b, ...batch, jurisdictions: batch.jurisdictions } : b, | |
| ) | |
| if (!batches.some((b) => b.batch_id === batch.batch_id)) { | |
| batches.unshift(batch) | |
| } | |
| return { ...prev, batches, detail: 'full' } | |
| } | |
| /** SSE URL for live dashboard updates (Postgres-backed). */ | |
| export function batchJobsStreamUrl(): string { | |
| const base = import.meta.env.PROD | |
| ? '/api' | |
| : import.meta.env.VITE_API_URL || '/api' | |
| const prefix = base.endsWith('/') ? base.slice(0, -1) : base | |
| return `${prefix}/batch-jobs/stream` | |
| } | |