Track E: recreate, system prune, Hub search, swarm secrets/configs
CI / test (push) Successful in 10m3s
CI / test (push) Successful in 10m3s
Investigate Docker/Portainer gaps and ship high-value ops: recreate container, system prune + builder prune UI, Docker Hub search in pull modal, Swarm secrets/configs tables. Update roadmap Track E/F.
This commit is contained in:
@@ -536,6 +536,14 @@ export function registerContainerHandlers(session) {
|
||||
return duplicateContainer(args, session)
|
||||
})
|
||||
|
||||
/**
|
||||
* Recreate container with the same config (Portainer-style).
|
||||
* Stops/removes the old container, creates a new one with the same name, starts if it was running.
|
||||
*/
|
||||
session.respond('recreateContainer', async (args) => {
|
||||
return recreateContainer(args, session)
|
||||
})
|
||||
|
||||
session.respond('execContainer', async (args) => {
|
||||
const container = docker.getContainer(args.id)
|
||||
const exec = await container.exec({
|
||||
@@ -633,6 +641,121 @@ async function duplicateContainer(args, session) {
|
||||
return { success: true, message: `Container '${name}' duplicated and started successfully.` }
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dockerode create options from an inspect result.
|
||||
* @param {object} inspect
|
||||
* @param {string} name
|
||||
*/
|
||||
function createOptsFromInspect(inspect, name) {
|
||||
const config = { ...(inspect.Config || {}) }
|
||||
// Runtime-only / identity fields must not be passed to create
|
||||
for (const key of [
|
||||
'Hostname',
|
||||
'Domainname',
|
||||
'Image',
|
||||
'AttachStdin',
|
||||
'AttachStdout',
|
||||
'AttachStderr',
|
||||
'Tty',
|
||||
'OpenStdin',
|
||||
'StdinOnce',
|
||||
]) {
|
||||
// keep these intentionally from Config below
|
||||
void key
|
||||
}
|
||||
|
||||
const hostConfig = { ...(inspect.HostConfig || {}) }
|
||||
// Drop empty/null host config noise that can break recreate
|
||||
for (const [k, v] of Object.entries(hostConfig)) {
|
||||
if (v === null || v === undefined || v === '' || (Array.isArray(v) && v.length === 0)) {
|
||||
delete hostConfig[k]
|
||||
}
|
||||
}
|
||||
|
||||
const networks = inspect.NetworkSettings?.Networks || {}
|
||||
const endpoints = {}
|
||||
for (const [netName, conf] of Object.entries(networks)) {
|
||||
if (!conf || netName === 'host' || netName === 'none') continue
|
||||
endpoints[netName] = {
|
||||
Aliases: conf.Aliases || undefined,
|
||||
IPAMConfig: conf.IPAMConfig || undefined,
|
||||
Links: conf.Links || undefined,
|
||||
NetworkID: conf.NetworkID || undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
Image: config.Image,
|
||||
Env: config.Env,
|
||||
Cmd: config.Cmd,
|
||||
Entrypoint: config.Entrypoint,
|
||||
Labels: config.Labels,
|
||||
WorkingDir: config.WorkingDir,
|
||||
User: config.User,
|
||||
Hostname: config.Hostname,
|
||||
Domainname: config.Domainname,
|
||||
Tty: config.Tty,
|
||||
OpenStdin: config.OpenStdin,
|
||||
StdinOnce: config.StdinOnce,
|
||||
ExposedPorts: config.ExposedPorts,
|
||||
Volumes: config.Volumes,
|
||||
StopSignal: config.StopSignal,
|
||||
StopTimeout: config.StopTimeout,
|
||||
Healthcheck: config.Healthcheck,
|
||||
HostConfig: hostConfig,
|
||||
NetworkingConfig:
|
||||
Object.keys(endpoints).length > 0 ? { EndpointsConfig: endpoints } : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ id: string, start?: boolean, removeVolumes?: boolean, timeout?: number }} args
|
||||
*/
|
||||
async function recreateContainer(args, session) {
|
||||
const id = args.id
|
||||
if (!id) throw Object.assign(new Error('id required'), { code: 'INVALID_ARGS' })
|
||||
|
||||
const container = docker.getContainer(id)
|
||||
const inspect = await container.inspect()
|
||||
const name = String(inspect.Name || '').replace(/^\//, '') || id.slice(0, 12)
|
||||
const wasRunning = Boolean(inspect.State?.Running)
|
||||
const shouldStart = args.start !== false && (args.start === true || wasRunning)
|
||||
|
||||
session._cleanupLogsForContainer?.(id)
|
||||
|
||||
try {
|
||||
if (wasRunning) {
|
||||
await container.stop({ t: Number(args.timeout) >= 0 ? Number(args.timeout) : 10 })
|
||||
}
|
||||
} catch {
|
||||
// already stopped
|
||||
}
|
||||
|
||||
await container.remove({
|
||||
force: true,
|
||||
v: Boolean(args.removeVolumes || args.v),
|
||||
})
|
||||
|
||||
const createOpts = createOptsFromInspect(inspect, name)
|
||||
const created = await docker.createContainer(createOpts)
|
||||
|
||||
if (shouldStart) {
|
||||
await created.start()
|
||||
}
|
||||
|
||||
await broadcastContainers()
|
||||
return {
|
||||
success: true,
|
||||
message: shouldStart
|
||||
? `Container '${name}' recreated and started`
|
||||
: `Container '${name}' recreated`,
|
||||
id: created.id,
|
||||
name,
|
||||
started: shouldStart,
|
||||
}
|
||||
}
|
||||
|
||||
export async function broadcastContainers() {
|
||||
try {
|
||||
const containers = await docker.listContainers({ all: true })
|
||||
|
||||
@@ -58,6 +58,51 @@ export function registerSystemHandlers(session) {
|
||||
return { type: 'systemDf', data, success: true }
|
||||
})
|
||||
|
||||
/**
|
||||
* Docker system prune (Portainer-style "clean unused data").
|
||||
* @param {{ volumes?: boolean, all?: boolean }} args
|
||||
*/
|
||||
session.respond('systemPrune', async (args = {}) => {
|
||||
const volumes = Boolean(args.volumes)
|
||||
const all = Boolean(args.all)
|
||||
let data = null
|
||||
if (typeof docker.pruneSystem === 'function') {
|
||||
data = await docker.pruneSystem({ volumes, all })
|
||||
} else {
|
||||
// Fallback: dial Engine /system/prune directly
|
||||
data = await new Promise((resolve, reject) => {
|
||||
const qs = new URLSearchParams()
|
||||
if (volumes) qs.set('volumes', '1')
|
||||
if (all) qs.set('all', '1')
|
||||
const path = `/system/prune${qs.toString() ? `?${qs}` : ''}`
|
||||
docker.modem.dial(
|
||||
{
|
||||
path,
|
||||
method: 'POST',
|
||||
statusCodes: {
|
||||
200: true,
|
||||
500: 'server error',
|
||||
},
|
||||
},
|
||||
(err, result) => (err ? reject(err) : resolve(result))
|
||||
)
|
||||
})
|
||||
}
|
||||
logger.info('systemPrune completed', {
|
||||
volumes,
|
||||
all,
|
||||
peerId: session.id?.slice?.(0, 12),
|
||||
})
|
||||
return {
|
||||
success: true,
|
||||
type: 'systemPrune',
|
||||
message: volumes
|
||||
? 'System pruned (including unused volumes)'
|
||||
: 'System pruned (containers, networks, images; volumes kept)',
|
||||
data,
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('getMetrics', async () => {
|
||||
return getMetricsSnapshot({ peerCount: peers.size })
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user