Job Tray Updates
Release rolling / release (push) Successful in 9m12s

This commit is contained in:
Raven Scott
2026-07-16 04:20:26 -04:00
parent 6866e83439
commit 2c35f5cb82
6 changed files with 1051 additions and 100 deletions
+571
View File
@@ -0,0 +1,571 @@
/**
* Multi-step job-tray actions for long Docker ops.
* Prefer these over showStatusIndicator so the tray shows clear steps + logs.
*/
import { manager, Methods } from './manager.js'
import { runJob, setStep, appendJobLog, setJobProgress } from './jobs.js'
import {
beginPullProgress,
endPullProgress,
finalizePullProgress,
} from './pullProgress.js'
import { explainError } from './errors.js'
/**
* @param {unknown} err
* @param {string} method
*/
function rethrowJobError(err, method) {
const info = explainError(err, method)
const full = [info.title, info.message, info.recovery ? `How to fix: ${info.recovery}` : '']
.filter(Boolean)
.join(' — ')
const e = new Error(full || err?.message || String(err))
e.code = info.code || err?.code
e.cause = err
throw e
}
/**
* Short id for logs.
* @param {string} id
*/
function shortId(id) {
const s = String(id || '')
return s.length > 12 ? s.slice(0, 12) : s
}
/**
* Bulk start/stop/restart/kill/pause/unpause/remove containers.
* @param {{
* operation: string,
* label: string,
* containerIds: string[],
* names?: string[],
* force?: boolean,
* }} args
*/
export async function bulkContainerJob(args) {
const op = String(args.operation || '')
const label = String(args.label || op)
const ids = Array.isArray(args.containerIds) ? args.containerIds.filter(Boolean) : []
const names = Array.isArray(args.names) ? args.names : []
if (!ids.length) throw new Error('No containers selected')
return runJob(
`${label} ${ids.length} container${ids.length === 1 ? '' : 's'}`,
[
{
id: 'plan',
label: 'Review targets',
run: async ({ log }) => {
log(`Operation: ${label.toLowerCase()}`)
log(`Targets: ${ids.length}`)
for (let i = 0; i < ids.length; i++) {
const name = names[i] || shortId(ids[i])
log(` · ${name} (${shortId(ids[i])})`)
}
},
},
{
id: 'run',
label: `${label} containers`,
run: async ({ job, log }) => {
// Prefer bulk RPC when available (atomic-ish batch)
if (
op !== 'remove' &&
Methods.bulkContainerOperation &&
ids.length > 1
) {
log(`Batch ${label.toLowerCase()} via bulk API…`)
try {
const res = await manager.request(Methods.bulkContainerOperation, {
containerIds: ids,
operation: op,
})
const results = res?.results || []
let ok = 0
let fail = 0
for (let i = 0; i < results.length; i++) {
const r = results[i]
const name = names[i] || shortId(r.id || ids[i])
if (r.success) {
ok++
log(`${name}`)
} else {
fail++
log(`${name}: ${r.error || r.message || 'failed'}`, 'error')
}
}
setStep(job.id, 'run', 'active', {
detail: `${ok} ok · ${fail} failed`,
})
log(`Done: ${ok} ok${fail ? `, ${fail} failed` : ''}`)
if (fail && !ok) {
throw new Error(`${label} failed for all ${fail} container(s)`)
}
return res
} catch (err) {
// Fall through to per-container if bulk missing
if (!/unknown method|not found|not implemented/i.test(String(err?.message || ''))) {
rethrowJobError(err, 'bulkContainerOperation')
}
log('Bulk API unavailable — running one-by-one…', 'warning')
}
}
let ok = 0
let fail = 0
const methodMap = {
start: Methods.startContainer,
stop: Methods.stopContainer,
kill: Methods.killContainer,
restart: Methods.restartContainer,
pause: Methods.pauseContainer,
unpause: Methods.unpauseContainer,
resume: Methods.unpauseContainer,
remove: Methods.removeContainer,
}
const method = methodMap[op]
if (!method) throw new Error(`Unknown operation: ${op}`)
for (let i = 0; i < ids.length; i++) {
const id = ids[i]
const name = names[i] || shortId(id)
setStep(job.id, 'run', 'active', {
detail: `${i + 1}/${ids.length} · ${name}`,
})
log(`[${i + 1}/${ids.length}] ${label} ${name}`)
try {
const payload =
op === 'remove' ? { id, force: args.force !== false } : { id }
await manager.request(method, payload)
ok++
log(`${name}`)
} catch (err) {
fail++
log(`${name}: ${err?.message || err}`, 'error')
}
}
log(`Done: ${ok} ok${fail ? `, ${fail} failed` : ''}`)
if (fail && !ok) throw new Error(`${label} failed for all targets`)
return { ok, fail }
},
},
],
{
peerId: manager.active?.id,
icon: op === 'remove' ? 'fa-trash' : 'fa-cubes',
subtitle: `${ids.length} selected`,
}
)
}
/**
* Recreate one or many containers (stop/remove/create with same config).
* @param {{ ids: string[], names?: string[], start?: boolean }} args
*/
export async function recreateContainersJob(args) {
const ids = Array.isArray(args.ids) ? args.ids.filter(Boolean) : []
const names = Array.isArray(args.names) ? args.names : []
const start = args.start !== false
if (!ids.length) throw new Error('No containers to recreate')
return runJob(
ids.length === 1
? `Recreate ${names[0] || shortId(ids[0])}`
: `Recreate ${ids.length} containers`,
[
{
id: 'plan',
label: 'Plan recreate',
run: async ({ log }) => {
log('Each container will be stopped, removed, and recreated with the same configuration.')
log(`Start after recreate: ${start ? 'yes' : 'no'}`)
for (let i = 0; i < ids.length; i++) {
log(` · ${names[i] || shortId(ids[i])}`)
}
},
},
{
id: 'recreate',
label: 'Recreate',
run: async ({ job, log }) => {
let ok = 0
let fail = 0
for (let i = 0; i < ids.length; i++) {
const id = ids[i]
const name = names[i] || shortId(id)
setStep(job.id, 'recreate', 'active', {
detail: `${i + 1}/${ids.length} · ${name}`,
})
log(`[${i + 1}/${ids.length}] Recreating ${name}`)
try {
const res = await manager.request(Methods.recreateContainer, {
id,
start,
})
ok++
log(`${name}${res?.id ? `${shortId(res.id)}` : ''}`)
} catch (err) {
fail++
log(`${name}: ${err?.message || err}`, 'error')
}
}
log(`Recreate finished: ${ok} ok${fail ? `, ${fail} failed` : ''}`)
if (fail && !ok) throw new Error('Recreate failed for all containers')
return { ok, fail }
},
},
],
{
peerId: manager.active?.id,
icon: 'fa-rotate',
subtitle: start ? 'with start' : 'without start',
}
)
}
/**
* Remove images with progress in the tray.
* @param {{ ids: string[], labels?: string[], force?: boolean }} args
*/
export async function removeImagesJob(args) {
const ids = Array.isArray(args.ids) ? args.ids.filter(Boolean) : []
const labels = Array.isArray(args.labels) ? args.labels : []
if (!ids.length) throw new Error('No images selected')
return runJob(
`Remove ${ids.length} image${ids.length === 1 ? '' : 's'}`,
[
{
id: 'plan',
label: 'Review images',
run: async ({ log }) => {
log(`Force remove: ${args.force !== false ? 'yes' : 'no'}`)
for (let i = 0; i < ids.length; i++) {
log(` · ${labels[i] || shortId(ids[i])}`)
}
},
},
{
id: 'remove',
label: 'Delete images',
run: async ({ job, log }) => {
let ok = 0
let fail = 0
for (let i = 0; i < ids.length; i++) {
const id = ids[i]
const label = labels[i] || shortId(id)
setStep(job.id, 'remove', 'active', {
detail: `${i + 1}/${ids.length} · ${label}`,
})
log(`[${i + 1}/${ids.length}] Removing ${label}`)
try {
await manager.request(Methods.removeImage, {
id,
force: args.force !== false,
})
ok++
log(`${label}`)
} catch (err) {
fail++
log(`${label}: ${err?.message || err}`, 'error')
}
}
log(`Removed ${ok}${fail ? `, ${fail} failed` : ''}`)
if (fail && !ok) throw new Error('Failed to remove all images')
return { ok, fail }
},
},
],
{ peerId: manager.active?.id, icon: 'fa-layer-group', subtitle: 'local engine' }
)
}
/**
* Prune a resource class or full system prune.
* @param {{ kind: string, label: string, method: string, args?: object }} args
*/
export async function pruneJob(args) {
const label = String(args.label || args.kind || 'resources')
const method = args.method
if (!method) throw new Error('Prune method required')
return runJob(`Prune ${label}`, [
{
id: 'confirm',
label: 'Prepare',
run: async ({ log }) => {
log(`Pruning: ${label}`)
log('This removes unused Docker data matching the prune scope.')
if (args.args && Object.keys(args.args).length) {
log(`Options: ${JSON.stringify(args.args)}`)
}
},
},
{
id: 'prune',
label: `Prune ${label}`,
run: async ({ log }) => {
log('Running prune…')
try {
const res = await manager.request(method, args.args || {})
log(res?.message || `Pruned ${label}`)
if (res?.SpaceReclaimed != null) {
log(`Space reclaimed: ${formatBytesJob(res.SpaceReclaimed)}`)
}
if (res?.spaceReclaimed != null) {
log(`Space reclaimed: ${formatBytesJob(res.spaceReclaimed)}`)
}
// Common docker prune report fields
for (const key of [
'ContainersDeleted',
'ImagesDeleted',
'VolumesDeleted',
'NetworksDeleted',
'containersDeleted',
'imagesDeleted',
'volumesDeleted',
'networksDeleted',
]) {
const v = res?.[key]
if (Array.isArray(v) && v.length) log(`${key}: ${v.length}`)
else if (typeof v === 'number' && v > 0) log(`${key}: ${v}`)
}
return res
} catch (err) {
rethrowJobError(err, method)
}
},
},
], { peerId: manager.active?.id, icon: 'fa-broom', subtitle: label })
}
/**
* Remove a compose stack with tray steps.
* @param {{ stackName: string }} args
*/
export async function removeStackJob(args) {
const stackName = String(args.stackName || '').trim()
if (!stackName) throw new Error('Stack name required')
return runJob(`Remove stack ${stackName}`, [
{
id: 'plan',
label: 'Prepare',
run: async ({ log }) => {
log(`Stack: ${stackName}`)
log('This removes the compose project and its containers.')
},
},
{
id: 'remove',
label: 'Compose down',
run: async ({ log }) => {
log(`Removing stack “${stackName}”…`)
try {
const res = await manager.request(Methods.removeStack, { stackName })
log(res?.message || `Stack "${stackName}" removed`)
return res
} catch (err) {
rethrowJobError(err, 'removeStack')
}
},
},
], { peerId: manager.active?.id, icon: 'fa-layer-group', subtitle: 'compose project' })
}
/**
* Single-container lifecycle action with a clear tray job.
* @param {{ action: string, id: string, name?: string, force?: boolean }} args
*/
export async function containerActionJob(args) {
const action = String(args.action || '')
const id = String(args.id || '')
const name = String(args.name || shortId(id))
const map = {
start: { method: Methods.startContainer, label: 'Start', icon: 'fa-play' },
stop: { method: Methods.stopContainer, label: 'Stop', icon: 'fa-stop' },
kill: { method: Methods.killContainer, label: 'Kill', icon: 'fa-skull' },
restart: { method: Methods.restartContainer, label: 'Restart', icon: 'fa-rotate' },
pause: { method: Methods.pauseContainer, label: 'Pause', icon: 'fa-pause' },
resume: { method: Methods.unpauseContainer, label: 'Resume', icon: 'fa-play' },
unpause: { method: Methods.unpauseContainer, label: 'Resume', icon: 'fa-play' },
remove: { method: Methods.removeContainer, label: 'Remove', icon: 'fa-trash' },
}
const spec = map[action]
if (!spec || !id) throw new Error('Invalid container action')
return runJob(`${spec.label} ${name}`, [
{
id: 'run',
label: `${spec.label} container`,
run: async ({ log }) => {
log(`${spec.label}ing ${name} (${shortId(id)})…`)
try {
const payload =
action === 'remove' ? { id, force: args.force !== false } : { id }
const res = await manager.request(spec.method, payload)
log(res?.message || `${spec.label}ed ${name}`)
return res
} catch (err) {
rethrowJobError(err, spec.method)
}
},
},
], { peerId: manager.active?.id, icon: spec.icon, subtitle: shortId(id) })
}
/**
* Build image with live log in the tray (server streams buildProgress separately).
* @param {{ tag: string, dockerfile: string, buildFn: () => Promise<object> }} args
*/
export async function buildImageJob(args) {
const tag = String(args.tag || '').trim()
if (!tag) throw new Error('Image tag required')
if (!args.dockerfile?.trim()) throw new Error('Dockerfile content required')
return runJob(`Build ${tag}`, [
{
id: 'prepare',
label: 'Prepare build',
run: async ({ log }) => {
log(`Tag: ${tag}`)
log(`Dockerfile: ${String(args.dockerfile).split('\n').length} lines`)
},
},
{
id: 'build',
label: 'docker build',
run: async ({ log }) => {
log('Building image…')
log('(streamed build output may also appear in the build modal)')
try {
const res = await args.buildFn()
log(res?.message || `Image "${tag}" built`)
return res
} catch (err) {
rethrowJobError(err, 'buildImage')
}
},
},
], { peerId: manager.active?.id, icon: 'fa-hammer', subtitle: tag })
}
/**
* Push image with hybrid progress card (same UX as pull).
* @param {{ image?: string, id?: string, repo?: string, tag?: string, credentialId?: string, body?: object }} args
*/
export async function pushImageJob(args) {
const image = String(args.image || args.id || '').trim()
const label = args.repo
? `${args.repo}:${args.tag || 'latest'}`
: image
if (!image && !args.repo) throw new Error('Image reference required')
const body = args.body || {
image: image || undefined,
id: args.id || image || undefined,
autoVault: true,
repo: args.repo,
tag: args.tag,
credentialId: args.credentialId,
}
return runJob(`Push ${label}`, [
{
id: 'prepare',
label: 'Prepare push',
run: async ({ log }) => {
log(`Target: ${label}`)
if (args.credentialId) log(`Credential: ${args.credentialId}`)
else log('Auth: vault / session')
},
},
{
id: 'push',
label: 'Upload layers',
run: async ({ job, log }) => {
const trackKey = label
const tracker = beginPullProgress(trackKey, job.id, 'push', {
kind: 'image-push',
})
setJobProgress(job.id, tracker.snapshot(), { stepId: 'push' })
log(`Pushing ${label}`)
try {
const res = await manager.request(Methods.pushImage, body)
const final = finalizePullProgress(trackKey, { ok: true })
if (final) {
setJobProgress(final.jobId, final.snapshot, { stepId: 'push' })
if (final.milestoneLine) appendJobLog(final.jobId, final.milestoneLine)
}
log(res?.message || `Pushed ${label}`)
return res
} catch (err) {
finalizePullProgress(trackKey, {
ok: false,
message: err?.message || String(err),
})
endPullProgress(trackKey, job.id)
rethrowJobError(err, 'pushImage')
}
},
},
], { peerId: manager.active?.id, icon: 'fa-cloud-arrow-up', subtitle: label })
}
/**
* Create a named volume.
* @param {{ name: string, driver?: string, labels?: object }} args
*/
export async function createVolumeJob(args) {
const name = String(args.name || '').trim()
if (!name) throw new Error('Volume name required')
return runJob(`Create volume ${name}`, [
{
id: 'create',
label: 'Create volume',
run: async ({ log }) => {
log(`Name: ${name}`)
if (args.driver) log(`Driver: ${args.driver}`)
try {
const res = await manager.request(Methods.createVolume, {
name,
driver: args.driver,
labels: args.labels,
})
log(res?.message || `Volume "${name}" created`)
return res
} catch (err) {
rethrowJobError(err, 'createVolume')
}
},
},
], { peerId: manager.active?.id, icon: 'fa-hard-drive', subtitle: 'volume' })
}
/**
* @param {number} n
*/
function formatBytesJob(n) {
const v = Number(n)
if (!Number.isFinite(v) || v < 0) return String(n)
if (v < 1024) return `${Math.round(v)} B`
if (v < 1024 ** 2) return `${(v / 1024).toFixed(1)} KiB`
if (v < 1024 ** 3) return `${(v / 1024 ** 2).toFixed(1)} MiB`
return `${(v / 1024 ** 3).toFixed(2)} GiB`
}
export default {
bulkContainerJob,
recreateContainersJob,
removeImagesJob,
pruneJob,
removeStackJob,
containerActionJob,
buildImageJob,
pushImageJob,
createVolumeJob,
}
+74 -14
View File
@@ -4,7 +4,7 @@
/**
* @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 }} Job
* @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
@@ -92,6 +92,8 @@ export function createJob(kind, stepDefs, meta = {}) {
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)
@@ -135,10 +137,11 @@ export function setStep(jobId, stepId, status, extra = {}) {
if (!job) return
const step = job.steps.find((s) => s.id === stepId || s.label === stepId)
if (!step) return
step.status = status
// 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) step.detail = extra.detail
if (status === 'error') job.status = 'error'
if (extra.detail !== undefined) step.detail = extra.detail
if (step.status === 'error') job.status = 'error'
emit(job)
}
@@ -270,10 +273,14 @@ export async function runJob(kind, steps, meta = {}) {
try {
for (const step of steps) {
const id = step.id || step.label
setStep(job.id, id, 'active')
setStep(job.id, id, 'active', { detail: '' })
try {
await step.run({ job, log })
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 })
@@ -286,13 +293,13 @@ export async function runJob(kind, steps, meta = {}) {
throw enriched
}
}
completeJob(job.id, { ok: true })
completeJob(job.id, { ok: true, ...(job.result || {}) })
job.viaJob = true
return job
} catch (err) {
job.status = 'error'
job.viaJob = true
emit(job)
emit(job, { immediate: true })
if (err && typeof err === 'object') {
err.viaJob = true
err.jobId = job.id
@@ -1005,9 +1012,20 @@ function patchJobPanel(host, panel, job) {
const statusClass = `job-panel job-panel--${status}`
if (panel.className !== statusClass) panel.className = statusClass
// Title
const title = panel.querySelector('.job-panel-header > strong')
if (title && title.textContent !== job.kind) title.textContent = job.kind
// 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')
@@ -1025,11 +1043,30 @@ function patchJobPanel(host, panel, job) {
}
const badge = headerRight.querySelector('.badge')
if (badge) {
badge.className = `badge bg-${statusBadgeClass(status)}`
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)
@@ -1102,13 +1139,36 @@ export function renderJobPanel(host, job) {
: ''
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">
<strong>${escapeHtml(job.kind)}</strong>
<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="badge bg-${statusBadgeClass(status)}">${escapeHtml(status)}</span>
<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>