/** * 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} */ 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} */ 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} 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 }>} 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 '' } 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 `` } /** * @param {JobStep|object} s */ function stepRowHtml(s) { return `
${stepIconHtml(s.status)} ${escapeHtml(s.label)} ${s.error ? `${escapeHtml(s.error)}` : ''} ${s.detail ? `${escapeHtml(s.detail)}` : ''}
` } /** * @param {{ t?: number, line: string, level?: string }} l */ function logLineHtml(l) { const ts = l.t ? new Date(l.t).toLocaleTimeString() : '' return `
${ ts ? `${escapeHtml(ts)} ` : '' }${escapeHtml(l.line)}
` } /** * @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 ? ' Following' : ' 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 = ' 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('') : '
No output yet
' 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 `` } 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 `
${shortId}
${meta}
` } /** * 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) => ``).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 = `
` } 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 = ` ${escapeHtml(shortId)}
${escapeHtml(meta)}` 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
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 = ' 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 = ' 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('') : '
No output yet
' const status = job.status || 'running' const autoHint = status === 'success' ? 'Closing in 3s…' : '' const followHint = status === 'running' && keepDetailsOpen ? ' Following' : '' 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 = `
${escapeHtml(job.kind)}
${subtitle}
${autoHint} ${escapeHtml(stepsChip)} ${escapeHtml(status)}
${stepsHtml}
${progressHtml}
${escapeHtml(logSummary)} ${followHint}
${logHtml}
` 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) => ``) .join('') const layersHtml = layerSlotsHtml(progressActiveLayers(progress)) const waitingText = progressWaitingText(progress) const waitingHtml = escapeHtml(waitingText || '\u00a0') const waitingEmpty = waitingText ? '' : ' pull-progress-waiting--empty' return `
${phaseLabel} ${summary} ${escapeHtml(pctText)}
${layersHtml}
${waitingHtml}
${activity}
` } function escapeHtml(s) { return String(s) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } export default { createJob, runJob, setStep, setJobProgress, appendJobLog, completeJob, renderJobPanel, renderJobProgress, scrollJobLogToBottom, subscribeJobs, listJobs, getJob, showActivity, updateActivity, completeActivity, }