import { useQuery } from '@tanstack/react-query' import { useCallback, useMemo, useState } from 'react' import { fetchDeployments, fetchDeploymentLog, launchDeployment, stopDeployment, type DeploymentJob, type DeploymentStep, } from '../api/deployments' import { formatAgoCompact, formatDateTimeAbsolute } from '../utils/dateTime' const STATUS_STYLE: Record = { completed: 'bg-emerald-100 text-emerald-800 border-emerald-200', running: 'bg-sky-100 text-sky-800 border-sky-200', failed: 'bg-red-100 text-red-800 border-red-200', cancelled: 'bg-amber-100 text-amber-800 border-amber-200', skipped: 'bg-slate-100 text-slate-500 border-slate-200', pending: 'bg-slate-100 text-slate-500 border-slate-200', } function StatusBadge({ status }: { status: string }) { const cls = STATUS_STYLE[status] ?? STATUS_STYLE.pending return ( {status === 'running' && ( )} {status} ) } function StepRow({ job, step }: { job: DeploymentJob; step: DeploymentStep }) { const [open, setOpen] = useState(false) const { data: log, isFetching } = useQuery({ queryKey: ['deployment-log', job.job_id, step.key], queryFn: () => fetchDeploymentLog(job.job_id, step.key), enabled: open, // Keep the log fresh while the step is actively running. refetchInterval: open && step.status === 'running' ? 3_000 : false, }) return (
{step.note && (

⚠️ {step.note}

)} {open && (
{step.cmd && (

$ {step.cmd}

)} {isFetching && !log && (

Loading log…

)} {log && log.lines.length > 0 ? (
              {log.lines.join('\n')}
            
) : ( !isFetching &&

No log output yet.

)}
)}
) } function stepSummary(job: DeploymentJob): string { const total = job.steps.length const done = job.steps.filter((s) => s.status === 'completed').length const failed = job.steps.filter((s) => s.status === 'failed').length const parts = [`${total} step${total === 1 ? '' : 's'}`] if (done) parts.push(`${done} done`) if (failed) parts.push(`${failed} failed`) return parts.join(' · ') } function JobCard({ job, onStop, clockMs, defaultOpen = false, }: { job: DeploymentJob onStop: (jobId: string) => void clockMs: number defaultOpen?: boolean }) { const [open, setOpen] = useState(defaultOpen) const when = job.started_at ? formatDateTimeAbsolute(job.started_at) : '—' const ago = job.started_at ? formatAgoCompact(job.started_at, clockMs) : null return (
{job.live && job.status === 'running' && ( )}
{open && (
{job.steps.map((s) => ( ))}
)}
) } export default function DeploymentPanel() { const { data, isPending, isError, error, refetch } = useQuery({ queryKey: ['deployments'], queryFn: fetchDeployments, refetchInterval: (q) => { const live = q.state.data?.jobs?.some((j) => j.live && j.status === 'running') return live ? 3_000 : 15_000 }, retry: 1, }) const availableSteps = data?.available_steps ?? [] const enabled = data?.enabled ?? false const [selected, setSelected] = useState>(new Set()) const [dryRun, setDryRun] = useState(true) const [launching, setLaunching] = useState(false) const [msg, setMsg] = useState(null) const [historyOpen, setHistoryOpen] = useState(false) // Default selection = every available step, once they load. const effectiveSelected = useMemo(() => { if (selected.size > 0) return selected return new Set(availableSteps.map((s) => s.key)) }, [selected, availableSteps]) const toggleStep = useCallback( (key: string) => { setSelected((prev) => { const base = prev.size > 0 ? new Set(prev) : new Set(availableSteps.map((s) => s.key)) if (base.has(key)) base.delete(key) else base.add(key) return base }) }, [availableSteps], ) const anyLive = data?.jobs?.some((j) => j.live && j.status === 'running') ?? false const onLaunch = useCallback(async () => { const steps = availableSteps.map((s) => s.key).filter((k) => effectiveSelected.has(k)) if (steps.length === 0) { setMsg('Select at least one step.') return } if (!dryRun) { const ok = window.confirm( `Run a REAL production deployment (${steps.join(', ')})?\n\n` + 'This pushes to the Neon prod database and/or HuggingFace. This cannot be undone.', ) if (!ok) return } setLaunching(true) setMsg(null) try { const res = await launchDeployment({ steps, dry_run: dryRun }) setMsg(res.detail || 'Launched.') window.setTimeout(() => void refetch(), 1_000) } catch (e) { setMsg((e as Error).message || 'Launch failed') } finally { setLaunching(false) } }, [availableSteps, effectiveSelected, dryRun, refetch]) const onStop = useCallback( async (jobId: string) => { if (!window.confirm(`Stop deployment ${jobId}? The running step is terminated.`)) return try { const res = await stopDeployment(jobId) setMsg(res.detail) void refetch() } catch (e) { setMsg((e as Error).message || 'Stop failed') } }, [refetch], ) const clockMs = Date.now() const jobs = data?.jobs ?? [] // Keep any in-flight deployment expanded and pinned to the top; everything // else is historical and tucked behind a collapsed "History" toggle. const activeJobs = jobs.filter((j) => j.live && j.status === 'running') const historyJobs = jobs.filter((j) => !(j.live && j.status === 'running')) return (
{/* Launch control */}

Launch a prod deployment

Runs the selected steps in order. A failing step stops the run.

{availableSteps.map((s) => { const on = effectiveSelected.has(s.key) return ( ) })} {availableSteps.length === 0 && ( No deployment steps available. )}
{anyLive && ( A deployment is already running — stop it before launching another. )}
{!dryRun && !enabled && (

Real deploys are disabled. Set DEPLOYMENTS_ALLOW_LAUNCH=1 on the API to enable, or keep Dry run on.

)} {msg &&

{msg}

}
{/* Recent jobs */} {isPending && !data && (
Loading deployments…
)} {isError && (
Could not load deployments: {(error as Error)?.message ?? 'error'}{' '}
)} {data && jobs.length === 0 && (
No deployments yet. Launch one above.
)} {/* In-flight deployment(s): always expanded, pinned to the top. */} {activeJobs.map((job) => ( ))} {/* Past runs: collapsed behind a single toggle so they don't dominate. */} {historyJobs.length > 0 && (
{historyOpen && historyJobs.map((job) => ( ))}
)}
) }