open-navigator / web_app /src /pages /BatchJobStatusPage.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
103 kB
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { Fragment, useCallback, useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import {
batchJobsStreamUrl,
batchJobsFetchErrorMessage,
fetchBatchJobDetail,
fetchBatchJurisdictions,
fetchBatchJobsDashboard,
fetchFailedVideos,
fetchLaunchLog,
fetchLaunchStatus,
launchPipeline,
stopPipeline,
mergeBatchIntoDashboard,
mergeBatchJurisdictions,
type BatchJob,
type BatchJurisdictionRun,
type BatchJobsDashboardPayload,
type BatchVideoResult,
type PipelineStage,
type StageReportRow,
} from '../api/batchJobs'
import DeploymentPanel from '../components/DeploymentPanel'
import { LinkifiedText } from '../utils/linkifiedText'
import {
formatCompactHours,
formatCompactNumber,
formatCompactPair,
formatFullNumber,
} from '../utils/formatCompact'
import {
aggregateRunningFileTiming,
avgSecondsPerFile,
filterJurisdictionsByState,
jurisdictionStateCodes,
remainingVideosForRunningBatches,
resolveRunningFileClock,
jurisdictionLastUpdatedIso,
latestDashboardActivityIso,
remainingVideosForBatch,
sortJurisdictions,
useTickingSeconds,
} from '../utils/batchJobTiming'
import {
formatAgoCompact,
formatDateTimeAbsolute,
formatUpdatedAt,
} from '../utils/dateTime'
import { STATE_CODES } from '../lib/usStates'
type FailedVideoRow = {
batch_id: string
batch_step: string
state_code: string
jurisdiction_id: string
jurisdiction_name: string
video: BatchVideoResult
}
const FAILED_VIDEO_STATUSES = new Set([
'fail',
'failed',
'tombstoned',
'empty',
'rate_limit',
'error',
])
function isFailedVideoStatus(status: string): boolean {
const s = (status || '').toLowerCase()
if (!s || s === 'ok' || s === 'pending' || s === 'skipped') return false
if (FAILED_VIDEO_STATUSES.has(s)) return true
return s !== 'ok'
}
function failedVideoCount(j: BatchJurisdictionRun): number {
const fromVideos = (j.videos || []).filter((v) => isFailedVideoStatus(v.status)).length
const fromStats = Number(j.stats?.fail ?? 0)
return Math.max(fromVideos, fromStats)
}
// Which runnable step advances each stage (used for the per-stage log drill-down).
const STAGE_STEP: Record<string, string> = {
discover: 'discover',
videos: 'catalog',
transcripts: 'captions',
analyses: 'analyze',
reports: 'analyze',
}
function fmtSecs(s?: number | null): string {
if (s == null || !Number.isFinite(s)) return '—'
const v = Math.round(s)
if (v < 60) return `${v}s`
const m = Math.floor(v / 60)
const r = v % 60
return r ? `${m}m ${r}s` : `${m}m`
}
function baseName(p?: string): string {
if (!p) return '—'
const parts = p.split('/')
return parts[parts.length - 1] || p
}
function fmtEta(hours: number): string {
if (!Number.isFinite(hours) || hours <= 0) return '—'
if (hours < 1) return `${Math.round(hours * 60)}m`
if (hours < 48) return `${Math.round(hours)}h`
return `${(hours / 24).toFixed(1)}d`
}
function mergeDashboardFromStream(
prev: BatchJobsDashboardPayload | undefined,
next: BatchJobsDashboardPayload,
): BatchJobsDashboardPayload {
if (!prev || next.detail !== 'summary') {
return next
}
const prevById = new Map(prev.batches.map((b) => [b.batch_id, b]))
return {
...next,
batches: next.batches.map((b) => {
const older = prevById.get(b.batch_id)
if (older?.jurisdictions?.length) {
return { ...b, jurisdictions: older.jurisdictions }
}
return b
}),
}
}
function collectFailedVideos(
batches: BatchJob[],
opts?: { batchId?: string; jurisdictionId?: string },
): FailedVideoRow[] {
const rows: FailedVideoRow[] = []
for (const batch of batches) {
if (opts?.batchId && batch.batch_id !== opts.batchId) continue
for (const j of batch.jurisdictions) {
if (opts?.jurisdictionId && j.jurisdiction_id !== opts.jurisdictionId) continue
for (const v of j.videos || []) {
if (!isFailedVideoStatus(v.status)) continue
rows.push({
batch_id: batch.batch_id,
batch_step: batch.step,
state_code: j.state_code,
jurisdiction_id: j.jurisdiction_id,
jurisdiction_name: j.jurisdiction_name,
video: v,
})
}
}
}
return rows
}
function formatDuration(seconds: unknown): string {
if (seconds == null || seconds === '') return '—'
const total = Math.max(0, Math.floor(Number(seconds)))
if (Number.isNaN(total)) return '—'
if (total < 60) return `${total}s`
const m = Math.floor(total / 60)
const s = total % 60
if (m < 60) return `${m}m ${s}s`
const h = Math.floor(m / 60)
const remM = m % 60
if (h >= 24) {
const compact = formatCompactNumber(h)
return remM > 0 ? `${compact}h ${remM}m` : `${compact}h`
}
return remM > 0 ? `${h}h ${remM}m` : `${h}h`
}
function metricCountTitle(n: unknown, label: string): string | undefined {
const full = formatFullNumber(n)
const compact = formatCompactNumber(n, '')
if (!full || full === compact) return undefined
return `${label}: ${full}`
}
function formatVideoDuration(seconds: unknown): string {
if (seconds == null || seconds === '') return '—'
const total = Math.max(0, Math.floor(Number(seconds)))
if (Number.isNaN(total) || total <= 0) return '—'
const h = Math.floor(total / 3600)
const m = Math.floor((total % 3600) / 60)
const s = total % 60
if (h > 0) return `${h}:${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`
return `${m}:${String(s).padStart(2, '0')}`
}
function statusBadgeClass(status: string): string {
if (status === 'completed' || status === 'ok') {
return 'bg-emerald-100 text-emerald-800'
}
if (status === 'noop') {
return 'bg-slate-100 text-slate-700'
}
if (status === 'failed' || status === 'fail') {
return 'bg-red-100 text-red-800'
}
if (status === 'running') {
return 'bg-sky-100 text-sky-800'
}
if (status === 'cancelled') {
return 'bg-slate-200 text-slate-700'
}
if (status === 'pending') {
return 'bg-amber-50 text-amber-900'
}
return 'bg-slate-100 text-slate-700'
}
function displayJurisdictionName(j: { jurisdiction_name?: string; jurisdiction_id?: string }): string {
const raw = (j.jurisdiction_name || j.jurisdiction_id || '').trim()
return raw.replace(/\s+(city|town|village|borough|municipality)\s*$/i, '').trim() || raw
}
function displayJurisdictionStatus(j: BatchJurisdictionRun): { label: string; badgeStatus: string } {
const st = j.stats || {}
if (Number(st.noop) > 0) return { label: 'noop', badgeStatus: 'noop' }
if (Number(st.dry_run) > 0) return { label: 'dry-run', badgeStatus: 'noop' }
return { label: j.status || 'pending', badgeStatus: j.status || 'pending' }
}
function SummaryCard({
label,
value,
title,
onClick,
active,
emphasis,
}: {
label: string
value: string | number
title?: string
onClick?: () => void
active?: boolean
emphasis?: 'danger' | 'default'
}) {
const content = (
<>
<div className="text-[10px] font-medium uppercase tracking-wide text-slate-500">{label}</div>
<div
className={`mt-0.5 text-xl font-semibold tabular-nums ${
emphasis === 'danger' ? 'text-red-700' : 'text-slate-900'
}`}
>
{value}
</div>
</>
)
const className = `rounded-lg border px-3 py-2 text-left shadow-sm transition-colors ${
active
? 'border-red-400 bg-red-50 ring-1 ring-red-300'
: 'border-slate-200 bg-white hover:border-slate-300'
} ${onClick ? 'cursor-pointer hover:bg-slate-50' : ''}`
const tip = title || (onClick ? `View ${label}` : undefined)
if (onClick) {
return (
<button type="button" onClick={onClick} className={className} title={tip}>
{content}
</button>
)
}
return (
<div className={className} title={tip}>
{content}
</div>
)
}
function ProgressBar({ pct }: { pct: number }) {
const width = Math.min(100, Math.max(0, pct))
return (
<div className="h-2 overflow-hidden rounded-full bg-slate-200">
<div
className="h-full rounded-full bg-teal-600 transition-all duration-300"
style={{ width: `${width}%` }}
/>
</div>
)
}
function FailedVideosPanel({
rows,
scopeLabel,
subtitle,
onClose,
onSelectJurisdiction,
}: {
rows: FailedVideoRow[]
scopeLabel: string
subtitle?: string
onClose: () => void
onSelectJurisdiction?: (batchId: string, jurisdictionId: string) => void
}) {
return (
<div className="rounded-lg border border-red-200 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-red-100 bg-red-50/80 px-4 py-3">
<div>
<h3 className="text-sm font-semibold text-red-900">Failed videos</h3>
<p className="text-xs text-red-800/80">
{scopeLabel} · {rows.length} video{rows.length === 1 ? '' : 's'}
{subtitle ? ` · ${subtitle}` : ''}
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-md border border-red-200 bg-white px-2.5 py-1 text-xs font-medium text-red-800 hover:bg-red-50"
>
Close
</button>
</div>
<div className="overflow-auto max-h-[min(420px,55vh)]">
{rows.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-slate-500">
No per-video failure log yet. Failures are recorded when backfill runs with{' '}
<code className="rounded bg-slate-100 px-1">--batch-id</code>.
</p>
) : (
<table className="min-w-full text-sm">
<thead className="sticky top-0 bg-white text-left text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 font-medium">Jurisdiction</th>
<th className="px-3 py-2 font-medium">Video</th>
<th className="px-3 py-2 font-medium">Title</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Error</th>
</tr>
</thead>
<tbody>
{rows.map((row) => (
<tr
key={`${row.batch_id}-${row.jurisdiction_id}-${row.video.video_id}`}
className="border-t border-slate-100 align-top"
>
<td className="px-3 py-2 text-xs">
<button
type="button"
className="text-left hover:text-teal-800"
onClick={() =>
onSelectJurisdiction?.(row.batch_id, row.jurisdiction_id)
}
>
<div className="font-medium text-slate-900">
{displayJurisdictionName({
jurisdiction_name: row.jurisdiction_name,
jurisdiction_id: row.jurisdiction_id,
})}
</div>
<div className="font-mono text-[11px] text-slate-500">
{row.state_code} · {row.jurisdiction_id}
</div>
<div className="mt-0.5 font-mono text-[10px] text-slate-400">
{row.batch_step}
</div>
</button>
</td>
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">
<a
href={`https://www.youtube.com/watch?v=${row.video.video_id}`}
target="_blank"
rel="noopener noreferrer"
className="text-teal-700 hover:underline"
>
{row.video.video_id}
</a>
</td>
<td className="max-w-[180px] px-3 py-2 text-xs text-slate-700">
{row.video.title || '—'}
</td>
<td className="px-3 py-2 whitespace-nowrap">
<span
className={`rounded px-1.5 py-0.5 text-xs font-medium ${statusBadgeClass(row.video.status)}`}
>
{row.video.status}
</span>
{row.video.transcript_source ? (
<div className="mt-0.5 text-[10px] text-slate-500">
{row.video.transcript_source}
</div>
) : null}
</td>
<td className="max-w-md px-3 py-2 text-xs text-red-800 whitespace-pre-wrap break-words">
{row.video.error ? (
<LinkifiedText
text={row.video.error}
linkClassName="font-medium text-teal-800 underline decoration-teal-500/70 hover:text-teal-950 break-all"
/>
) : (
'—'
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)
}
function JurisdictionTable({
jurisdictions,
selectedId,
onSelect,
onShowFailedVideos,
emptyMessage,
}: {
jurisdictions: BatchJurisdictionRun[]
selectedId: string | null
onSelect: (id: string) => void
onShowFailedVideos?: (jurisdictionId: string) => void
emptyMessage?: string
}) {
if (!jurisdictions.length) {
return (
<p className="py-6 text-center text-sm text-slate-500">
{emptyMessage ?? (
<>
No jurisdictions recorded yet. Run{' '}
<code className="rounded bg-slate-100 px-1">run_priority_states_last_n.sh captions</code>.
</>
)}
</p>
)
}
return (
<div className="overflow-auto max-h-[min(420px,50vh)]">
<table className="min-w-full text-sm">
<thead className="sticky top-0 bg-slate-50 text-left text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 font-medium">State</th>
<th className="px-3 py-2 font-medium">Jurisdiction</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Videos</th>
<th className="px-3 py-2 font-medium">Files</th>
<th className="px-3 py-2 font-medium">Time</th>
<th className="px-3 py-2 font-medium" title="When this jurisdiction was last checked in the batch run">
Last checked
</th>
</tr>
</thead>
<tbody>
{jurisdictions.map((j) => {
const st = j.stats || {}
const fc = j.file_counts || {}
const failCount = failedVideoCount(j)
const disp = displayJurisdictionStatus(j)
const selected = selectedId === j.jurisdiction_id
const lastUpdated = formatUpdatedAt(jurisdictionLastUpdatedIso(j))
return (
<tr
key={j.jurisdiction_id}
className={`cursor-pointer border-t border-slate-100 hover:bg-teal-50/50 ${
selected ? 'bg-teal-50' : ''
}`}
onClick={() => onSelect(j.jurisdiction_id)}
>
<td className="px-3 py-2 font-mono text-xs">{j.state_code}</td>
<td className="px-3 py-2">
<div className="font-medium text-slate-900">
{displayJurisdictionName(j)}
</div>
<div className="font-mono text-[11px] text-slate-500">{j.jurisdiction_id}</div>
</td>
<td className="px-3 py-2">
<span
className={`inline-block rounded px-1.5 py-0.5 text-xs font-medium ${statusBadgeClass(disp.badgeStatus)}`}
>
{disp.label}
{j.exit_code ? ` (${j.exit_code})` : ''}
</span>
</td>
<td className="px-3 py-2 text-xs text-slate-600">
{j.status === 'pending' ? (
<span className="text-slate-400"></span>
) : Number(st.noop) > 0 ? (
<span
className="text-slate-500"
title="Backfill ran; no videos needed download (already in bronze or on disk)"
>
all cached
</span>
) : (
<>
ok {st.ok ?? 0} ·{' '}
{failCount > 0 && onShowFailedVideos ? (
<button
type="button"
className="font-medium text-red-700 underline decoration-red-300 hover:text-red-900"
title="View failed videos for this jurisdiction"
onClick={(e) => {
e.stopPropagation()
onShowFailedVideos(j.jurisdiction_id)
}}
>
fail {failCount}
</button>
) : (
<>fail {failCount}</>
)}{' '}
· tomb {st.tombstoned ?? 0}
</>
)}
</td>
<td
className="px-3 py-2 text-xs text-slate-600"
title="Transcript / analysis / report files under policy cache for this place"
>
{Number(st.noop) > 0 &&
!(fc.transcripts_disk ?? fc.transcripts) &&
!(fc.analysis_disk ?? fc.analysis) &&
!(fc.reports_disk ?? fc.reports) ? (
<span className="text-slate-400"></span>
) : (
<>
disk T {fc.transcripts_disk ?? fc.transcripts ?? 0} · A{' '}
{fc.analysis_disk ?? fc.analysis ?? 0} · R{' '}
{fc.reports_disk ?? fc.reports ?? 0}
</>
)}
</td>
<td className="px-3 py-2 text-xs tabular-nums text-slate-600">
{formatDuration(j.elapsed_seconds)}
</td>
<td
className="px-3 py-2 text-xs text-slate-600 whitespace-nowrap"
title={lastUpdated.title}
>
{lastUpdated.display}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)
}
function VideoDrillDown({
jurisdiction,
onlyFailed = false,
}: {
jurisdiction: BatchJurisdictionRun
onlyFailed?: boolean
}) {
const videos = useMemo(() => {
const all = jurisdiction.videos || []
if (!onlyFailed) return all
return all.filter((v) => isFailedVideoStatus(v.status))
}, [jurisdiction.videos, onlyFailed])
return (
<div
id="batch-jurisdiction-drilldown"
className="rounded-lg border border-slate-200 bg-white shadow-sm"
>
<div className="border-b border-slate-100 px-4 py-3">
<h3 className="text-sm font-semibold text-slate-900">
{onlyFailed ? 'Failed videos · ' : ''}
{displayJurisdictionName(jurisdiction)}
</h3>
<p className="mt-0.5 font-mono text-xs text-slate-500">{jurisdiction.jurisdiction_id}</p>
{onlyFailed ? (
<p className="mt-1 text-xs text-red-800">
{videos.length} failed video{videos.length === 1 ? '' : 's'} in this batch run
</p>
) : null}
</div>
<div className="overflow-auto max-h-[min(360px,45vh)]">
<table className="min-w-full text-sm">
<thead className="sticky top-0 bg-white text-left text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 font-medium">Video</th>
<th className="px-3 py-2 font-medium">Title</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Duration</th>
<th className="px-3 py-2 font-medium">Source</th>
<th className="px-3 py-2 font-medium">Error</th>
</tr>
</thead>
<tbody>
{videos.length === 0 ? (
<tr>
<td colSpan={6} className="px-3 py-4 text-center text-xs text-slate-500">
{onlyFailed
? 'No failed videos logged for this jurisdiction in this batch run.'
: (
<>
No per-video log for this run (re-run backfill with{' '}
<code>--batch-id</code>).
</>
)}
</td>
</tr>
) : (
videos.map((v) => (
<tr key={v.video_id} className="border-t border-slate-50">
<td className="px-3 py-2 font-mono text-xs">
<a
href={`https://www.youtube.com/watch?v=${v.video_id}`}
target="_blank"
rel="noopener noreferrer"
className="text-teal-700 hover:underline"
>
{v.video_id}
</a>
</td>
<td className="max-w-[200px] truncate px-3 py-2 text-xs text-slate-700">
{v.title || '—'}
</td>
<td className="px-3 py-2">
<span
className={`rounded px-1.5 py-0.5 text-xs font-medium ${statusBadgeClass(v.status)}`}
>
{v.status}
</span>
</td>
<td className="px-3 py-2 font-mono text-xs tabular-nums text-slate-600 whitespace-nowrap">
{formatVideoDuration(v.duration_seconds)}
</td>
<td className="px-3 py-2 text-xs text-slate-600">{v.transcript_source || '—'}</td>
<td className="max-w-md px-3 py-2 text-xs text-slate-600 whitespace-pre-wrap break-words">
{v.error ? (
<LinkifiedText text={v.error} />
) : (
'—'
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
}
function BatchDetailPanel({
batch,
selectedJurisdictionId,
onSelectJurisdiction,
stateFilter,
onStateFilterChange,
showFailedVideos,
onToggleFailedVideos,
onShowFailedForJurisdiction,
}: {
batch: BatchJob
selectedJurisdictionId: string | null
onSelectJurisdiction: (id: string | null) => void
stateFilter: string
onStateFilterChange: (state: string) => void
showFailedVideos: boolean
onToggleFailedVideos: () => void
onShowFailedForJurisdiction: (jurisdictionId: string) => void
}) {
const s = batch.summary || {}
const total = Number(s.total_jurisdictions) || 0
const processed = Number(s.processed_jurisdictions) || 0
const pct = total > 0 ? Math.round((100 * processed) / total) : 0
const cfg = batch.config || {}
const states = Array.isArray(cfg.states) ? (cfg.states as string[]).join(', ') : '—'
const selectedJurisdiction = useMemo(
() =>
batch.jurisdictions.find((j) => j.jurisdiction_id === selectedJurisdictionId) ?? null,
[batch.jurisdictions, selectedJurisdictionId],
)
const videosFailCount = Number(s.videos_fail) || 0
const isRunning = batch.status === 'running'
const fileClock = useMemo(() => resolveRunningFileClock(batch), [batch])
const currentFileSeconds = useTickingSeconds(
fileClock?.startedAt,
isRunning && !!fileClock?.startedAt,
)
const avgPerFileSec = avgSecondsPerFile(s)
const batchRemainingVideos = useMemo(() => remainingVideosForBatch(batch), [batch])
const stateCodes = useMemo(() => {
const fromCfg = Array.isArray(cfg.states)
? (cfg.states as string[]).map((s) => String(s).toUpperCase().trim()).filter(Boolean)
: []
if (fromCfg.length) return [...new Set(fromCfg)].sort()
return jurisdictionStateCodes(batch.jurisdictions)
}, [batch.jurisdictions, cfg.states])
const sortedJurisdictions = useMemo(
() => sortJurisdictions(batch.jurisdictions),
[batch.jurisdictions],
)
const displayedJurisdictions = useMemo(
() => filterJurisdictionsByState(sortedJurisdictions, stateFilter),
[sortedJurisdictions, stateFilter],
)
const showJurisdictionTable = !!stateFilter
return (
<div className="space-y-4">
<div>
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-lg font-semibold text-slate-900">{batch.step}</h2>
<span className={`rounded px-2 py-0.5 text-xs font-medium ${statusBadgeClass(batch.status)}`}>
{batch.status}
</span>
</div>
<p className="mt-1 font-mono text-xs text-slate-500">{batch.batch_id}</p>
<p className="mt-1 text-xs text-slate-600">
Started {formatDateTimeAbsolute(batch.started_at)}
{batch.finished_at
? ` · Finished ${formatDateTimeAbsolute(batch.finished_at)}`
: ''}
</p>
{isRunning && fileClock?.videoId ? (
<p className="mt-1 text-xs text-slate-500">
Current:{' '}
<a
href={`https://www.youtube.com/watch?v=${fileClock.videoId}`}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-teal-700 hover:underline"
>
{fileClock.videoId}
</a>
{fileClock.title ? ` · ${fileClock.title.slice(0, 72)}` : ''}
</p>
) : null}
</div>
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
<SummaryCard
label="Processed"
value={formatCompactPair(processed, total || '?')}
title={metricCountTitle(processed, 'Processed jurisdictions')}
/>
<SummaryCard
label="Success"
value={formatCompactNumber(s.success_jurisdictions)}
title={metricCountTitle(s.success_jurisdictions, 'Success jurisdictions')}
/>
<SummaryCard
label="Failed"
value={formatCompactNumber(s.failed_jurisdictions)}
title={metricCountTitle(s.failed_jurisdictions, 'Failed jurisdictions')}
/>
<SummaryCard
label="Jurisdictions remaining"
value={formatCompactNumber(s.remaining_jurisdictions)}
title={metricCountTitle(s.remaining_jurisdictions, 'Remaining jurisdictions')}
/>
<SummaryCard label="Elapsed" value={formatDuration(s.elapsed_seconds)} />
<SummaryCard label="ETA" value={formatDuration(s.eta_seconds)} />
{isRunning ? (
<>
<SummaryCard label="Avg / file" value={formatDuration(avgPerFileSec)} />
<SummaryCard
label="Current file"
value={formatDuration(currentFileSeconds)}
/>
<SummaryCard
label="Videos remaining"
value={
batchRemainingVideos != null
? formatCompactNumber(batchRemainingVideos)
: '—'
}
title={metricCountTitle(batchRemainingVideos, 'Videos remaining')}
/>
</>
) : null}
<SummaryCard
label="Videos OK"
value={formatCompactNumber(s.videos_ok)}
title={metricCountTitle(s.videos_ok, 'Videos OK')}
/>
<SummaryCard
label="Videos fail"
value={formatCompactNumber(videosFailCount)}
title={metricCountTitle(videosFailCount, 'Videos failed')}
emphasis={videosFailCount > 0 ? 'danger' : 'default'}
active={showFailedVideos}
onClick={videosFailCount > 0 ? onToggleFailedVideos : undefined}
/>
</div>
<ProgressBar pct={pct} />
<p className="text-xs text-slate-600">
States: {states} · N={String(cfg.n ?? '—')} · delay={String(cfg.delay ?? '—')}s · source=
{String(cfg.transcript_source || '—')}
</p>
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 px-4 py-2">
<h3 className="text-sm font-semibold text-slate-800">
Jurisdictions
{showJurisdictionTable ? (
<span className="ml-1 font-normal text-slate-500">
({displayedJurisdictions.length} in {stateFilter})
</span>
) : null}
</h3>
<div className="flex items-center gap-2">
<label
htmlFor="batch-jobs-state-filter"
className="text-[10px] font-medium uppercase tracking-wide text-slate-500"
>
State <span className="text-red-600">*</span>
</label>
<select
id="batch-jobs-state-filter"
value={stateFilter}
onChange={(e) => onStateFilterChange(e.target.value)}
required
className="min-w-[8rem] rounded-md border border-slate-200 bg-white px-2 py-1 text-xs text-slate-800 shadow-sm"
>
<option value="" disabled>
Select state…
</option>
{stateCodes.map((st) => (
<option key={st} value={st}>
{st}
</option>
))}
</select>
</div>
</div>
{showJurisdictionTable ? (
<JurisdictionTable
jurisdictions={displayedJurisdictions}
selectedId={selectedJurisdictionId}
onSelect={(id) => onSelectJurisdiction(id)}
onShowFailedVideos={onShowFailedForJurisdiction}
emptyMessage={`No jurisdictions in ${stateFilter} for this batch.`}
/>
) : (
<p className="px-4 py-8 text-center text-sm text-slate-500">
{selectedJurisdictionId
? 'Per-jurisdiction details are below. Pick a state to browse the full list for that state.'
: 'Select a state to browse jurisdictions. The full plan is large; filter by state to keep the table fast.'}
</p>
)}
</div>
{selectedJurisdiction ? (
<VideoDrillDown
jurisdiction={selectedJurisdiction}
onlyFailed={showFailedVideos}
/>
) : null}
</div>
)
}
// Priority dev states — mirrors the wrapper-script default (the STATES env in
// youtube_run_priority_states_last_n.sh). "All states" uses STATE_CODES (50 + DC).
const PRIORITY_STATE_CODES = ['AL', 'GA', 'IN', 'MA', 'MT', 'WA', 'WI']
type JobType = 'youtube' | 'deployment'
function JobTypeTabs({
active,
onChange,
}: {
active: JobType
onChange: (t: JobType) => void
}) {
const tabs: { key: JobType; label: string }[] = [
{ key: 'youtube', label: 'YouTube pipeline' },
{ key: 'deployment', label: 'Prod deployment' },
]
return (
<div className="flex gap-1 border-b border-slate-200">
{tabs.map((t) => (
<button
key={t.key}
type="button"
onClick={() => onChange(t.key)}
className={`-mb-px border-b-2 px-3 py-2 text-sm font-medium ${
active === t.key
? 'border-sky-500 text-sky-700'
: 'border-transparent text-slate-500 hover:text-slate-700'
}`}
>
{t.label}
</button>
))}
</div>
)
}
export default function BatchJobStatusPage() {
const [searchParams, setSearchParams] = useSearchParams()
const jobType: JobType = searchParams.get('type') === 'deployment' ? 'deployment' : 'youtube'
const setJobType = useCallback(
(t: JobType) => {
const next = new URLSearchParams(searchParams)
if (t === 'deployment') next.set('type', 'deployment')
else next.delete('type')
setSearchParams(next, { replace: true })
},
[searchParams, setSearchParams],
)
const batchId = searchParams.get('batch') ?? ''
const jurisdictionId = searchParams.get('jurisdiction') ?? ''
const stateFilter = (searchParams.get('state') ?? '').toUpperCase()
const showFailedVideos = searchParams.get('view') === 'failed-videos'
const failedVideosScopeAll = searchParams.get('failed_scope') === 'all'
const queryClient = useQueryClient()
const [streamLive, setStreamLive] = useState(false)
const [videosLoading, setVideosLoading] = useState(false)
const { data, isPending, isFetching, isError, error, refetch } = useQuery({
queryKey: ['batch-jobs-dashboard'],
queryFn: () => fetchBatchJobsDashboard(false, 'summary'),
staleTime: 30_000,
refetchOnWindowFocus: true,
retry: 1,
})
useEffect(() => {
const url = batchJobsStreamUrl()
const es = new EventSource(url)
const applyPayload = (payload: BatchJobsDashboardPayload) => {
queryClient.setQueryData<BatchJobsDashboardPayload>(['batch-jobs-dashboard'], (prev) =>
mergeDashboardFromStream(prev, payload),
)
}
es.onopen = () => setStreamLive(true)
es.addEventListener('summary', (ev) => {
try {
applyPayload(JSON.parse(ev.data) as BatchJobsDashboardPayload)
} catch {
/* ignore */
}
})
es.onerror = () => {
setStreamLive(false)
es.close()
void refetch()
}
return () => {
setStreamLive(false)
es.close()
}
}, [queryClient, refetch])
const { data: launchStatus, refetch: refetchLaunch } = useQuery({
queryKey: ['batch-launch-status'],
queryFn: fetchLaunchStatus,
refetchInterval: 20_000,
retry: false,
})
const [launching, setLaunching] = useState(false)
const [stopping, setStopping] = useState(false)
const [launchMsg, setLaunchMsg] = useState<string | null>(null)
// Parallelism = how many jurisdictions analyze at once (the main throughput
// lever). Persisted so it survives reloads; ceiling is the Gemini quota.
const [parallelism, setParallelism] = useState<number>(() => {
const v = Number(localStorage.getItem('batch-parallelism'))
return v >= 1 && v <= 8 ? v : 6
})
const setParallel = useCallback((v: number) => {
setParallelism(v)
localStorage.setItem('batch-parallelism', String(v))
}, [])
// Which states a full / "ALL"-scope launch targets: the priority dev set
// (matches the script default) or every US state + DC. Picking a single state
// from the scope dropdown overrides this. Persisted across reloads.
const [allScope, setAllScopeState] = useState<'priority' | 'all'>(() => {
return localStorage.getItem('batch-launch-scope') === 'all' ? 'all' : 'priority'
})
const setAllScope = useCallback((v: 'priority' | 'all') => {
setAllScopeState(v)
localStorage.setItem('batch-launch-scope', v)
}, [])
// How many missing transcripts one ▶ Backfill click fetches. 0 = "All" (sweep
// every missing transcript). The run is resumable — it re-queries what's still
// missing — so a bounded size still makes incremental progress across clicks.
// Persisted across reloads; defaults to 4000.
const BACKFILL_SIZES = [100, 500, 2000, 4000, 0] as const
const [backfillSize, setBackfillSizeState] = useState<number>(() => {
const raw = localStorage.getItem('batch-backfill-size')
const v = Number(raw)
return raw != null && (BACKFILL_SIZES as readonly number[]).includes(v) ? v : 4000
})
const setBackfillSize = useCallback((v: number) => {
setBackfillSizeState(v)
localStorage.setItem('batch-backfill-size', String(v))
}, [])
const runLaunch = useCallback(
async (step: string, states: string[], nOverride?: number) => {
setLaunching(true)
setLaunchMsg(null)
try {
const res = await launchPipeline({
step,
states,
parallel: parallelism,
n: nOverride ?? 25,
})
setLaunchMsg(res.detail || 'Launched.')
void refetchLaunch()
window.setTimeout(() => void refetch(), 2500)
} catch (e) {
setLaunchMsg((e as Error).message || 'Launch failed')
} finally {
setLaunching(false)
}
},
[refetchLaunch, refetch, parallelism],
)
const stopLaunch = useCallback(
async (step?: string) => {
const label = step ? `the '${step}' run` : 'all running jobs'
if (!window.confirm(`Stop ${label}? In-flight work checkpoints and the process exits.`)) {
return
}
setStopping(true)
setLaunchMsg(null)
try {
const res = await stopPipeline(step ? { step } : {})
setLaunchMsg(res.detail || 'Stop requested.')
void refetchLaunch()
window.setTimeout(() => void refetch(), 2500)
} catch (e) {
setLaunchMsg((e as Error).message || 'Stop failed')
} finally {
setStopping(false)
}
},
[refetchLaunch, refetch],
)
// Per-stage drill-down: expand one stage to see its live log + current file.
const [expandedStage, setExpandedStage] = useState<string | null>(null)
// The Transcripts stage is advanced by two steps — per-jurisdiction `captions`
// and the global `backfill` — which write separate launch logs. Show the
// backfill log while it's the live (running/stalled) step; otherwise the
// conventional captions log.
const transcriptsBackfillLive =
(launchStatus?.running_steps ?? []).includes('backfill') ||
(launchStatus?.stalled_steps ?? []).includes('backfill')
const expandedStep = expandedStage
? expandedStage === 'transcripts' && transcriptsBackfillLive
? 'backfill'
: STAGE_STEP[expandedStage]
: null
const { data: stageLog } = useQuery({
queryKey: ['launch-log', expandedStep],
queryFn: () => fetchLaunchLog(expandedStep as string),
enabled: !!expandedStep,
refetchInterval: 4000,
retry: false,
})
const selectedBatch = useMemo(() => {
if (!data?.batches?.length) return null
if (batchId) {
return data.batches.find((b) => b.batch_id === batchId) ?? data.batches[0]
}
return data.batches[0]
}, [data, batchId])
const setBatch = useCallback(
(id: string) => {
const next = new URLSearchParams(searchParams)
next.set('batch', id)
next.delete('jurisdiction')
next.delete('state')
setSearchParams(next, { replace: true })
},
[searchParams, setSearchParams],
)
const setStateFilter = useCallback(
(state: string) => {
const next = new URLSearchParams(searchParams)
if (selectedBatch?.batch_id) next.set('batch', selectedBatch.batch_id)
const st = state.trim().toUpperCase()
if (st) next.set('state', st)
else next.delete('state')
if (jurisdictionId && selectedBatch) {
const visible = filterJurisdictionsByState(
sortJurisdictions(selectedBatch.jurisdictions),
st,
)
if (!visible.some((j) => j.jurisdiction_id === jurisdictionId)) {
next.delete('jurisdiction')
}
}
setSearchParams(next, { replace: true })
},
[searchParams, setSearchParams, selectedBatch, jurisdictionId],
)
const setJurisdiction = useCallback(
(id: string | null) => {
const next = new URLSearchParams(searchParams)
if (selectedBatch?.batch_id) next.set('batch', selectedBatch.batch_id)
if (id) next.set('jurisdiction', id)
else next.delete('jurisdiction')
next.delete('view')
setSearchParams(next, { replace: true })
},
[searchParams, setSearchParams, selectedBatch?.batch_id],
)
const setShowFailedVideos = useCallback(
(
open: boolean,
opts?: { batchId?: string; jurisdictionId?: string; allBatches?: boolean },
) => {
const next = new URLSearchParams(searchParams)
if (opts?.batchId) next.set('batch', opts.batchId)
if (opts?.jurisdictionId) next.set('jurisdiction', opts.jurisdictionId)
else if (!open) next.delete('jurisdiction')
if (open) {
next.set('view', 'failed-videos')
if (opts?.allBatches) next.set('failed_scope', 'all')
else next.delete('failed_scope')
} else {
next.delete('view')
next.delete('failed_scope')
}
setSearchParams(next, { replace: true })
},
[searchParams, setSearchParams],
)
const planStateCodes = useMemo(() => {
if (!selectedBatch) return [] as string[]
const fromCfg = Array.isArray(selectedBatch.config?.states)
? (selectedBatch.config.states as string[])
.map((s) => String(s).toUpperCase().trim())
.filter(Boolean)
: []
if (fromCfg.length) return [...new Set(fromCfg)].sort()
return jurisdictionStateCodes(selectedBatch.jurisdictions)
}, [selectedBatch])
const effectiveStateFilter = useMemo(() => {
if (!selectedBatch || !stateFilter) return ''
return planStateCodes.includes(stateFilter) ? stateFilter : ''
}, [selectedBatch, stateFilter, planStateCodes])
const {
data: stateJurisdictions,
isFetching: stateJurisdictionsFetching,
} = useQuery({
queryKey: ['batch-jurisdictions', selectedBatch?.batch_id, effectiveStateFilter],
queryFn: () =>
fetchBatchJurisdictions(selectedBatch!.batch_id, effectiveStateFilter),
enabled: !!selectedBatch?.batch_id && !!effectiveStateFilter,
staleTime: 15_000,
})
const selectedBatchForPanel = useMemo(() => {
if (!selectedBatch) return null
if (!effectiveStateFilter) return selectedBatch
if (stateJurisdictions) {
return mergeBatchJurisdictions(selectedBatch, stateJurisdictions)
}
return selectedBatch
}, [selectedBatch, effectiveStateFilter, stateJurisdictions])
const visibleJurisdictions = useMemo(() => {
if (!selectedBatchForPanel || !effectiveStateFilter) return []
return filterJurisdictionsByState(
sortJurisdictions(selectedBatchForPanel.jurisdictions),
effectiveStateFilter,
)
}, [selectedBatchForPanel, effectiveStateFilter])
const effectiveJurisdictionId = useMemo(() => {
if (!jurisdictionId || !selectedBatch) return null
if (!effectiveStateFilter) return jurisdictionId
return visibleJurisdictions.some((j) => j.jurisdiction_id === jurisdictionId)
? jurisdictionId
: null
}, [jurisdictionId, selectedBatch, effectiveStateFilter, visibleJurisdictions])
const selectedJurisdictionForVideos = useMemo(() => {
if (!selectedBatchForPanel || !effectiveJurisdictionId) return null
return (
selectedBatchForPanel.jurisdictions.find(
(j) => j.jurisdiction_id === effectiveJurisdictionId,
) ?? null
)
}, [selectedBatchForPanel, effectiveJurisdictionId])
const failedVideosBatchId = failedVideosScopeAll
? undefined
: selectedBatch?.batch_id
const {
data: failedVideosPayload,
isFetching: failedVideosFetching,
} = useQuery({
queryKey: ['batch-failed-videos', failedVideosBatchId ?? 'all'],
queryFn: () => fetchFailedVideos(failedVideosBatchId),
enabled:
showFailedVideos &&
!effectiveJurisdictionId &&
(data?.totals.videos_fail ?? 0) > 0,
staleTime: 30_000,
})
useEffect(() => {
if (!selectedBatch?.batch_id) return
const needsJurisdictionVideos =
!!effectiveJurisdictionId &&
!!selectedJurisdictionForVideos &&
(showFailedVideos
? failedVideoCount(selectedJurisdictionForVideos) > 0 &&
!(selectedJurisdictionForVideos.videos || []).some((v) =>
isFailedVideoStatus(v.status),
)
: (selectedJurisdictionForVideos.videos?.length ?? 0) === 0 &&
(Number(selectedJurisdictionForVideos.stats?.ok ?? 0) > 0 ||
failedVideoCount(selectedJurisdictionForVideos) > 0))
if (!needsJurisdictionVideos) return
let cancelled = false
setVideosLoading(true)
const mode = 'all'
void fetchBatchJobDetail(selectedBatch.batch_id, mode)
.then((batch) => {
if (cancelled) return
queryClient.setQueryData<BatchJobsDashboardPayload>(
['batch-jobs-dashboard'],
(prev) => mergeBatchIntoDashboard(prev, batch) ?? prev,
)
})
.finally(() => {
if (!cancelled) setVideosLoading(false)
})
return () => {
cancelled = true
}
}, [
selectedBatch?.batch_id,
effectiveJurisdictionId,
showFailedVideos,
selectedJurisdictionForVideos,
data?.totals.videos_fail,
queryClient,
])
const allFailedVideoRows = useMemo((): FailedVideoRow[] => {
if (effectiveJurisdictionId) {
return collectFailedVideos(data?.batches ?? [], {
batchId: selectedBatch?.batch_id,
jurisdictionId: effectiveJurisdictionId,
})
}
if (failedVideosPayload?.rows?.length) {
return failedVideosPayload.rows.map((r) => ({
batch_id: r.batch_id,
batch_step: r.batch_step,
state_code: r.state_code,
jurisdiction_id: r.jurisdiction_id,
jurisdiction_name: r.jurisdiction_name,
video: r.video,
}))
}
return collectFailedVideos(data?.batches ?? [], {
batchId: failedVideosBatchId,
})
}, [
data?.batches,
selectedBatch?.batch_id,
effectiveJurisdictionId,
failedVideosPayload,
failedVideosBatchId,
])
const failedScopeLabel = useMemo(() => {
if (effectiveJurisdictionId && selectedBatch) {
const j = selectedBatch.jurisdictions.find(
(x) => x.jurisdiction_id === effectiveJurisdictionId,
)
return `${selectedBatch.step} · ${j?.jurisdiction_name || effectiveJurisdictionId}`
}
if (failedVideosScopeAll) return 'All recent batches'
if (selectedBatch) return `Batch ${selectedBatch.step} (${selectedBatch.batch_id})`
return 'All batches'
}, [effectiveJurisdictionId, selectedBatch, failedVideosScopeAll])
const failedVideosSubtitle = useMemo(() => {
if (effectiveJurisdictionId) return undefined
const total =
failedVideosPayload?.total_fail_in_summaries ?? data?.totals.videos_fail ?? 0
const parts: string[] = []
if (total > allFailedVideoRows.length) {
parts.push(`${total} in summaries`)
}
if (failedVideosPayload?.truncated) {
parts.push('list capped at 500')
}
return parts.length ? parts.join('; ') : undefined
}, [
effectiveJurisdictionId,
failedVideosPayload,
data?.totals.videos_fail,
allFailedVideoRows.length,
])
const lastActivityIso = useMemo(() => {
const fromApi = data?.last_activity_at?.trim()
if (fromApi) return fromApi
const fromBatches = latestDashboardActivityIso(data?.batches ?? [])
if (fromBatches) return fromBatches
const running = data?.batches?.find((b) => b.status === 'running')
return running?.updated_at?.trim() || null
}, [data?.last_activity_at, data?.batches])
const [agoClockMs, setAgoClockMs] = useState(() => Date.now())
useEffect(() => {
const id = window.setInterval(() => setAgoClockMs(Date.now()), 10_000)
return () => window.clearInterval(id)
}, [])
const lastUpdateAgo = useMemo(
() => (lastActivityIso ? formatAgoCompact(lastActivityIso, agoClockMs) : null),
[lastActivityIso, agoClockMs],
)
const lastUpdateAbsolute = useMemo(
() => (lastActivityIso ? formatUpdatedAt(lastActivityIso) : null),
[lastActivityIso],
)
// Analyze/report runs don't touch the batch tracker (see "Last batch step"), so
// these "ago" cards read the most recent policy stamps instead — the real signal
// that the analyze pipeline is alive, including standalone/parallel runs.
const lastTranscriptIso = data?.totals.last_transcript_at?.trim() || null
const lastAnalysisIso = data?.totals.last_analysis_at?.trim() || null
const lastReportIso = data?.totals.last_report_at?.trim() || null
const lastTranscriptAgo = useMemo(
() => (lastTranscriptIso ? formatAgoCompact(lastTranscriptIso, agoClockMs) : null),
[lastTranscriptIso, agoClockMs],
)
const lastTranscriptAbsolute = useMemo(
() => (lastTranscriptIso ? formatUpdatedAt(lastTranscriptIso) : null),
[lastTranscriptIso],
)
const lastAnalysisAgo = useMemo(
() => (lastAnalysisIso ? formatAgoCompact(lastAnalysisIso, agoClockMs) : null),
[lastAnalysisIso, agoClockMs],
)
const lastReportAgo = useMemo(
() => (lastReportIso ? formatAgoCompact(lastReportIso, agoClockMs) : null),
[lastReportIso, agoClockMs],
)
const lastAnalysisAbsolute = useMemo(
() => (lastAnalysisIso ? formatUpdatedAt(lastAnalysisIso) : null),
[lastAnalysisIso],
)
const lastReportAbsolute = useMemo(
() => (lastReportIso ? formatUpdatedAt(lastReportIso) : null),
[lastReportIso],
)
const runningTiming = useMemo(
() => aggregateRunningFileTiming(data?.batches ?? []),
[data?.batches],
)
const remainingVideos = useMemo(
() => remainingVideosForRunningBatches(data?.batches ?? []),
[data?.batches],
)
const globalCurrentFileSeconds = useTickingSeconds(
runningTiming.activeVideo?.startedAt,
(data?.totals.running ?? 0) > 0 && !!runningTiming.activeVideo,
)
if (jobType === 'deployment') {
return (
<div className="min-h-0 w-full space-y-4">
<JobTypeTabs active={jobType} onChange={setJobType} />
<DeploymentPanel />
</div>
)
}
return (
<div className="min-h-0 w-full space-y-4">
<JobTypeTabs active={jobType} onChange={setJobType} />
<div className="flex flex-wrap items-center justify-between gap-2">
<p className="text-xs text-slate-600">
Live from <code>bronze.youtube_batch_job_runs</code> via{' '}
<code>GET /api/batch-jobs/stream</code>
{streamLive && (
<span className="ml-2 inline-flex items-center gap-1 text-emerald-700">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
connected
</span>
)}
</p>
</div>
{isPending && !data && (
<div className="rounded-lg border border-slate-200 bg-white p-8 text-center text-sm text-slate-500">
Loading batch summary…
</div>
)}
{isFetching && data && (
<p className="text-xs text-slate-500">Refreshing batch summary…</p>
)}
{isError && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
Could not load batch jobs: {batchJobsFetchErrorMessage(error)}
<p className="mt-2 text-xs text-red-700">
Start or restart the API:{' '}
<code className="rounded bg-red-100 px-1">
uvicorn api.main:app --reload --port 8001
</code>
. Apply migration 073 if needed. Batch runs sync from{' '}
<code>run_priority_states_last_n.sh</code>.
{!streamLive && (
<>
{' '}
<button
type="button"
className="underline"
onClick={() => refetch()}
>
Retry load
</button>
</>
)}
</p>
</div>
)}
{data && (
<>
{(() => {
const report = data.stage_report ?? { states: [], rows: [] }
const stateOpts = report.states ?? []
const scope = stateFilter && stateOpts.includes(stateFilter) ? stateFilter : 'ALL'
const rowFor = (
sc: string,
stage: PipelineStage,
): StageReportRow | undefined =>
report.rows.find((r) => r.scope === sc && r.stage === stage)
const fmtPct = (done: number, total: number) => {
if (!total) return '—'
const r = Math.round((done / total) * 1000) / 10
return `${Number.isInteger(r) ? r : r.toFixed(1)}%`
}
const agoFromIso = (iso?: string) =>
iso ? formatAgoCompact(iso, agoClockMs) : null
const openFailed = () => setShowFailedVideos(true, { allBatches: true })
const setScope = (sc: string) => {
const next = new URLSearchParams(searchParams)
if (sc) next.set('state', sc)
else next.delete('state')
setSearchParams(next, { replace: true })
}
const stageList: PipelineStage[] = [
'discover',
'videos',
'transcripts',
'analyses',
'reports',
]
const stageLabel: Record<PipelineStage, string> = {
discover: 'Discover',
videos: 'Videos',
transcripts: 'Transcripts',
analyses: 'Analyses',
reports: 'Reports',
}
// Discover-stage per-entity split labels (counties vs municipalities).
const entityShort: Record<string, string> = {
counties: 'Co',
municipalities: 'Mun',
}
const entityLabel: Record<string, string> = {
counties: 'Counties',
municipalities: 'Municipalities (cities/towns)',
}
// Which runnable step each stage's "Run" button kicks off.
const stageStep: Record<PipelineStage, string> = {
discover: 'discover',
videos: 'catalog',
transcripts: 'captions',
analyses: 'analyze',
reports: 'analyze',
}
const stepDesc: Record<PipelineStage, string> = {
discover: 'discover channels (scrape sites / search YouTube)',
videos: 'catalog — list videos on the channel',
transcripts: 'captions — download transcripts',
analyses: 'analyze — Gemini policy analysis',
reports: 'analyze — regenerates reports too',
}
type Def = {
n: number
stage: PipelineStage
unit: string
sub?: string
pill: boolean
drill: (() => void) | null
failTitle: string
}
const defs: Def[] = [
{
n: 0,
stage: 'discover',
unit: 'jurisdictions',
sub: 'channel discovery — scrape county sites / search YouTube',
pill: true,
drill: null,
failTitle: 'Jurisdictions still missing a YouTube channel',
},
{
n: 1,
stage: 'videos',
unit: 'attempted',
pill: false,
drill: openFailed,
failTitle: 'Videos whose caption fetch failed — tap to list',
},
{
n: 2,
stage: 'transcripts',
unit: 'videos',
sub: `${formatCompactNumber(data.totals.files_transcripts_disk ?? 0)} segment rows · deduped to videos`,
pill: true,
drill: openFailed,
failTitle: 'Videos with no usable transcript — tap to list',
},
{
n: 3,
stage: 'analyses',
unit: 'videos',
pill: true,
drill: null,
failTitle: 'Analysis errors (bronze policy_analysis_error)',
},
{
n: 4,
stage: 'reports',
unit: 'videos',
pill: true,
drill: null,
failTitle: 'Report errors (bronze policy_report_error)',
},
]
// Fixed-width Failed/Run columns so the progress column lines up across rows.
const cols =
'grid grid-cols-[1.4fr_0.8fr_minmax(0,1.7fr)_4.5rem_6.5rem] items-center gap-3'
const ls = launchStatus
// ALL view scope → an explicit launch target (priority set or 50 + DC)
// so the backend can't silently fall back to its priority-only default.
const launchScopeStates =
scope === 'ALL'
? allScope === 'all'
? STATE_CODES
: PRIORITY_STATE_CODES
: [scope]
const scopeLaunchLabel =
scope !== 'ALL' ? scope : allScope === 'all' ? 'all 50 + DC' : 'priority'
// Each running step maps to the stage(s) it advances. Different steps
// run concurrently, so we union them for the live "running" markers.
const stepStages: Record<string, PipelineStage[]> = {
discover: ['discover'],
catalog: ['videos'],
captions: ['videos', 'transcripts'],
// Global mart-wide sweep — advances only the transcripts stage.
backfill: ['transcripts'],
analyze: ['analyses', 'reports'],
all: ['discover', 'videos', 'transcripts', 'analyses', 'reports'],
each: ['discover', 'videos', 'transcripts', 'analyses', 'reports'],
}
const runningSteps = new Set(ls?.running_steps ?? [])
const stalledSteps = new Set(ls?.stalled_steps ?? [])
const runningStages = new Set<PipelineStage>()
runningSteps.forEach((s) =>
(stepStages[s] ?? []).forEach((g) => runningStages.add(g)),
)
const anyFullRun = runningSteps.has('all') || runningSteps.has('each')
// A stage's Run is allowed unless its step (or a full-pipeline run) is
// already running — so e.g. Discover can launch while Analyze runs.
const canRunStage = (g: PipelineStage) =>
!!ls?.enabled &&
!launching &&
!runningSteps.has(stageStep[g]) &&
!anyFullRun
// The global backfill is its own step, so it may run alongside
// captions; it's only blocked by another backfill or a full run.
const canRunBackfill =
!!ls?.enabled &&
!launching &&
!runningSteps.has('backfill') &&
!anyFullRun
const canRunAll =
!!ls?.enabled &&
!launching &&
runningSteps.size === 0 &&
data.totals.running === 0
// Header "last activity" = the freshest per-step stamp for this scope,
// not the stale batch-step clock.
const freshestIso = stageList
.map((s) => rowFor(scope, s)?.last_at || '')
.reduce((a, b) => (a > b ? a : b), '')
const freshestAgo = agoFromIso(freshestIso)
// Mirrors the API stall window (_LAUNCH_STALL_SECONDS /
// BATCH_JOB_INACTIVITY_SECONDS, default 3600s).
const STALL_SECONDS = 3600
const freshestAgeSecs = freshestIso
? Math.max(0, Math.floor((agoClockMs - Date.parse(freshestIso)) / 1000))
: null
// A batch row stays status='running' after its worker dies or starts
// skipping already-done work, so totals.running alone overstates
// liveness. Count row-running as live only with recent recorded
// progress; otherwise it's a stalled run the reaper hasn't swept yet.
const rowsStale =
data.totals.running > 0 &&
runningSteps.size === 0 &&
freshestAgeSecs != null &&
freshestAgeSecs > STALL_SECONDS
const liveRunningRows = data.totals.running > 0 && !rowsStale
const isActive = liveRunningRows || runningSteps.size > 0
const anyStalled = stalledSteps.size > 0 || rowsStale
const statusText = isActive ? 'Running' : anyStalled ? 'Stalled' : 'Idle'
const statusDot = isActive
? 'bg-emerald-500'
: anyStalled
? 'bg-amber-500'
: 'bg-slate-400'
return (
<>
<section className="rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-slate-100 px-4 py-3">
<div className="flex flex-wrap items-center gap-1.5 text-sm">
<span
className={`inline-block h-2.5 w-2.5 rounded-full ${statusDot} ${
isActive ? 'animate-pulse' : ''
}`}
/>
<span className="font-semibold text-slate-800">{statusText}</span>
{freshestAgo ? (
<span className="text-slate-500">· last activity {freshestAgo}</span>
) : null}
<span className="text-slate-500">
{runningSteps.size > 0
? `· running ${[...runningSteps].join(', ')}`
: rowsStale
? `· ${formatCompactNumber(data.totals.running)} running, no progress >1h — likely stalled`
: stalledSteps.size > 0
? `· ${[...stalledSteps].join(', ')} timed out (no activity 1h)`
: `· ${formatCompactNumber(data.totals.running)} running`}
</span>
{scope !== 'ALL' ? (
<span className="rounded bg-slate-100 px-1.5 py-0.5 text-xs font-medium text-slate-600">
{scope}
</span>
) : null}
</div>
<div className="flex items-center gap-2">
{ls?.enabled && scope === 'ALL' ? (
<label
className="flex items-center gap-1 text-xs text-slate-500"
title="Which states a full / ALL-scope run targets. Priority = the dev set (AL, GA, IN, MA, MT, WA, WI); All = every US state + DC. Pick a single state from the scope dropdown to run just that one. Applies to the next launch."
>
<span aria-hidden>🗺️</span>
<select
value={allScope}
onChange={(e) => setAllScope(e.target.value as 'priority' | 'all')}
className="rounded-md border border-slate-300 bg-white px-1.5 py-1 text-xs font-medium text-slate-700"
>
<option value="priority">Priority states</option>
<option value="all">All states (50 + DC)</option>
</select>
</label>
) : null}
{ls?.enabled ? (
<label
className="flex items-center gap-1 text-xs text-slate-500"
title="Jurisdictions analyzed in parallel — the main throughput lever (ceiling is Gemini quota). Applies to the next launch."
>
<span aria-hidden></span>
<select
value={parallelism}
onChange={(e) => setParallel(Number(e.target.value))}
className="rounded-md border border-slate-300 bg-white px-1.5 py-1 text-xs font-medium text-slate-700"
>
{[2, 4, 6, 8].map((p) => (
<option key={p} value={p}>
{p}× parallel
</option>
))}
</select>
</label>
) : null}
{ls?.enabled ? (
<button
type="button"
disabled={!canRunAll}
onClick={() => runLaunch('all', launchScopeStates)}
title={
runningSteps.size > 0
? 'Finish the running step(s) before a full-pipeline run'
: `Run the full pipeline (catalog → captions → analyze) for ${scopeLaunchLabel} states`
}
className="inline-flex items-center gap-1 rounded-md bg-[#354F52] px-2.5 py-1.5 text-xs font-semibold uppercase tracking-wide text-white shadow-sm hover:bg-[#2c4346] disabled:cursor-not-allowed disabled:opacity-50"
>
{launching ? 'Launching…' : `▶ Run all · ${scopeLaunchLabel}`}
</button>
) : null}
{ls?.enabled && (ls?.busy || runningSteps.size > 0 || stalledSteps.size > 0) ? (
<button
type="button"
disabled={stopping}
onClick={() => void stopLaunch()}
title="Stop all running pipeline jobs (SIGTERM — work checkpoints and exits)"
className="inline-flex items-center gap-1 rounded-md bg-rose-600 px-2.5 py-1.5 text-xs font-semibold uppercase tracking-wide text-white shadow-sm hover:bg-rose-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{stopping ? 'Stopping…' : '■ Stop all'}
</button>
) : null}
{stateOpts.length > 0 ? (
<select
value={scope === 'ALL' ? '' : scope}
onChange={(e) => setScope(e.target.value)}
title="Scope the stage numbers (and the detail tables) to one state"
className="rounded-md border border-slate-300 bg-white px-2.5 py-1.5 text-xs font-medium text-slate-700 shadow-sm hover:bg-slate-50"
>
<option value="">All states</option>
{stateOpts.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
) : null}
</div>
</div>
{launchMsg ? (
<div className="border-b border-slate-100 bg-slate-50 px-4 py-1.5 text-xs text-slate-600">
{launchMsg}
</div>
) : null}
<div
className={`${cols} px-4 py-2 text-[10px] font-semibold uppercase tracking-wide text-slate-400`}
>
<div>Stage</div>
<div>Last activity</div>
<div>Progress</div>
<div className="text-right">Failed</div>
<div className="text-right">Run</div>
</div>
{defs.map((st, idx) => {
const r = rowFor(scope, st.stage)
const done = r?.done ?? 0
const total = r?.total ?? 0
const failed = r?.failed ?? 0
const pct = total ? (done / total) * 100 : 0
const ago = agoFromIso(r?.last_at)
const running = runningStages.has(st.stage)
const expanded = expandedStage === st.stage
const tm = data.stage_report?.timing?.[st.stage]
// Live pace: avg_seconds is the median gap between *completed*
// files, so it freezes during a stall (no new gap is ever
// recorded). Once a running stage is overdue for its next
// completion, our best estimate of the current pace is at
// least how long we've already been waiting — fold the open
// trailing gap in so /hr and ETA degrade every clock tick and
// recover the moment a file lands.
const avgSecs = tm?.avg_seconds && tm.avg_seconds > 0 ? tm.avg_seconds : null
const lastAtMs = tm?.last_at ? Date.parse(tm.last_at) : NaN
const sinceLastSecs = Number.isFinite(lastAtMs)
? Math.max(0, (agoClockMs - lastAtMs) / 1000)
: null
const effectiveSecs =
avgSecs == null
? null
: running && sinceLastSecs != null
? Math.max(avgSecs, sinceLastSecs)
: avgSecs
const stalled =
running &&
avgSecs != null &&
sinceLastSecs != null &&
sinceLastSecs > avgSecs * 3
return (
<Fragment key={st.stage}>
<div className={`${cols} border-t border-slate-100 px-4 py-3`}>
<button
type="button"
onClick={() => setExpandedStage(expanded ? null : st.stage)}
className="min-w-0 text-left"
title="Show this stage's live log + timing"
>
<div className="flex items-center gap-1 text-sm font-semibold text-slate-800">
<span className="text-slate-400">{expanded ? '▾' : '▸'}</span>
{idx + 1} · {stageLabel[st.stage]}
</div>
{st.sub ? (
<div className="mt-0.5 flex items-center gap-1 text-[11px] text-amber-600">
<span aria-hidden>⚠️</span>
<span className="truncate" title={st.sub}>
{st.sub}
</span>
</div>
) : null}
</button>
<div className="flex items-center gap-1.5 text-sm">
{running ? (
<>
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-emerald-500" />
<span className="font-medium text-emerald-600">running…</span>
</>
) : (
<>
<span className="inline-block h-1.5 w-1.5 rounded-full bg-slate-400" />
<span className="text-slate-600">{ago ?? '—'}</span>
</>
)}
</div>
<div className="min-w-0">
<div className="text-sm tabular-nums text-slate-700">
<span className="font-semibold text-slate-900">
{formatCompactNumber(done)}
</span>{' '}
/ {formatCompactNumber(total)} {st.unit} · {fmtPct(done, total)}
</div>
<div className="mt-1.5">
<ProgressBar pct={pct} />
</div>
{st.stage === 'discover' && (r?.breakdown?.length ?? 0) > 0 ? (
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-[11px] text-slate-500">
{r!.breakdown!.map((b) => (
<span
key={b.entity}
title={`${entityLabel[b.entity] ?? b.entity}: ${formatCompactNumber(b.done)} of ${formatCompactNumber(b.total)} with a channel`}
>
<span className="font-medium text-slate-600">
{entityLabel[b.entity] ?? b.entity}
</span>{' '}
{fmtPct(b.done, b.total)}{' '}
<span className="tabular-nums text-slate-400">
{formatCompactNumber(b.done)}/{formatCompactNumber(b.total)}
</span>
</span>
))}
</div>
) : null}
</div>
<div className="text-right">
{failed > 0 ? (
st.drill ? (
<button
type="button"
onClick={st.drill}
title={st.failTitle}
className={
st.pill
? 'inline-flex items-center gap-0.5 rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-xs font-semibold text-red-700 hover:bg-red-100'
: 'text-sm font-semibold text-red-700 hover:underline'
}
>
{formatCompactNumber(failed)}
<span className="text-red-400" aria-hidden>
</span>
</button>
) : (
<span
title={st.failTitle}
className="inline-flex items-center rounded-full border border-red-200 bg-red-50 px-2 py-0.5 text-xs font-semibold text-red-700"
>
{formatCompactNumber(failed)}
</span>
)
) : (
<span className="text-xs text-slate-400">0</span>
)}
</div>
<div className="flex flex-col items-end gap-1">
{ls?.enabled ? (
<button
type="button"
disabled={!canRunStage(st.stage)}
onClick={() => runLaunch(stageStep[st.stage], launchScopeStates)}
title={
runningSteps.has(stageStep[st.stage])
? `${stageStep[st.stage]} is already running`
: `Run ${stepDesc[st.stage]} · ${scopeLaunchLabel}`
}
className="whitespace-nowrap rounded-md border border-slate-300 bg-white px-2 py-1 text-[11px] font-semibold text-slate-700 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"
>
{running ? '· · ·' : '▶ Run'}
</button>
) : null}
{/* Transcripts only: global mart-wide sweep (also reaches
LocalView/union videos captions never visits). The
size picker sets how many missing transcripts one
click fetches (All = sweep everything). */}
{ls?.enabled && st.stage === 'transcripts' ? (
<>
<button
type="button"
disabled={!canRunBackfill}
onClick={() =>
runLaunch(
'backfill',
// The backfill is a flat mart-wide sweep, so
// "All states (50 + DC)" is best expressed as
// an unfiltered national sweep ([] → backend
// omits --states). A literal 50 + DC state
// filter silently drops the CivicSearch videos
// that carry no state_code (~19.5k school +
// ~3k general), which are the priority sources
// here. Priority/single-state scopes still
// filter as before.
scope === 'ALL' && allScope === 'all'
? []
: launchScopeStates,
backfillSize,
)
}
title={
runningSteps.has('backfill')
? 'backfill is already running'
: `Backfill ${
backfillSize === 0 ? 'all' : backfillSize
} missing transcript(s) across the full event mart (LocalView + YouTube) · ${scopeLaunchLabel}`
}
className="whitespace-nowrap rounded-md border border-slate-300 bg-white px-2 py-1 text-[11px] font-semibold text-slate-700 hover:bg-slate-50 disabled:cursor-not-allowed disabled:opacity-40"
>
{runningSteps.has('backfill') ? '· · ·' : '▶ Backfill'}
</button>
<select
value={backfillSize}
onChange={(e) => setBackfillSize(Number(e.target.value))}
title="How many missing transcripts one Backfill click fetches (All = sweep everything; runs are resumable)"
className="w-full rounded-md border border-slate-300 bg-white px-1 py-0.5 text-[10px] font-medium text-slate-600 hover:bg-slate-50"
>
<option value={100}>100</option>
<option value={500}>500</option>
<option value={2000}>2,000</option>
<option value={4000}>4,000</option>
<option value={0}>All</option>
</select>
</>
) : null}
</div>
</div>
{expanded ? (
<div className="border-t border-slate-100 bg-slate-50 px-4 py-3 text-xs text-slate-600">
<div className="flex flex-wrap gap-x-6 gap-y-1">
<span>
avg <b className="text-slate-800">{fmtSecs(avgSecs)}</b>/file
{effectiveSecs ? ` · ${Math.round(3600 / effectiveSecs)}/hr` : ''}
{stalled ? (
<span
className="ml-1 font-semibold text-amber-600"
title="No new completions — /hr and ETA now reflect the time since the last file, not the historical pace"
>
· stalled
</span>
) : null}
</span>
{effectiveSecs && total - done > 0 ? (
<span>
ETA{' '}
<b className={stalled ? 'text-amber-600' : 'text-slate-800'}>
{fmtEta(((total - done) * effectiveSecs) / 3600)}
</b>{' '}
for {formatCompactNumber(total - done)} left
</span>
) : null}
<span>
last file:{' '}
<b className="text-slate-800" title={tm?.last_path}>
{baseName(tm?.last_path)}
</b>
{tm?.last_at ? ` · ${agoFromIso(tm.last_at) ?? ''}` : ''}
</span>
{running && stageLog?.current ? (
<span className="min-w-0">
current:{' '}
<b className="text-emerald-700">{stageLog.current}</b>
{stageLog.current_since
? ` · ${agoFromIso(stageLog.current_since) ?? ''} on it`
: ''}
</span>
) : null}
</div>
<pre className="mt-2 max-h-56 overflow-auto rounded bg-slate-900 p-2 text-[10px] leading-snug text-slate-100">
{stageLog?.lines?.length
? stageLog.lines.join('\n')
: 'No log for this step yet — launch it to see live output.'}
</pre>
{stageLog?.path ? (
<div className="mt-1 text-[10px] text-slate-400">log: {stageLog.path}</div>
) : null}
</div>
) : null}
</Fragment>
)
})}
<div className="border-t border-slate-100 px-4 py-2 text-[11px] text-slate-400">
<span className="text-emerald-500" aria-hidden>
</span>{' '}
retryable{' '}
<span className="text-slate-500" aria-hidden>
</span>{' '}
terminal · tap a failed count to drill in
{ls && !ls.enabled ? ' · launch disabled (set BATCH_JOBS_ALLOW_LAUNCH=1)' : ''}
</div>
</section>
{stateOpts.length > 0 ? (
<section className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 px-4 py-2 text-[10px] font-semibold uppercase tracking-wide text-slate-400">
State coverage · {stateOpts.length} states · click a row to scope
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-[10px] uppercase tracking-wide text-slate-400">
<th className="px-4 py-2 text-left font-semibold">State</th>
{stageList.map((s) => (
<th key={s} className="px-3 py-2 text-right font-semibold">
{stageLabel[s]}
</th>
))}
</tr>
</thead>
<tbody>
{stateOpts.map((stCode) => (
<tr
key={stCode}
onClick={() => setScope(stCode)}
className={`cursor-pointer border-t border-slate-100 hover:bg-slate-50 ${
scope === stCode ? 'bg-slate-50' : ''
}`}
>
<td className="px-4 py-2 font-semibold text-slate-800">{stCode}</td>
{stageList.map((s) => {
const r = rowFor(stCode, s)
const done = r?.done ?? 0
const total = r?.total ?? 0
const bd = s === 'discover' ? r?.breakdown ?? [] : []
return (
<td key={s} className="px-3 py-2 text-right tabular-nums">
<div className="text-slate-700">{fmtPct(done, total)}</div>
<div className="text-[10px] text-slate-400">
{formatCompactNumber(done)}/{formatCompactNumber(total)}
</div>
{bd.length > 0 ? (
<div className="mt-0.5 space-y-px">
{bd.map((b) => (
<div
key={b.entity}
className="text-[10px] text-slate-400"
title={`${entityLabel[b.entity] ?? b.entity}: ${formatCompactNumber(b.done)} of ${formatCompactNumber(b.total)} with a channel`}
>
<span className="text-slate-500">
{entityShort[b.entity] ?? b.entity}
</span>{' '}
{fmtPct(b.done, b.total)}{' '}
<span className="text-slate-300">
{formatCompactNumber(b.done)}/{formatCompactNumber(b.total)}
</span>
</div>
))}
</div>
) : null}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
</section>
) : null}
</>
)
})()}
<details className="group">
<summary className="cursor-pointer select-none py-1 text-xs font-medium text-slate-500 hover:text-slate-700">
All metrics
</summary>
<div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-5">
{lastActivityIso && lastUpdateAgo ? (
<SummaryCard
label="Last batch step"
value={lastUpdateAgo}
title={
lastUpdateAbsolute?.title
? `${lastUpdateAbsolute.title} · last catalog/captions batch step (NOT the analyze pipelinesee Last analysis/report)${
data.source === 'database' ? ' · from database' : ''
}`
: 'Last catalog/captions batch step (NOT the analyze pipelinesee Last analysis/report)'
}
/>
) : null}
{lastTranscriptIso && lastTranscriptAgo ? (
<SummaryCard
label="Last transcript"
value={lastTranscriptAgo}
title={
lastTranscriptAbsolute?.title
? `${lastTranscriptAbsolute.title} · most recent transcript downloaded (transcript_download_at)`
: 'Most recent transcript downloaded (transcript_download_at)'
}
/>
) : null}
{lastAnalysisIso && lastAnalysisAgo ? (
<SummaryCard
label="Last analysis"
value={lastAnalysisAgo}
title={
lastAnalysisAbsolute?.title
? `${lastAnalysisAbsolute.title} · most recent AI analysis written (policy_analysis_at; includes standalone/parallel analyze runs)`
: 'Most recent AI analysis written (includes standalone/parallel analyze runs)'
}
/>
) : null}
{lastReportIso && lastReportAgo ? (
<SummaryCard
label="Last report"
value={lastReportAgo}
title={
lastReportAbsolute?.title
? `${lastReportAbsolute.title} · most recent report generated (policy_report_at; includes standalone/parallel analyze runs)`
: 'Most recent report generated (includes standalone/parallel analyze runs)'
}
/>
) : null}
<SummaryCard
label="Batches"
value={formatCompactNumber(data.totals.batches)}
title={metricCountTitle(data.totals.batches, 'Batches')}
/>
<SummaryCard
label="Running"
value={formatCompactNumber(data.totals.running)}
title={
runningTiming.idleRunningBatchCount > 0
? `${formatFullNumber(data.totals.running)} batch(es) with status running; ${runningTiming.idleRunningBatchCount} idle (no active file). Idle runs auto-cancel after 1h without progress.`
: metricCountTitle(data.totals.running, 'Running batches')
}
/>
<SummaryCard
label="States started"
value={formatCompactPair(
data.totals.states_started ?? 0,
data.totals.states_planned ?? data.totals.states ?? 0,
)}
title="States with at least one jurisdiction running or finished in the batch plan"
/>
<SummaryCard
label="States completed"
value={formatCompactPair(
data.totals.states_completed ?? 0,
data.totals.states_planned ?? data.totals.states ?? 0,
)}
title="States where every planned jurisdiction row is finished (none pending or running)"
/>
{data.totals.running > 0 ? (
<>
<SummaryCard
label="Avg / file"
value={formatDuration(runningTiming.avgSecondsPerFile)}
/>
<SummaryCard
label="Current file"
value={
runningTiming.activeVideo
? formatDuration(globalCurrentFileSeconds)
: 'Idle'
}
title={
runningTiming.activeVideo
? [
'Time on this in-flight video since it started (can exceed Avg / file if yt-dlp is slow or stuck).',
runningTiming.activeVideo.title,
runningTiming.activeVideo.videoId,
runningTiming.activeVideo.jurisdictionId,
]
.filter(Boolean)
.join(' · ') || 'In-flight video'
: runningTiming.idleRunningBatchCount > 0
? `${runningTiming.idleRunningBatchCount} running batch(es) with no in-flight video (between jurisdictions or waiting).`
: 'No in-flight video with a start time'
}
/>
<SummaryCard
label="Videos remaining"
value={
remainingVideos != null ? formatCompactNumber(remainingVideos) : '—'
}
title={metricCountTitle(remainingVideos, 'Videos remaining')}
/>
</>
) : null}
<SummaryCard
label="Jurisdictions done"
value={formatCompactNumber(data.totals.processed_jurisdictions)}
title={metricCountTitle(data.totals.processed_jurisdictions, 'Jurisdictions done')}
/>
<SummaryCard
label="Jurisdictions failed"
value={formatCompactNumber(data.totals.failed_jurisdictions)}
title={metricCountTitle(data.totals.failed_jurisdictions, 'Jurisdictions failed')}
/>
<SummaryCard
label="Jurisdictions remaining"
value={formatCompactNumber(data.totals.remaining_jurisdictions)}
title={metricCountTitle(
data.totals.remaining_jurisdictions,
'Jurisdictions remaining',
)}
/>
<SummaryCard
label="Videos OK"
value={formatCompactNumber(data.totals.videos_ok)}
title={metricCountTitle(data.totals.videos_ok, 'Videos OK')}
/>
<SummaryCard
label="Videos failed"
value={formatCompactNumber(data.totals.videos_fail)}
title={metricCountTitle(data.totals.videos_fail, 'Videos failed')}
emphasis={data.totals.videos_fail > 0 ? 'danger' : 'default'}
active={showFailedVideos}
onClick={
data.totals.videos_fail > 0
? () =>
setShowFailedVideos(!showFailedVideos, {
allBatches: !showFailedVideos,
})
: undefined
}
/>
<SummaryCard
label="Videos attempted"
value={formatCompactNumber(data.totals.videos_attempted ?? 0)}
title={
metricCountTitle(data.totals.videos_attempted, 'Videos attempted') ??
'Caption fetch outcomes this batch (ok + fail + tombstoned + empty + rate limit)'
}
/>
<SummaryCard
label="Transcripts"
value={formatCompactNumber(data.totals.files_transcripts_disk ?? 0)}
title={
metricCountTitle(data.totals.files_transcripts_disk, 'Transcripts') ??
'Distinct videos with a downloaded transcript (bronze transcript_download_at, all time) — the denominator for analysis/report progress'
}
/>
<SummaryCard
label="Transcript hours"
value={formatCompactHours(data.totals.transcript_hours ?? 0)}
title={
metricCountTitle(data.totals.transcript_hours, 'Transcript hours') ??
'Sum of catalog duration_minutes for batch OK videos (from per-video rows or bronze since batch start).'
}
/>
<SummaryCard
label="Bronze download rows"
value={formatCompactNumber(data.totals.bronze_download_rows ?? 0)}
title={
metricCountTitle(data.totals.bronze_download_rows, 'Bronze download rows') ??
'bronze_event_youtube rows with transcript_download_at (all time)'
}
/>
<SummaryCard
label="Analyses"
value={formatCompactNumber(data.totals.files_analysis)}
title={
metricCountTitle(data.totals.files_analysis, 'Analyses') ??
'Videos with an AI analysis (bronze policy_analysis_at, all time, live)'
}
/>
<SummaryCard
label="Reports"
value={formatCompactNumber(data.totals.files_reports)}
title={
metricCountTitle(data.totals.files_reports, 'Reports') ??
'Videos with a generated report (bronze policy_report_at, all time, live)'
}
/>
<SummaryCard
label="Analysis progress"
value={
data.totals.files_transcripts_disk > 0
? `${Math.round((data.totals.files_analysis / data.totals.files_transcripts_disk) * 100)}%`
: '—'
}
title={`${formatCompactNumber(data.totals.files_analysis)} of ${formatCompactNumber(
data.totals.files_transcripts_disk ?? 0,
)} transcripts have an AI analysis (live bronze stamps)`}
/>
<SummaryCard
label="Reports progress"
value={
data.totals.files_transcripts_disk > 0
? `${Math.round((data.totals.files_reports / data.totals.files_transcripts_disk) * 100)}%`
: '—'
}
title={`${formatCompactNumber(data.totals.files_reports)} of ${formatCompactNumber(
data.totals.files_transcripts_disk ?? 0,
)} transcripts have a generated report (live bronze stamps; same denominator as Analysis progress, so the two read as one funnel — not reports÷analyses)`}
/>
<SummaryCard
label="Analysis (24h)"
value={formatCompactNumber(data.totals.files_analysis_recent ?? 0)}
title={
metricCountTitle(data.totals.files_analysis_recent, 'AI analyses') ??
'AI summary analyses written in the last 24 hours (by file mtime)'
}
/>
<SummaryCard
label="Reports (24h)"
value={formatCompactNumber(data.totals.files_reports_recent ?? 0)}
title={
metricCountTitle(data.totals.files_reports_recent, 'Reports') ??
'Reports generated in the last 24 hours (by file mtime)'
}
/>
</div>
</details>
{showFailedVideos && !effectiveJurisdictionId && (
<FailedVideosPanel
rows={allFailedVideoRows}
scopeLabel={failedScopeLabel}
subtitle={
failedVideosFetching
? 'loading…'
: failedVideosSubtitle
}
onClose={() => setShowFailedVideos(false)}
onSelectJurisdiction={(bid, jid) => {
setShowFailedVideos(true, { batchId: bid, jurisdictionId: jid })
}}
/>
)}
<div className="grid min-h-0 gap-4 lg:grid-cols-[minmax(220px,280px)_1fr]">
{videosLoading || stateJurisdictionsFetching ? (
<p className="text-xs text-slate-500 lg:col-span-2">
{videosLoading
? 'Loading per-video details…'
: 'Loading jurisdictions for selected state…'}
</p>
) : null}
<aside className="rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 px-3 py-2 text-sm font-semibold text-slate-800">
Batches
</div>
<ul className="max-h-[min(520px,60vh)] overflow-auto p-2">
{data.batches.length === 0 ? (
<li className="px-2 py-4 text-center text-xs text-slate-500">No batches yet</li>
) : (
data.batches.map((b) => {
const sum = b.summary || {}
const t = Number(sum.total_jurisdictions) || 0
const p = Number(sum.processed_jurisdictions) || 0
const pct = t > 0 ? Math.round((100 * p) / t) : 0
const active = selectedBatch?.batch_id === b.batch_id
return (
<li key={b.batch_id} className="mb-1">
<button
type="button"
onClick={() => setBatch(b.batch_id)}
className={`w-full rounded-md border px-2 py-2 text-left text-sm transition-colors ${
active
? 'border-teal-500 bg-teal-50'
: 'border-transparent hover:bg-slate-50'
}`}
>
<div className="flex items-center gap-1">
<span className="font-medium text-slate-900">{b.step}</span>
<span
className={`rounded px-1 text-[10px] font-medium ${statusBadgeClass(b.status)}`}
>
{b.status}
</span>
</div>
<div className="mt-0.5 truncate font-mono text-[10px] text-slate-500">
{b.batch_id}
</div>
<div className="mt-1 text-[11px] text-slate-600">
{p}/{t || '?'} jurisdictions · {pct}%
</div>
</button>
</li>
)
})
)}
</ul>
</aside>
<section className="min-w-0">
{selectedBatchForPanel ? (
<BatchDetailPanel
batch={selectedBatchForPanel}
selectedJurisdictionId={effectiveJurisdictionId}
onSelectJurisdiction={setJurisdiction}
stateFilter={effectiveStateFilter}
onStateFilterChange={setStateFilter}
showFailedVideos={showFailedVideos}
onToggleFailedVideos={() =>
setShowFailedVideos(!showFailedVideos, {
batchId: selectedBatchForPanel.batch_id,
allBatches: false,
})
}
onShowFailedForJurisdiction={(jid) => {
setShowFailedVideos(true, {
batchId: selectedBatchForPanel.batch_id,
jurisdictionId: jid,
})
requestAnimationFrame(() => {
document
.getElementById('batch-jurisdiction-drilldown')
?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
}}
/>
) : (
<div className="rounded-lg border border-slate-200 bg-white p-8 text-center text-sm text-slate-500">
Select a batch
</div>
)}
</section>
</div>
</>
)}
</div>
)
}