forked from snxraven/peardock
1270 lines
42 KiB
JavaScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
}
|
|
|
|
export default {
|
|
createJob,
|
|
runJob,
|
|
setStep,
|
|
setJobProgress,
|
|
appendJobLog,
|
|
completeJob,
|
|
renderJobPanel,
|
|
renderJobProgress,
|
|
scrollJobLogToBottom,
|
|
subscribeJobs,
|
|
listJobs,
|
|
getJob,
|
|
showActivity,
|
|
updateActivity,
|
|
completeActivity,
|
|
}
|