Initial push

This commit is contained in:
2026-08-11 09:00:01 +02:00
commit fe8b354adc
54 changed files with 12640 additions and 0 deletions
+189
View File
@@ -0,0 +1,189 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { api } from './api'
import type { JobSnapshot } from './types'
import { duration, humanBytes, Notice, Progress, StateDot } from './ui'
export function Jobs({
jobs, activeJob, setActiveJob, reload, reloadPackages,
}: {
jobs: JobSnapshot[]
activeJob: string
setActiveJob: (id: string) => void
reload: () => void
reloadPackages: () => void
}) {
const selected = activeJob || jobs[0]?.id || ''
if (jobs.length === 0) {
return <div className="empty">no migrations yet select containers and start one</div>
}
return (
<div style={{ display: 'flex', minHeight: 0, height: '100%' }}>
<div className="joblist" style={{ width: 320, flex: '0 0 320px', overflow: 'auto' }}>
{jobs.map((j) => (
<div
key={j.id}
className={`jobcard${j.id === selected ? ' active' : ''}`}
onClick={() => setActiveJob(j.id)}
>
<div className="row">
<StateDot state={j.state} />
<span className="spacer" />
<span className="small faint">{j.kind === 'ssh' ? 'ssh' : 'package'}</span>
</div>
<div className="truncate" style={{ marginTop: 2 }}>{j.title}</div>
<div className="small faint">
{new Date(j.createdAt).toLocaleTimeString()} · {duration(j.startedAt, j.endedAt)}
{j.dryRun && ' · dry run'}
</div>
<div style={{ marginTop: 6 }}>
<Progress done={j.bytesDone} total={j.bytesTotal} state={j.state} />
</div>
</div>
))}
</div>
<div style={{ flex: 1, minWidth: 0, overflow: 'auto', borderLeft: '1px solid var(--border)' }}>
{selected && <JobDetail id={selected} reload={reload} reloadPackages={reloadPackages} />}
</div>
</div>
)
}
function JobDetail({
id, reload, reloadPackages,
}: {
id: string
reload: () => void
reloadPackages: () => void
}) {
const [job, setJob] = useState<JobSnapshot | null>(null)
const [showCmds, setShowCmds] = useState(true)
const logRef = useRef<HTMLDivElement>(null)
const stick = useRef(true)
// Each job detail holds one server-sent events stream, which pushes a full
// snapshot whenever anything changes.
useEffect(() => {
setJob(null)
let closed = false
api.job(id).then((j) => !closed && setJob(j)).catch(() => undefined)
const es = api.jobEvents(id)
es.onmessage = (ev) => {
try {
setJob(JSON.parse(ev.data) as JobSnapshot)
} catch { /* a truncated frame is replaced by the next one */ }
}
es.addEventListener('done', () => {
es.close()
reload()
reloadPackages()
})
es.onerror = () => es.close()
return () => {
closed = true
es.close()
}
}, [id, reload, reloadPackages])
const lines = useMemo(
() => (job?.log ?? []).filter((l) => showCmds || l.level !== 'cmd'),
[job, showCmds],
)
useEffect(() => {
const el = logRef.current
if (el && stick.current) el.scrollTop = el.scrollHeight
}, [lines])
if (!job) return <div className="empty">loading</div>
const running = job.state === 'running' || job.state === 'pending'
return (
<div style={{ padding: 16, display: 'flex', flexDirection: 'column', gap: 14 }}>
<div className="row">
<StateDot state={job.state} />
<b>{job.title}</b>
{job.dryRun && <span className="badge">dry run</span>}
<span className="spacer" />
<span className="small faint">
{humanBytes(job.bytesDone)}
{job.bytesTotal > 0 && <> of {humanBytes(job.bytesTotal)}</>} · {duration(job.startedAt, job.endedAt)}
</span>
{running ? (
<button className="btn tiny danger" onClick={() => api.cancelJob(job.id).then(reload)}>cancel</button>
) : (
<button className="btn tiny ghost" onClick={() => api.deleteJob(job.id).then(reload)}>remove</button>
)}
</div>
<Progress done={job.bytesDone} total={job.bytesTotal} state={job.state} />
{job.error && <Notice kind="err">{job.error}</Notice>}
{job.state === 'succeeded' && job.artifact && (
<Notice kind="ok">
Package ready at <span className="mono">{job.artifact}</span> ({humanBytes(job.artifactBytes ?? 0)}).
{' '}Open the Packages tab to download it.
</Notice>
)}
{job.items.map((it) => (
<div key={it.id} style={{ border: '1px solid var(--border)', borderRadius: 6, padding: '8px 10px' }}>
<div className="row">
<StateDot state={it.state} />
<b>{it.name}</b>
<span className="spacer" />
<span className="small faint">{it.steps.filter((s) => s.state === 'succeeded').length}/{it.steps.length} steps</span>
</div>
{it.error && <div className="small" style={{ color: 'var(--err)' }}>{it.error}</div>}
{(it.warnings ?? []).map((w, i) => (
<div key={i} className="small" style={{ color: 'var(--warn)' }}>! {w}</div>
))}
<div className="steps">
{it.steps.map((s) => (
<div key={s.id} className={`step ${s.state}`}>
<StateDot state={s.state} label="" />
<span className="label truncate" title={s.error || s.label}>{s.label}</span>
<span>
{s.bytesTotal > 0 || s.bytesDone > 0 ? (
<Progress done={s.bytesDone} total={s.bytesTotal} state={s.state} />
) : null}
</span>
<span className="faint nowrap" style={{ textAlign: 'right' }}>
{s.bytesDone > 0 ? humanBytes(s.bytesDone) : s.state === 'skipped' ? 'skipped' : ''}
</span>
</div>
))}
</div>
</div>
))}
<div className="row">
<h3 style={{ margin: 0, fontSize: 12 }}>log</h3>
<span className="spacer" />
<label className="check small">
<input type="checkbox" checked={showCmds} onChange={(e) => setShowCmds(e.target.checked)} />
<span>show commands</span>
</label>
</div>
<div
className="log"
ref={logRef}
onScroll={(e) => {
const el = e.currentTarget
stick.current = el.scrollHeight - el.scrollTop - el.clientHeight < 24
}}
>
{lines.map((l) => (
<div key={l.seq} className={`l-${l.level}`}>
<span className="ts">{new Date(l.at).toLocaleTimeString()} </span>
{l.message}
</div>
))}
{lines.length === 0 && <span className="faint">nothing logged yet</span>}
</div>
</div>
)
}