forked from snxraven/peardock
Cap scrollable centered modals so only the body scrolls when push options expand, and copy the image pull ref to the clipboard on success.
848 lines
27 KiB
JavaScript
848 lines
27 KiB
JavaScript
/**
|
|
* 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 })
|
|
}
|
|
|
|
/**
|
|
* Flatten (squash) a container into a single-layer image, optionally push.
|
|
* @param {{
|
|
* id: string,
|
|
* name?: string,
|
|
* repo: string,
|
|
* tag?: string,
|
|
* message?: string,
|
|
* pause?: boolean,
|
|
* cmd?: string,
|
|
* entrypoint?: string,
|
|
* workdir?: string,
|
|
* user?: string,
|
|
* env?: string,
|
|
* push?: boolean,
|
|
* credentialId?: string,
|
|
* }} args
|
|
*/
|
|
export async function flattenContainerJob(args) {
|
|
const id = String(args.id || '').trim()
|
|
const name = String(args.name || shortId(id))
|
|
const repo = String(args.repo || '').trim()
|
|
const tag = String(args.tag || 'latest').trim() || 'latest'
|
|
if (!id) throw new Error('Container id required')
|
|
if (!repo) throw new Error('Repository name required')
|
|
const ref = `${repo}:${tag}`
|
|
const doPush = Boolean(args.push)
|
|
|
|
const steps = [
|
|
{
|
|
id: 'plan',
|
|
label: 'Plan flatten',
|
|
run: async ({ log }) => {
|
|
log(`Source container: ${name} (${shortId(id)})`)
|
|
log(`Target image: ${ref}`)
|
|
log(`Mode: docker export | docker import (single layer)`)
|
|
log(`Pause during export: ${args.pause !== false ? 'yes' : 'no'}`)
|
|
if (args.message) log(`Message: ${args.message}`)
|
|
if (args.cmd) log(`CMD: ${args.cmd}`)
|
|
if (args.entrypoint) log(`ENTRYPOINT: ${args.entrypoint}`)
|
|
if (args.workdir) log(`WORKDIR: ${args.workdir}`)
|
|
if (args.user) log(`USER: ${args.user}`)
|
|
if (args.env) {
|
|
const lines = String(args.env)
|
|
.split(/\r?\n/)
|
|
.map((l) => l.trim())
|
|
.filter((l) => l && !l.startsWith('#'))
|
|
log(`ENV: ${lines.length} variable(s)`)
|
|
}
|
|
log(`Push after flatten: ${doPush ? 'yes' : 'no'}`)
|
|
if (doPush && args.pushTarget === 'ttl.sh') {
|
|
log(`Push target: ttl.sh (anonymous ephemeral · tag is TTL)`)
|
|
} else if (doPush && args.credentialId) {
|
|
log(`Credential: ${args.credentialId}`)
|
|
}
|
|
},
|
|
},
|
|
{
|
|
id: 'flatten',
|
|
label: 'Export → import',
|
|
run: async ({ job, log }) => {
|
|
log(`Flattening ${name} → ${ref}…`)
|
|
setStep(job.id, 'flatten', 'active', { detail: 'streaming filesystem' })
|
|
|
|
// Live progress from server flattenProgress pushes
|
|
const onMsg = (msg) => {
|
|
if (msg?.type !== 'flattenProgress' && msg?.type !== 'push:flattenProgress') return
|
|
if (msg.containerId && msg.containerId !== id && !String(id).startsWith(String(msg.containerId).slice(0, 12))) {
|
|
// still accept if ref matches
|
|
if (msg.ref !== ref) return
|
|
}
|
|
const phase = msg.phase || ''
|
|
const bytes = Number(msg.bytes) || 0
|
|
const detail =
|
|
bytes > 0
|
|
? `${phase || 'work'} · ${formatBytesJob(bytes)}`
|
|
: phase || msg.message || ''
|
|
if (detail) setStep(job.id, 'flatten', 'active', { detail })
|
|
if (msg.message) {
|
|
// Avoid spamming identical lines
|
|
const last = job.log?.[job.log.length - 1]?.line
|
|
if (last !== msg.message) log(msg.message)
|
|
}
|
|
if (bytes > 0) {
|
|
setJobProgress(
|
|
job.id,
|
|
{
|
|
kind: 'flatten',
|
|
phase,
|
|
bytes,
|
|
label: msg.message || `Streaming ${formatBytesJob(bytes)}`,
|
|
percent: null,
|
|
},
|
|
{ stepId: 'flatten' }
|
|
)
|
|
}
|
|
}
|
|
|
|
manager.on?.('message', onMsg)
|
|
try {
|
|
const body = {
|
|
id,
|
|
repo,
|
|
tag,
|
|
pause: args.pause !== false,
|
|
}
|
|
if (args.message) body.message = args.message
|
|
if (args.cmd) body.cmd = args.cmd
|
|
if (args.entrypoint) body.entrypoint = args.entrypoint
|
|
if (args.workdir) body.workdir = args.workdir
|
|
if (args.user) body.user = args.user
|
|
if (args.env) body.env = args.env
|
|
|
|
const res = await manager.request(Methods.flattenContainer, body)
|
|
if (res?.exportBytes != null) {
|
|
log(`Streamed ${formatBytesJob(res.exportBytes)} of filesystem tar`)
|
|
}
|
|
if (res?.imageId) log(`Image id: ${shortId(res.imageId)}`)
|
|
if (res?.paused) log('Container was paused during export and resumed')
|
|
if (res?.elapsedMs != null) log(`Elapsed: ${(res.elapsedMs / 1000).toFixed(1)}s`)
|
|
log(res?.message || `Created ${ref}`)
|
|
setJobProgress(job.id, null)
|
|
return res
|
|
} catch (err) {
|
|
setJobProgress(job.id, null)
|
|
rethrowJobError(err, 'flattenContainer')
|
|
} finally {
|
|
try {
|
|
manager.off?.('message', onMsg)
|
|
manager.removeListener?.('message', onMsg)
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
},
|
|
},
|
|
]
|
|
|
|
if (doPush) {
|
|
steps.push({
|
|
id: 'push',
|
|
label: 'Push to registry',
|
|
run: async ({ job, log }) => {
|
|
const trackKey = ref
|
|
const tracker = beginPullProgress(trackKey, job.id, 'push', {
|
|
kind: 'image-push',
|
|
})
|
|
setJobProgress(job.id, tracker.snapshot(), { stepId: 'push' })
|
|
log(`Pushing ${ref}…`)
|
|
if (args.pushTarget === 'ttl.sh') {
|
|
log('Anonymous push to ttl.sh — image is public and will expire with the TTL tag')
|
|
} else if (args.credentialId) {
|
|
log(`Using vault credential ${args.credentialId} (auto-retags short names onto that registry)`)
|
|
}
|
|
try {
|
|
const body = {
|
|
image: ref,
|
|
id: ref,
|
|
// ttl.sh is anonymous; skip vault matching that could attach wrong Hub creds
|
|
autoVault: args.pushTarget === 'ttl.sh' ? false : true,
|
|
}
|
|
if (args.credentialId) body.credentialId = args.credentialId
|
|
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)
|
|
}
|
|
if (res?.retargeted && res?.image && res.image !== ref) {
|
|
log(`Retagged for registry: ${ref} → ${res.image}`)
|
|
}
|
|
log(res?.message || `Pushed ${res?.image || ref}`)
|
|
return res
|
|
} catch (err) {
|
|
finalizePullProgress(trackKey, {
|
|
ok: false,
|
|
message: err?.message || String(err),
|
|
})
|
|
endPullProgress(trackKey, job.id)
|
|
rethrowJobError(err, 'pushImage')
|
|
}
|
|
},
|
|
})
|
|
}
|
|
|
|
steps.push({
|
|
id: 'done',
|
|
label: 'Finish',
|
|
run: async ({ job, log }) => {
|
|
// Prefer final pushed ref (may differ after registry retarget)
|
|
const pullRef = String(job.result?.image || job.result?.ref || ref).trim()
|
|
log(`Flatten complete: ${pullRef}`)
|
|
if (!doPush) {
|
|
log('Image is available on the local Docker engine.')
|
|
return { pullRef, pullCmd: '', copiedToClipboard: false }
|
|
}
|
|
|
|
log('Image is available locally and on the registry.')
|
|
const { pullCmd } = pullCommandForImage(pullRef)
|
|
log(`Pull: ${pullCmd}`)
|
|
|
|
let copied = false
|
|
// Prefer the bare image ref (works in compose/k8s/pull); also log full command
|
|
if (pullRef) {
|
|
copied = await copyTextToClipboard(pullRef)
|
|
if (copied) {
|
|
log(`✓ Pull ref copied to clipboard: ${pullRef}`)
|
|
} else {
|
|
log(`Could not auto-copy — use: ${pullRef}`, 'warning')
|
|
}
|
|
}
|
|
return {
|
|
pullRef,
|
|
pullCmd,
|
|
copiedToClipboard: copied,
|
|
}
|
|
},
|
|
})
|
|
|
|
return runJob(`Flatten ${name}`, steps, {
|
|
peerId: manager.active?.id,
|
|
icon: 'fa-compress',
|
|
subtitle: ref,
|
|
})
|
|
}
|
|
|
|
/**
|
|
* 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`
|
|
}
|
|
|
|
/**
|
|
* Copy text to the system clipboard (Electron / browser).
|
|
* @param {string} text
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
export async function copyTextToClipboard(text) {
|
|
const s = String(text || '')
|
|
if (!s) return false
|
|
try {
|
|
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
|
await navigator.clipboard.writeText(s)
|
|
return true
|
|
}
|
|
} catch {
|
|
// fall through
|
|
}
|
|
try {
|
|
if (typeof document === 'undefined') return false
|
|
const ta = document.createElement('textarea')
|
|
ta.value = s
|
|
ta.setAttribute('readonly', '')
|
|
ta.style.position = 'fixed'
|
|
ta.style.left = '-9999px'
|
|
ta.style.top = '0'
|
|
document.body.appendChild(ta)
|
|
ta.select()
|
|
const ok = document.execCommand('copy')
|
|
document.body.removeChild(ta)
|
|
return Boolean(ok)
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build a ready-to-paste pull command for a registry image ref.
|
|
* @param {string} imageRef
|
|
* @returns {{ pullRef: string, pullCmd: string }}
|
|
*/
|
|
export function pullCommandForImage(imageRef) {
|
|
const pullRef = String(imageRef || '').trim()
|
|
return {
|
|
pullRef,
|
|
pullCmd: pullRef ? `docker pull ${pullRef}` : '',
|
|
}
|
|
}
|
|
|
|
export default {
|
|
bulkContainerJob,
|
|
recreateContainersJob,
|
|
removeImagesJob,
|
|
pruneJob,
|
|
removeStackJob,
|
|
containerActionJob,
|
|
buildImageJob,
|
|
flattenContainerJob,
|
|
pushImageJob,
|
|
createVolumeJob,
|
|
copyTextToClipboard,
|
|
pullCommandForImage,
|
|
}
|