Initial push
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user