open-navigator / web_app /src /components /DeploymentPanel.tsx
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
14 kB
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<string, string> = {
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 (
<span
className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium ${cls}`}
>
{status === 'running' && (
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-sky-500" />
)}
{status}
</span>
)
}
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 (
<div className="rounded-md border border-slate-200">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left hover:bg-slate-50"
>
<span className="flex items-center gap-2">
<span className="text-slate-400">{open ? '▾' : '▸'}</span>
<span className="text-sm font-medium text-slate-800">{step.label}</span>
<span className="text-xs text-slate-500">{step.description}</span>
</span>
<span className="flex items-center gap-3">
{step.exit_code != null && step.status !== 'completed' && (
<span className="text-xs text-slate-500">exit {step.exit_code}</span>
)}
<StatusBadge status={step.status} />
</span>
</button>
{step.note && (
<p className="border-t border-amber-100 bg-amber-50 px-3 py-2 text-xs text-amber-800">
⚠️ {step.note}
</p>
)}
{open && (
<div className="border-t border-slate-100 bg-slate-50 px-3 py-2">
{step.cmd && (
<p className="mb-1 font-mono text-[11px] text-slate-500">$ {step.cmd}</p>
)}
{isFetching && !log && (
<p className="text-xs text-slate-400">Loading log…</p>
)}
{log && log.lines.length > 0 ? (
<pre className="max-h-72 overflow-auto whitespace-pre-wrap break-words rounded bg-slate-900 p-2 font-mono text-[11px] leading-relaxed text-slate-100">
{log.lines.join('\n')}
</pre>
) : (
!isFetching && <p className="text-xs text-slate-400">No log output yet.</p>
)}
</div>
)}
</div>
)
}
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 (
<div className="rounded-lg border border-slate-200 bg-white shadow-sm">
<div className="flex flex-wrap items-center justify-between gap-2 p-4">
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="flex flex-1 items-start gap-2 text-left"
>
<span className="mt-0.5 text-slate-400">{open ? '▾' : '▸'}</span>
<span>
<span className="flex flex-wrap items-center gap-2">
<span className="text-sm font-semibold text-slate-900">{job.label}</span>
{job.dry_run && (
<span className="rounded bg-indigo-100 px-1.5 py-0.5 text-[10px] font-medium text-indigo-700">
DRY RUN
</span>
)}
<StatusBadge status={job.status} />
{!open && (
<span className="text-[11px] text-slate-500">{stepSummary(job)}</span>
)}
</span>
<span className="mt-0.5 block font-mono text-[11px] text-slate-400">
{job.job_id} · started {when}
{ago ? ` (${ago})` : ''}
</span>
</span>
</button>
{job.live && job.status === 'running' && (
<button
type="button"
onClick={() => onStop(job.job_id)}
className="rounded-md border border-red-200 bg-red-50 px-3 py-1.5 text-xs font-medium text-red-700 hover:bg-red-100"
>
Stop
</button>
)}
</div>
{open && (
<div className="space-y-2 border-t border-slate-100 p-4">
{job.steps.map((s) => (
<StepRow key={s.key} job={job} step={s} />
))}
</div>
)}
</div>
)
}
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<Set<string>>(new Set())
const [dryRun, setDryRun] = useState(true)
const [launching, setLaunching] = useState(false)
const [msg, setMsg] = useState<string | null>(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 (
<div className="space-y-4">
{/* Launch control */}
<div className="rounded-lg border border-slate-200 bg-white p-4 shadow-sm">
<h2 className="text-sm font-semibold text-slate-900">Launch a prod deployment</h2>
<p className="mt-0.5 text-xs text-slate-500">
Runs the selected steps in order. A failing step stops the run.
</p>
<div className="mt-3 flex flex-wrap gap-2">
{availableSteps.map((s) => {
const on = effectiveSelected.has(s.key)
return (
<button
key={s.key}
type="button"
onClick={() => toggleStep(s.key)}
title={s.description}
className={`rounded-md border px-3 py-1.5 text-xs font-medium ${
on
? 'border-sky-300 bg-sky-50 text-sky-800'
: 'border-slate-200 bg-white text-slate-500'
}`}
>
<span className="mr-1">{on ? '✓' : '○'}</span>
{s.label}
</button>
)
})}
{availableSteps.length === 0 && (
<span className="text-xs text-slate-400">No deployment steps available.</span>
)}
</div>
<div className="mt-3 flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-xs text-slate-700">
<input
type="checkbox"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
className="h-3.5 w-3.5"
/>
Dry run (log commands, don&apos;t execute)
</label>
<button
type="button"
onClick={onLaunch}
disabled={launching || (!dryRun && !enabled) || anyLive}
className={`rounded-md px-4 py-1.5 text-xs font-semibold text-white disabled:cursor-not-allowed disabled:opacity-50 ${
dryRun ? 'bg-indigo-600 hover:bg-indigo-700' : 'bg-red-600 hover:bg-red-700'
}`}
>
{launching
? 'Launching…'
: dryRun
? '▶ Launch dry run'
: '▶ Deploy to PRODUCTION'}
</button>
{anyLive && (
<span className="text-xs text-amber-600">
A deployment is already running — stop it before launching another.
</span>
)}
</div>
{!dryRun && !enabled && (
<p className="mt-2 text-xs text-amber-700">
Real deploys are disabled. Set <code>DEPLOYMENTS_ALLOW_LAUNCH=1</code> on the API to
enable, or keep Dry run on.
</p>
)}
{msg && <p className="mt-2 text-xs text-slate-600">{msg}</p>}
</div>
{/* Recent jobs */}
{isPending && !data && (
<div className="rounded-lg border border-slate-200 bg-white p-8 text-center text-sm text-slate-500">
Loading deployments…
</div>
)}
{isError && (
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-800">
Could not load deployments: {(error as Error)?.message ?? 'error'}{' '}
<button type="button" className="underline" onClick={() => refetch()}>
Retry
</button>
</div>
)}
{data && jobs.length === 0 && (
<div className="rounded-lg border border-dashed border-slate-200 bg-white p-8 text-center text-sm text-slate-500">
No deployments yet. Launch one above.
</div>
)}
{/* In-flight deployment(s): always expanded, pinned to the top. */}
{activeJobs.map((job) => (
<JobCard key={job.job_id} job={job} onStop={onStop} clockMs={clockMs} defaultOpen />
))}
{/* Past runs: collapsed behind a single toggle so they don't dominate. */}
{historyJobs.length > 0 && (
<div className="space-y-2">
<button
type="button"
onClick={() => setHistoryOpen((v) => !v)}
className="flex w-full items-center gap-2 rounded-lg border border-slate-200 bg-white px-4 py-2.5 text-left text-sm font-medium text-slate-700 shadow-sm hover:bg-slate-50"
>
<span className="text-slate-400">{historyOpen ? '▾' : '▸'}</span>
History
<span className="rounded-full bg-slate-100 px-2 py-0.5 text-[11px] font-medium text-slate-500">
{historyJobs.length}
</span>
{!historyOpen && (
<span className="ml-auto text-[11px] font-normal text-slate-400">
{historyJobs[0]?.status} ·{' '}
{historyJobs[0]?.started_at
? formatAgoCompact(historyJobs[0].started_at, clockMs)
: '—'}
</span>
)}
</button>
{historyOpen &&
historyJobs.map((job) => (
<JobCard key={job.job_id} job={job} onStop={onStop} clockMs={clockMs} />
))}
</div>
)}
</div>
)
}