Files
peardock/client/jobs.js
T
2026-07-16 04:20:26 -04:00

1270 lines
42 KiB
JavaScript

/**
* Universal long-running job runner (deploy / pull / build steppers).
*/
/**
* @typedef {{ id: string, label: string, status: 'pending'|'active'|'success'|'error'|'skipped', error?: string, detail?: string }} JobStep
* @typedef {{ id: string, kind: string, peerId?: string, steps: JobStep[], log: Array<{t:number,line:string,level?:string}>, result?: object, status: string, progress?: object|null, createdAt?: number, icon?: string|null, subtitle?: string|null }} Job
*/
let seq = 0
/** @type {Map<string, Job>} */
const jobs = new Map()
/** @type {Set<(job: Job) => void>} */
const listeners = new Set()
/** Coalesce high-frequency progress/log emits into one paint per frame per job */
/** @type {Map<string, Job>} */
const pendingEmits = new Map()
/** @type {number|null} */
let emitRaf = null
export function subscribeJobs(fn) {
listeners.add(fn)
return () => listeners.delete(fn)
}
/**
* Notify listeners. Progress ticks are rAF-coalesced; terminal states flush now.
* @param {Job} job
* @param {{ immediate?: boolean }} [opts]
*/
function emit(job, opts = {}) {
const immediate =
opts.immediate === true || job.status === 'success' || job.status === 'error'
if (immediate) {
pendingEmits.delete(job.id)
flushEmit(job)
return
}
pendingEmits.set(job.id, job)
if (emitRaf != null) return
emitRaf =
typeof requestAnimationFrame === 'function'
? requestAnimationFrame(flushPendingEmits)
: /** @type {any} */ (setTimeout(flushPendingEmits, 16))
}
function flushPendingEmits() {
emitRaf = null
const batch = [...pendingEmits.values()]
pendingEmits.clear()
for (const job of batch) {
// Skip if a later immediate emit already delivered a terminal state
const live = jobs.get(job.id)
flushEmit(live || job)
}
}
/**
* @param {Job} job
*/
function flushEmit(job) {
for (const fn of listeners) {
try {
fn(job)
} catch {
// ignore
}
}
}
/**
* @param {string} kind
* @param {Array<string|{id:string,label:string}>} stepDefs
* @param {object} [meta]
*/
export function createJob(kind, stepDefs, meta = {}) {
const id = `job-${Date.now()}-${++seq}`
const steps = stepDefs.map((s, i) =>
typeof s === 'string'
? { id: `s${i}`, label: s, status: 'pending' }
: { id: s.id || `s${i}`, label: s.label, status: 'pending' }
)
/** @type {Job} */
const job = {
id,
kind,
peerId: meta.peerId || null,
steps,
log: [],
status: 'running',
result: null,
progress: null,
createdAt: Date.now(),
icon: meta.icon ? String(meta.icon) : null,
subtitle: meta.subtitle != null ? String(meta.subtitle) : null,
}
jobs.set(id, job)
emit(job)
return job
}
/**
* Attach structured progress (e.g. hybrid image-pull card) to a job.
* @param {string} jobId
* @param {object|null} progress
* @param {{ stepId?: string, detail?: string, emit?: boolean }} [opts]
*/
export function setJobProgress(jobId, progress, opts = {}) {
const job = jobs.get(jobId)
if (!job) return
job.progress = progress || null
if (opts.stepId && opts.detail != null) {
const step = job.steps.find((s) => s.id === opts.stepId || s.label === opts.stepId)
if (step) step.detail = opts.detail
}
if (opts.emit === false) return
emit(job)
}
export function getJob(id) {
return jobs.get(id) || null
}
export function listJobs(limit = 20) {
return [...jobs.values()].sort((a, b) => b.createdAt - a.createdAt).slice(0, limit)
}
/**
* @param {string} jobId
* @param {string} stepId
* @param {'pending'|'active'|'success'|'error'|'skipped'} status
* @param {{ error?: string, detail?: string }} [extra]
*/
export function setStep(jobId, stepId, status, extra = {}) {
const job = jobs.get(jobId)
if (!job) return
const step = job.steps.find((s) => s.id === stepId || s.label === stepId)
if (!step) return
// Allow detail-only refresh without forcing status change
if (status != null && status !== '') step.status = status
if (extra.error) step.error = extra.error
if (extra.detail !== undefined) step.detail = extra.detail
if (step.status === 'error') job.status = 'error'
emit(job)
}
/**
* @param {string} jobId
* @param {string} line
* @param {string} [level='info']
* @param {{ emit?: boolean }} [opts]
*/
export function appendJobLog(jobId, line, level = 'info', opts = {}) {
const job = jobs.get(jobId)
if (!job) return
job.log.push({ t: Date.now(), line: String(line), level })
if (job.log.length > 2000) job.log.splice(0, job.log.length - 2000)
if (opts.emit === false) return
emit(job)
}
export function completeJob(jobId, result = {}) {
const job = jobs.get(jobId)
if (!job) return
for (const s of job.steps) {
if (s.status === 'pending' || s.status === 'active') s.status = 'skipped'
}
job.status = job.status === 'error' ? 'error' : 'success'
job.result = result
emit(job, { immediate: true })
// Notify feedback coordinator (uiUtils) when available — avoid duplicate toasts
try {
const msg =
result?.message ||
(job.status === 'success' ? `${job.kind} complete` : `${job.kind} failed`)
const type = job.status === 'success' ? 'success' : 'danger'
if (typeof globalThis !== 'undefined' && globalThis.__peardockMarkFeedback) {
globalThis.__peardockMarkFeedback(type, msg)
}
} catch {
// ignore
}
}
/**
* Lightweight single-step activity job (replaces full-screen spinners).
* Reuses one job id while running so updates don't stack panels.
*/
let activityJobId = null
/**
* @param {string} message
* @returns {Job}
*/
export function showActivity(message) {
const label = String(message || 'Working…')
if (activityJobId && jobs.has(activityJobId)) {
const job = jobs.get(activityJobId)
if (job.status === 'running') {
job.kind = label
if (job.steps[0]) {
job.steps[0].label = label
job.steps[0].status = 'active'
}
job.log.push({ t: Date.now(), line: label, level: 'info' })
if (job.log.length > 2000) job.log.splice(0, job.log.length - 2000)
emit(job)
return job
}
}
const job = createJob(label, [{ id: 'work', label }])
activityJobId = job.id
setStep(job.id, 'work', 'active')
appendJobLog(job.id, label)
return job
}
/**
* @param {string} message
*/
export function updateActivity(message) {
return showActivity(message)
}
/**
* @param {boolean} [ok=true]
* @param {string} [message] final status line shown in the job log
*/
export function completeActivity(ok = true, message) {
if (!activityJobId || !jobs.has(activityJobId)) {
activityJobId = null
return null
}
const id = activityJobId
const job = jobs.get(id)
const stepId = job.steps[0]?.id || 'work'
const finalMsg = message != null && String(message).trim() ? String(message).trim() : null
if (ok) {
if (finalMsg) {
if (job.steps[0]) job.steps[0].label = finalMsg
appendJobLog(id, finalMsg, 'info')
}
setStep(id, stepId, 'success')
completeJob(id, { ok: true, message: finalMsg || undefined })
} else {
const errText = finalMsg || 'Failed'
setStep(id, stepId, 'error', { error: errText })
appendJobLog(id, errText, 'error')
completeJob(id, { ok: false, message: errText })
}
const done = jobs.get(id)
activityJobId = null
return done
}
export function getActivityJobId() {
return activityJobId
}
/**
* Run sequential async steps.
* @param {string} kind
* @param {Array<{ id?: string, label: string, run: (ctx: { job: Job, log: Function }) => Promise<any> }>} steps
*/
export async function runJob(kind, steps, meta = {}) {
const job = createJob(
kind,
steps.map((s) => ({ id: s.id, label: s.label })),
meta
)
const log = (line, level) => appendJobLog(job.id, line, level)
try {
for (const step of steps) {
const id = step.id || step.label
setStep(job.id, id, 'active', { detail: '' })
try {
const result = await step.run({ job, log })
setStep(job.id, id, 'success')
// Allow steps to stash result on the job
if (result != null && typeof result === 'object') {
job.result = { ...(job.result || {}), ...result, ok: true }
}
} catch (err) {
const { summary, lines } = formatJobError(err, kind)
setStep(job.id, id, 'error', { error: summary })
for (const line of lines) {
log(line, 'error')
}
const enriched = err instanceof Error ? err : new Error(summary)
enriched.viaJob = true
enriched.jobId = job.id
throw enriched
}
}
completeJob(job.id, { ok: true, ...(job.result || {}) })
job.viaJob = true
return job
} catch (err) {
job.status = 'error'
job.viaJob = true
emit(job, { immediate: true })
if (err && typeof err === 'object') {
err.viaJob = true
err.jobId = job.id
}
throw err
}
}
/**
* Expand errors into multi-line job log output (title, detail, how-to-fix).
* @param {unknown} err
* @param {string} [kind]
* @returns {{ summary: string, lines: string[] }}
*/
function formatJobError(err, kind) {
const raw = String(err?.message || err || 'Unknown error')
const lines = []
// "Title — detail — How to fix: … — (Docker HTTP 409)"
if (/how to fix:/i.test(raw) || raw.includes(' — ')) {
const chunks = raw
.split(/\s*—\s*/)
.map((s) => s.trim())
.filter(Boolean)
for (const c of chunks) {
if (/^how to fix:/i.test(c)) {
lines.push(`→ ${c}`)
} else if (/^\(Docker HTTP/i.test(c)) {
lines.push(c)
} else {
lines.push(c)
}
}
} else {
lines.push(raw)
}
// Deduplicate consecutive identical lines
const deduped = []
for (const line of lines) {
if (deduped[deduped.length - 1] !== line) deduped.push(line)
}
const summary = deduped[0] || raw
if (kind && !/deploy|failed|error/i.test(summary)) {
deduped.unshift(`${kind} failed`)
}
return { summary: summary.slice(0, 280), lines: deduped }
}
/**
* True if the log element is scrolled near the bottom (user is "following").
* @param {HTMLElement|null} el
* @param {number} [thresholdPx=48]
*/
function isLogFollowing(el, thresholdPx = 48) {
if (!el) return true
return el.scrollHeight - el.scrollTop - el.clientHeight <= thresholdPx
}
/**
* Scroll job log to latest line (follow output).
* @param {HTMLElement|null} logEl
*/
export function scrollJobLogToBottom(logEl) {
if (!logEl) return
// Single rAF is enough when we patch in place (no full innerHTML wipe)
requestAnimationFrame(() => {
logEl.scrollTop = logEl.scrollHeight
})
}
/**
* @param {string} status
*/
function stepIconHtml(status) {
if (status === 'active') {
return '<span class="job-spinner" role="status" aria-label="In progress"><span class="job-spinner-ring"></span></span>'
}
const icon =
status === 'success'
? 'fa-check-circle text-success'
: status === 'error'
? 'fa-times-circle text-danger'
: status === 'skipped'
? 'fa-minus-circle text-muted'
: 'fa-circle text-muted'
return `<i class="fas ${icon}" aria-hidden="true"></i>`
}
/**
* @param {JobStep|object} s
*/
function stepRowHtml(s) {
return `<div class="job-step job-step--${escapeHtml(s.status)}" data-step-id="${escapeHtml(s.id || '')}" data-step-status="${escapeHtml(s.status)}">
<span class="job-step-icon">${stepIconHtml(s.status)}</span>
<span class="job-step-label">${escapeHtml(s.label)}</span>
${s.error ? `<span class="job-step-error">${escapeHtml(s.error)}</span>` : ''}
${s.detail ? `<span class="job-step-detail text-muted">${escapeHtml(s.detail)}</span>` : ''}
</div>`
}
/**
* @param {{ t?: number, line: string, level?: string }} l
*/
function logLineHtml(l) {
const ts = l.t ? new Date(l.t).toLocaleTimeString() : ''
return `<div class="job-log-line job-log--${escapeHtml(l.level || 'info')}" data-t="${l.t || ''}">${
ts ? `<span class="job-log-ts">${escapeHtml(ts)}</span> ` : ''
}${escapeHtml(l.line)}</div>`
}
/**
* @param {string} status
*/
function statusBadgeClass(status) {
return status === 'success' ? 'success' : status === 'error' ? 'danger' : 'primary'
}
/**
* Bind log follow UX once per panel (survives incremental patches).
* @param {HTMLElement} host
* @param {HTMLElement|null} logEl
*/
function bindLogFollow(host, logEl) {
if (!logEl || logEl.dataset.followBound) return
logEl.dataset.followBound = '1'
logEl.addEventListener('scroll', () => {
const following = isLogFollowing(logEl)
logEl.dataset.following = following ? '1' : '0'
const hint = host.querySelector('.job-log-follow-hint')
if (hint) {
hint.classList.toggle('is-paused', !following)
hint.innerHTML = following
? '<i class="fas fa-angles-down"></i> Following'
: '<i class="fas fa-pause"></i> Paused — scroll to bottom'
}
})
// Delegate follow-hint click from host so it works after re-created hints
if (!host.dataset.followClickBound) {
host.dataset.followClickBound = '1'
host.addEventListener('click', (e) => {
const hint = e.target?.closest?.('.job-log-follow-hint')
if (!hint || !host.contains(hint)) return
e.preventDefault()
e.stopPropagation()
const log = host.querySelector('.job-log')
if (!log) return
log.dataset.following = '1'
scrollJobLogToBottom(log)
hint.classList.remove('is-paused')
hint.innerHTML = '<i class="fas fa-angles-down"></i> Following'
})
}
}
/**
* Patch step rows in place — only replace icons when status changes (keeps spinner spinning).
* @param {HTMLElement} stepsEl
* @param {Job} job
*/
function patchJobSteps(stepsEl, job) {
const existing = [...stepsEl.querySelectorAll('.job-step')]
const byId = new Map(existing.map((el) => [el.getAttribute('data-step-id') || '', el]))
// Rebuild only if step count / order / ids changed
const ids = job.steps.map((s) => s.id || '')
const sameStructure =
existing.length === ids.length &&
existing.every((el, i) => (el.getAttribute('data-step-id') || '') === ids[i])
if (!sameStructure) {
stepsEl.innerHTML = job.steps.map(stepRowHtml).join('')
return
}
for (const s of job.steps) {
const el = byId.get(s.id || '')
if (!el) continue
const prevStatus = el.getAttribute('data-step-status')
if (prevStatus !== s.status) {
el.className = `job-step job-step--${s.status}`
el.setAttribute('data-step-status', s.status)
const icon = el.querySelector('.job-step-icon')
if (icon) icon.innerHTML = stepIconHtml(s.status)
}
const label = el.querySelector('.job-step-label')
if (label && label.textContent !== s.label) label.textContent = s.label
let errEl = el.querySelector('.job-step-error')
if (s.error) {
if (!errEl) {
errEl = document.createElement('span')
errEl.className = 'job-step-error'
el.appendChild(errEl)
}
if (errEl.textContent !== s.error) errEl.textContent = s.error
} else if (errEl) {
errEl.remove()
}
let detailEl = el.querySelector('.job-step-detail')
if (s.detail) {
if (!detailEl) {
detailEl = document.createElement('span')
detailEl.className = 'job-step-detail text-muted'
el.appendChild(detailEl)
}
if (detailEl.textContent !== s.detail) detailEl.textContent = s.detail
} else if (detailEl) {
detailEl.remove()
}
}
}
/**
* Append-only log updates. Avoids wiping scroll position / reflowing every tick.
* @param {HTMLElement} logEl
* @param {Job} job
* @returns {boolean} whether any lines were appended
*/
function patchJobLog(logEl, job) {
const rendered = Number(logEl.dataset.logLen || 0)
const total = job.log.length
const tail = job.log.slice(-200)
// Truncation or first paint / reset
if (rendered === 0 || total < rendered || logEl.querySelector('.job-log-empty')) {
logEl.innerHTML = tail.length
? tail.map(logLineHtml).join('')
: '<div class="text-muted job-log-empty">No output yet</div>'
logEl.dataset.logLen = String(total)
return true
}
if (total === rendered) return false
// Append only new lines (respect 200-line visible tail)
const start = Math.max(rendered, Math.max(0, total - 200))
const empty = logEl.querySelector('.job-log-empty')
if (empty) empty.remove()
const frag = document.createDocumentFragment()
const wrap = document.createElement('div')
wrap.innerHTML = job.log.slice(start).map(logLineHtml).join('')
while (wrap.firstChild) frag.appendChild(wrap.firstChild)
logEl.appendChild(frag)
// Drop overflow above the 200-line window
const maxKids = 200
while (logEl.children.length > maxKids) {
logEl.removeChild(logEl.firstChild)
}
logEl.dataset.logLen = String(total)
return true
}
/**
* @param {object|null|undefined} progress
*/
function progressMetrics(progress) {
const pct =
progress?.percent != null && Number.isFinite(Number(progress.percent))
? Math.max(0, Math.min(100, Math.round(Number(progress.percent))))
: null
const barWidth = pct != null ? pct : progress?.phase === 'resolving' ? 8 : 0
const indeterminate =
pct == null && progress?.phase !== 'complete' && progress?.phase !== 'error'
return { pct, barWidth, indeterminate, pctText: pct != null ? `${pct}%` : '…' }
}
/**
* Fixed-height activity line text (never empty → no tray vertical bounce).
* @param {object} progress
*/
function progressActivityText(progress) {
const t = progress?.activity || progress?.summary || ''
return String(t).trim() || 'Working…'
}
/** Must match PullProgressTracker MAX_ACTIVE_LAYERS — fixed tray slots. */
const PULL_LAYER_SLOTS = 4
/**
* @param {object|null|undefined} progress
* @returns {object[]}
*/
function progressActiveLayers(progress) {
const raw = Array.isArray(progress?.activeLayers) ? progress.activeLayers : []
return raw.slice(0, PULL_LAYER_SLOTS)
}
/**
* @param {object|null|undefined} layer
*/
function layerBarState(layer) {
const phase = layer?.phase
// Waiting / unknown download size → indeterminate shimmer (keeps the row visible as “Pulling…”)
if (phase === 'waiting' || phase === 'downloading' || phase === 'extracting') {
if (layer?.percent != null && Number.isFinite(Number(layer.percent))) {
const lp = Math.max(0, Math.min(100, Math.round(Number(layer.percent))))
return { lp, fill: lp, indet: false }
}
return { lp: null, fill: 40, indet: true }
}
const lp =
layer?.percent != null && Number.isFinite(Number(layer.percent))
? Math.max(0, Math.min(100, Math.round(Number(layer.percent))))
: 0
return {
lp,
fill: lp != null ? lp : 0,
indet: false,
}
}
/**
* One fixed layer row (or empty placeholder slot).
* @param {object|null|undefined} layer
* @param {number} slot
*/
function layerRowHtml(layer, slot) {
if (!layer) {
return `<div class="pull-layer pull-layer--empty" data-layer-slot="${slot}" aria-hidden="true">
<span class="pull-layer-id">—</span>
<div class="pull-layer-bar"><div class="pull-layer-bar-fill" style="width:0%"></div></div>
<span class="pull-layer-meta"></span>
</div>`
}
const { lp, fill, indet } = layerBarState(layer)
const phase = escapeHtml(layer.phase || 'waiting')
const id = escapeHtml(layer.id || '')
const shortId = escapeHtml(layer.shortId || layer.id || '')
const meta = escapeHtml(layer.meta || '')
return `<div class="pull-layer pull-layer--${phase}" data-layer-slot="${slot}" data-layer-id="${id}">
<span class="pull-layer-id" title="${id}">${shortId}</span>
<div class="pull-layer-bar${indet ? ' pull-layer-bar--indet' : ''}" role="progressbar" aria-valuenow="${lp ?? 0}" aria-valuemin="0" aria-valuemax="100">
<div class="pull-layer-bar-fill" style="width:${fill}%"></div>
</div>
<span class="pull-layer-meta">${meta}</span>
</div>`
}
/**
* Always exactly PULL_LAYER_SLOTS rows so tray height never oscillates.
* @param {object[]} layers
*/
function layerSlotsHtml(layers) {
const rows = []
for (let i = 0; i < PULL_LAYER_SLOTS; i++) {
rows.push(layerRowHtml(layers[i] || null, i))
}
return rows.join('')
}
/**
* Fixed-height waiting line text (empty string still reserves CSS height).
* @param {object|null|undefined} progress
*/
function progressWaitingText(progress) {
const n = Number(progress?.layersWaiting) || 0
if (n <= 0 || progress?.phase === 'complete' || progress?.phase === 'error') {
return ''
}
return `${n} layer${n === 1 ? '' : 's'} waiting`
}
/**
* @param {object|null|undefined} progress
* @returns {string[]}
*/
function progressPips(progress) {
const raw = progress?.layerPips
if (Array.isArray(raw) && raw.length > 0) return raw.map(String)
// Fallback skeleton so the track never collapses
return ['idle', 'idle', 'idle', 'idle']
}
/**
* Patch fixed-height layer pip track in place (no row add/remove → no height flicker).
* @param {HTMLElement} card
* @param {string[]} pips
*/
function patchLayerPips(card, pips) {
let track = card.querySelector('.pull-progress-track')
if (!track) {
track = document.createElement('div')
track.className = 'pull-progress-track'
track.setAttribute('aria-hidden', 'true')
const layers = card.querySelector('.pull-progress-layers')
const activity = card.querySelector('.pull-progress-activity')
if (layers) card.insertBefore(track, layers)
else if (activity) card.insertBefore(track, activity)
else card.appendChild(track)
}
const key = pips.join(',')
if (track.dataset.pipsKey === key) return
track.dataset.pipsKey = key
const kids = track.children
// Reuse existing pip nodes when count matches (avoids layout thrash)
if (kids.length === pips.length) {
for (let i = 0; i < pips.length; i++) {
const el = kids[i]
const nextClass = `pull-layer-pip pull-layer-pip--${pips[i]}`
if (el.className !== nextClass) el.className = nextClass
}
return
}
track.innerHTML = pips.map((p) => `<span class="pull-layer-pip pull-layer-pip--${escapeHtml(p)}"></span>`).join('')
}
/**
* Patch fixed-slot active layer rows in place (ids/bars/meta update; slot count never changes).
* @param {HTMLElement} card
* @param {object[]} layers
*/
function patchActiveLayers(card, layers) {
let layersEl = card.querySelector('.pull-progress-layers')
if (!layersEl) {
layersEl = document.createElement('div')
layersEl.className = 'pull-progress-layers'
layersEl.setAttribute('aria-label', 'Active layers')
const waiting = card.querySelector('.pull-progress-waiting')
const activity = card.querySelector('.pull-progress-activity')
if (waiting) card.insertBefore(layersEl, waiting)
else if (activity) card.insertBefore(layersEl, activity)
else card.appendChild(layersEl)
layersEl.innerHTML = layerSlotsHtml(layers)
return
}
// Ensure fixed slot count (rebuild only if structure broke)
let rows = [...layersEl.querySelectorAll(':scope > .pull-layer')]
if (rows.length !== PULL_LAYER_SLOTS) {
layersEl.innerHTML = layerSlotsHtml(layers)
return
}
for (let i = 0; i < PULL_LAYER_SLOTS; i++) {
const layer = layers[i] || null
const row = rows[i]
if (!layer) {
if (!row.classList.contains('pull-layer--empty')) {
row.className = 'pull-layer pull-layer--empty'
row.setAttribute('data-layer-slot', String(i))
row.removeAttribute('data-layer-id')
row.setAttribute('aria-hidden', 'true')
row.innerHTML = `
<span class="pull-layer-id">—</span>
<div class="pull-layer-bar"><div class="pull-layer-bar-fill" style="width:0%"></div></div>
<span class="pull-layer-meta"></span>`
}
continue
}
const { lp, fill, indet } = layerBarState(layer)
const phase = layer.phase || 'waiting'
const id = layer.id || ''
const shortId = layer.shortId || layer.id || ''
const meta = layer.meta || ''
const nextClass = `pull-layer pull-layer--${phase}`
if (row.className !== nextClass) row.className = nextClass
row.setAttribute('data-layer-slot', String(i))
row.setAttribute('data-layer-id', id)
row.removeAttribute('aria-hidden')
let idEl = row.querySelector('.pull-layer-id')
let bar = row.querySelector('.pull-layer-bar')
let barFill = row.querySelector('.pull-layer-bar-fill')
let metaEl = row.querySelector('.pull-layer-meta')
// Recreate missing pieces if structure was corrupted
if (!idEl || !bar || !barFill || !metaEl) {
row.innerHTML = `
<span class="pull-layer-id" title="${escapeHtml(id)}">${escapeHtml(shortId)}</span>
<div class="pull-layer-bar${indet ? ' pull-layer-bar--indet' : ''}" role="progressbar" aria-valuenow="${lp ?? 0}" aria-valuemin="0" aria-valuemax="100">
<div class="pull-layer-bar-fill" style="width:${fill}%"></div>
</div>
<span class="pull-layer-meta">${escapeHtml(meta)}</span>`
continue
}
if (idEl.textContent !== shortId) idEl.textContent = shortId
if (idEl.getAttribute('title') !== id) idEl.setAttribute('title', id)
bar.classList.toggle('pull-layer-bar--indet', indet)
bar.setAttribute('aria-valuenow', String(lp ?? 0))
if (!indet) {
const next = `${fill}%`
if (barFill.style.width !== next) barFill.style.width = next
}
if (metaEl.textContent !== meta) metaEl.textContent = meta
}
}
/**
* @param {HTMLElement} card
* @param {string} waitingText
*/
function patchWaitingLine(card, waitingText) {
let waitingEl = card.querySelector('.pull-progress-waiting')
if (!waitingEl) {
waitingEl = document.createElement('div')
waitingEl.className = 'pull-progress-waiting'
const activity = card.querySelector('.pull-progress-activity')
if (activity) card.insertBefore(waitingEl, activity)
else card.appendChild(waitingEl)
}
const next = waitingText || '\u00a0' // nbsp keeps line height when empty
if (waitingEl.textContent !== next) {
waitingEl.textContent = next
}
waitingEl.classList.toggle('pull-progress-waiting--empty', !waitingText)
}
/**
* Update hybrid pull/push card in place.
* Structure is fixed: summary + bar + pips + layer slots + waiting + activity.
* @param {HTMLElement} host
* @param {HTMLElement} panel
* @param {object|null|undefined} progress
*/
function patchJobProgress(host, panel, progress) {
const stepsEl = panel.querySelector('.job-steps')
let card = panel.querySelector('.pull-progress')
if (!progress || (progress.kind !== 'image-pull' && progress.kind !== 'image-push')) {
if (card) card.remove()
return
}
if (!card) {
const html = renderJobProgress(progress)
if (!html || !stepsEl) return
stepsEl.insertAdjacentHTML('afterend', html)
return
}
const phase = progress.phase || 'resolving'
const { pct, barWidth, indeterminate, pctText } = progressMetrics(progress)
const verb = progress.kind === 'image-push' ? 'Push' : 'Pull'
const phaseLabel = progress.phaseLabel || 'Pulling'
const summary = progress.summary || ''
const activity = progressActivityText(progress)
if (card.getAttribute('data-pull-phase') !== phase) {
card.className = `pull-progress pull-progress--${phase}`
card.setAttribute('data-pull-phase', phase)
}
card.setAttribute('aria-label', `${verb} progress`)
const phaseEl = card.querySelector('.pull-progress-phase')
if (phaseEl && phaseEl.textContent !== phaseLabel) phaseEl.textContent = phaseLabel
const metaEl = card.querySelector('.pull-progress-meta')
if (metaEl && metaEl.textContent !== summary) metaEl.textContent = summary
const pctEl = card.querySelector('.pull-progress-pct')
if (pctEl && pctEl.textContent !== pctText) pctEl.textContent = pctText
const bar = card.querySelector('.pull-progress-bar')
const fill = card.querySelector('.pull-progress-bar-fill')
if (bar) {
bar.classList.toggle('pull-progress-bar--indet', indeterminate)
bar.setAttribute('aria-valuenow', String(pct ?? 0))
bar.setAttribute('aria-label', `${verb} ${progress.image || ''}`.trim())
}
if (fill && !indeterminate) {
const next = `${barWidth}%`
if (fill.style.width !== next) fill.style.width = next
}
patchLayerPips(card, progressPips(progress))
patchActiveLayers(card, progressActiveLayers(progress))
patchWaitingLine(card, progressWaitingText(progress))
// Always-present activity line — text only, height reserved in CSS
let activityEl = card.querySelector('.pull-progress-activity')
if (!activityEl) {
activityEl = document.createElement('div')
activityEl.className = 'pull-progress-activity'
activityEl.setAttribute('aria-live', 'polite')
card.appendChild(activityEl)
}
if (activityEl.textContent !== activity) activityEl.textContent = activity
}
/**
* True when the hybrid image pull/push progress card is the primary UI.
* @param {object|null|undefined} progress
*/
function hasHybridProgress(progress) {
return Boolean(
progress && (progress.kind === 'image-pull' || progress.kind === 'image-push')
)
}
/**
* Live-log label for the expandable summary control.
* @param {number} count
* @param {boolean} open
*/
function jobLogSummaryText(count, open) {
const n = Number(count) || 0
return open ? `Live log (${n})` : `Show live log (${n})`
}
/**
* Wire <details> so the live log stays opt-in (click to expand).
* Hybrid pull/push cards auto-collapse the log so layer progress is not covered.
* @param {HTMLElement} details
* @param {HTMLElement} host
*/
function bindJobLogDetails(details, host) {
if (!details || details.dataset.toggleBound) return
details.dataset.toggleBound = '1'
details.addEventListener('toggle', () => {
const label = details.querySelector('.job-log-summary-label')
const log = host.querySelector('.job-log')
const count = log ? Number(log.dataset.logLen || 0) : 0
if (label) label.textContent = jobLogSummaryText(count, details.open)
if (details.open) {
// Remember intentional expand so we do not collapse mid-pull while they read it
details.dataset.userOpened = '1'
if (log) {
log.dataset.following = '1'
scrollJobLogToBottom(log)
}
// Show follow hint while expanded and running
const panel = host.querySelector('.job-panel')
const running = panel?.classList.contains('job-panel--running')
if (running) syncJobLogFollowHint(details, 'running')
} else {
details.querySelector('.job-log-follow-hint')?.remove()
}
})
}
/**
* Keep live log closed during hybrid pull/push unless the user expanded it or the job failed.
* @param {HTMLDetailsElement|null} details
* @param {Job} job
*/
function syncJobLogDetailsOpen(details, job) {
if (!details) return
const status = job.status || 'running'
const hybrid = hasHybridProgress(job.progress)
const wasOpen = details.open
if (status === 'error') {
details.open = true
} else if (hybrid && details.dataset.userOpened !== '1') {
// Hybrid progress card owns the tray — collapse the terminal log unless user opened it
details.open = false
}
// If we programmatically closed/opened, refresh the summary label (toggle event may not fire)
if (wasOpen !== details.open) {
const label = details.querySelector('.job-log-summary-label')
const log = details.querySelector('.job-log')
const count = log ? Number(log.dataset.logLen || 0) : 0
if (label) label.textContent = jobLogSummaryText(count, details.open)
if (!details.open) details.querySelector('.job-log-follow-hint')?.remove()
}
}
/**
* Update follow-hint visibility (only meaningful while the log is expanded).
* @param {HTMLElement|null} details
* @param {string} status
*/
function syncJobLogFollowHint(details, status) {
if (!details) return
let followHint = details.querySelector('.job-log-follow-hint')
const show = status === 'running' && details.open
if (show) {
if (!followHint) {
const summary = details.querySelector('summary')
if (summary) {
followHint = document.createElement('span')
followHint.className = 'job-log-follow-hint'
followHint.title = 'Log auto-scrolls to new output'
followHint.innerHTML = '<i class="fas fa-angles-down"></i> Following'
summary.appendChild(followHint)
}
}
} else if (followHint) {
followHint.remove()
}
}
/**
* Incremental update of an already-mounted panel for the same job.
* @param {HTMLElement} host
* @param {HTMLElement} panel
* @param {Job} job
*/
function patchJobPanel(host, panel, job) {
const prevLog = host.querySelector('.job-log')
const wasFollowing = !prevLog || prevLog.dataset.following !== '0'
// Panel status class
const status = job.status || 'running'
const statusClass = `job-panel job-panel--${status}`
if (panel.className !== statusClass) panel.className = statusClass
// Title block (icon + kind + optional subtitle)
const titleStrong = panel.querySelector('.job-panel-title strong, .job-panel-header > strong')
if (titleStrong && titleStrong.textContent !== job.kind) titleStrong.textContent = job.kind
const subEl = panel.querySelector('.job-panel-subtitle')
if (subEl) {
const sub = job.subtitle || ''
if (subEl.textContent !== sub) subEl.textContent = sub
subEl.hidden = !sub
}
const iconEl = panel.querySelector('.job-kind-icon i')
if (iconEl && job.icon) {
const next = `fas ${job.icon}`
if (iconEl.className !== next) iconEl.className = next
}
// Badge + auto-dismiss hint
const headerRight = panel.querySelector('.job-panel-header-right')
if (headerRight) {
let hint = headerRight.querySelector('.job-auto-dismiss-hint')
if (status === 'success') {
if (!hint) {
hint = document.createElement('span')
hint.className = 'job-auto-dismiss-hint'
hint.textContent = 'Closing in 3s…'
headerRight.insertBefore(hint, headerRight.firstChild)
}
} else if (hint) {
hint.remove()
}
const badge = headerRight.querySelector('.badge')
if (badge) {
badge.className = `badge bg-${statusBadgeClass(status)} job-status-badge`
if (badge.textContent !== status) badge.textContent = status
}
}
// Step progress chip (e.g. 2/3)
const chip = panel.querySelector('.job-steps-chip')
if (chip) {
const total = job.steps?.length || 0
const done = (job.steps || []).filter(
(s) => s.status === 'success' || s.status === 'skipped'
).length
const active = (job.steps || []).find((s) => s.status === 'active')
const text =
status === 'success'
? `${total}/${total} done`
: status === 'error'
? `failed · ${done}/${total}`
: active
? `${done + 1}/${total}`
: `${done}/${total}`
if (chip.textContent !== text) chip.textContent = text
}
const stepsEl = panel.querySelector('.job-steps')
if (stepsEl) patchJobSteps(stepsEl, job)
patchJobProgress(host, panel, job.progress)
const details = panel.querySelector('.job-log-details')
const logEl = panel.querySelector('.job-log')
bindJobLogDetails(details, host)
syncJobLogDetailsOpen(details, job)
// Summary count (collapsed vs expanded wording)
const summaryLabel = details?.querySelector('.job-log-summary-label')
if (summaryLabel) {
const text = jobLogSummaryText(job.log.length, Boolean(details?.open))
if (summaryLabel.textContent !== text) summaryLabel.textContent = text
}
syncJobLogFollowHint(details, status)
let appended = false
if (logEl) {
appended = patchJobLog(logEl, job)
bindLogFollow(host, logEl)
// Only scroll when the log is visible — avoids layout thrash while collapsed
if (details?.open && appended && wasFollowing) {
scrollJobLogToBottom(logEl)
logEl.dataset.following = '1'
} else if (details?.open && !wasFollowing) {
logEl.dataset.following = '0'
const hint = host.querySelector('.job-log-follow-hint')
if (hint) {
hint.classList.add('is-paused')
hint.innerHTML = '<i class="fas fa-pause"></i> Paused — scroll to bottom'
}
}
}
}
/**
* Render job into a DOM host element.
* Same-job updates patch the live DOM (no full rebuild) so progress bars,
* spinners, and scroll position stay stable during rapid pull events.
* @param {HTMLElement} host
* @param {Job} job
*/
export function renderJobPanel(host, job) {
if (!host || !job) return
const existing = host.querySelector('.job-panel')
if (existing && existing.getAttribute('data-job-id') === job.id) {
patchJobPanel(host, existing, job)
return
}
// Live log is opt-in (click the summary). Only errors auto-expand so failures
// are visible; hybrid pull/push progress owns the tray during image ops.
const keepDetailsOpen = job.status === 'error'
const stepsHtml = job.steps.map(stepRowHtml).join('')
const progressHtml = renderJobProgress(job.progress)
const logTail = job.log.slice(-200)
const logHtml = logTail.length
? logTail.map(logLineHtml).join('')
: '<div class="text-muted job-log-empty">No output yet</div>'
const status = job.status || 'running'
const autoHint =
status === 'success' ? '<span class="job-auto-dismiss-hint">Closing in 3s…</span>' : ''
const followHint =
status === 'running' && keepDetailsOpen
? '<span class="job-log-follow-hint" title="Log auto-scrolls to new output"><i class="fas fa-angles-down"></i> Following</span>'
: ''
const logSummary = jobLogSummaryText(job.log.length, keepDetailsOpen)
const totalSteps = job.steps?.length || 0
const doneSteps = (job.steps || []).filter(
(s) => s.status === 'success' || s.status === 'skipped'
).length
const activeStep = (job.steps || []).find((s) => s.status === 'active')
const stepsChip =
status === 'success'
? `${totalSteps}/${totalSteps} done`
: status === 'error'
? `failed · ${doneSteps}/${totalSteps}`
: activeStep
? `${doneSteps + 1}/${totalSteps}`
: `${doneSteps}/${totalSteps}`
const iconClass = job.icon ? escapeHtml(job.icon) : 'fa-bolt'
const subtitle = job.subtitle ? escapeHtml(job.subtitle) : ''
host.innerHTML = `
<div class="job-panel job-panel--${escapeHtml(status)}" data-job-id="${escapeHtml(job.id)}">
<div class="job-panel-header">
<div class="job-panel-title">
<span class="job-kind-icon" aria-hidden="true"><i class="fas ${iconClass}"></i></span>
<div class="job-panel-title-text min-w-0">
<strong>${escapeHtml(job.kind)}</strong>
<div class="job-panel-subtitle"${subtitle ? '' : ' hidden'}>${subtitle}</div>
</div>
</div>
<div class="job-panel-header-right">
${autoHint}
<span class="job-steps-chip" title="Step progress">${escapeHtml(stepsChip)}</span>
<span class="badge bg-${statusBadgeClass(status)} job-status-badge">${escapeHtml(status)}</span>
<button type="button" class="job-dismiss-btn" data-job-dismiss title="Dismiss" aria-label="Dismiss">
<i class="fas fa-times"></i>
</button>
</div>
</div>
<div class="job-steps">${stepsHtml}</div>
${progressHtml}
<details class="job-log-details" ${keepDetailsOpen ? 'open' : ''}>
<summary class="job-log-summary" title="Toggle live terminal output">
<span class="job-log-summary-label">${escapeHtml(logSummary)}</span>
${followHint}
</summary>
<div class="job-log" data-job-log="${escapeHtml(job.id)}" data-log-len="${job.log.length}" role="log" aria-live="polite" aria-relevant="additions">
${logHtml}
</div>
</details>
</div>`
const logEl = host.querySelector('.job-log')
const details = host.querySelector('.job-log-details')
bindJobLogDetails(details, host)
if (details && keepDetailsOpen) details.open = true
if (logEl) {
logEl.dataset.following = '1'
bindLogFollow(host, logEl)
if (details?.open) scrollJobLogToBottom(logEl)
}
}
/**
* Hybrid image pull/push progress card for the job tray.
* Fixed DOM shape (summary + bar + pips + layer slots + waiting + activity) so height never oscillates mid-pull.
* @param {object|null|undefined} progress
* @returns {string} HTML
*/
export function renderJobProgress(progress) {
if (!progress || (progress.kind !== 'image-pull' && progress.kind !== 'image-push')) {
return ''
}
const phase = escapeHtml(progress.phase || 'resolving')
const phaseLabel = escapeHtml(progress.phaseLabel || 'Pulling')
const { pct, barWidth, indeterminate, pctText } = progressMetrics(progress)
const summary = escapeHtml(progress.summary || '')
const activity = escapeHtml(progressActivityText(progress))
const image = progress.image ? escapeHtml(progress.image) : ''
const verb = progress.kind === 'image-push' ? 'Push' : 'Pull'
const pips = progressPips(progress)
const trackHtml = pips
.map((p) => `<span class="pull-layer-pip pull-layer-pip--${escapeHtml(p)}"></span>`)
.join('')
const layersHtml = layerSlotsHtml(progressActiveLayers(progress))
const waitingText = progressWaitingText(progress)
const waitingHtml = escapeHtml(waitingText || '\u00a0')
const waitingEmpty = waitingText ? '' : ' pull-progress-waiting--empty'
return `<div class="pull-progress pull-progress--${phase}" data-pull-phase="${phase}" role="group" aria-label="${escapeHtml(verb)} progress">
<div class="pull-progress-summary">
<span class="pull-progress-phase">${phaseLabel}</span>
<span class="pull-progress-meta">${summary}</span>
<span class="pull-progress-pct">${escapeHtml(pctText)}</span>
</div>
<div class="pull-progress-bar${indeterminate ? ' pull-progress-bar--indet' : ''}" role="progressbar" aria-valuenow="${pct ?? 0}" aria-valuemin="0" aria-valuemax="100" aria-label="${escapeHtml(verb)} ${image}">
<div class="pull-progress-bar-fill" style="width:${barWidth}%"></div>
</div>
<div class="pull-progress-track" aria-hidden="true" data-pips-key="${escapeHtml(pips.join(','))}">${trackHtml}</div>
<div class="pull-progress-layers" aria-label="Active layers">${layersHtml}</div>
<div class="pull-progress-waiting${waitingEmpty}">${waitingHtml}</div>
<div class="pull-progress-activity" aria-live="polite">${activity}</div>
</div>`
}
function escapeHtml(s) {
return String(s)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
}
export default {
createJob,
runJob,
setStep,
setJobProgress,
appendJobLog,
completeJob,
renderJobPanel,
renderJobProgress,
scrollJobLogToBottom,
subscribeJobs,
listJobs,
getJob,
showActivity,
updateActivity,
completeActivity,
}