@@ -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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user