Stabilize multi-peer UI and fix container start API body
Release rolling / release (push) Successful in 8m25s
Release rolling / release (push) Successful in 8m25s
Keep the containers table scoped to the active peer with a merge store, restore the last active server on boot with a restoring screen, start containers without a Docker API body (v1.24+), and stop ⋮ menus from sticking to the viewport bottom after refreshes.
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
* Container RPC handlers.
|
||||
*/
|
||||
import { PassThrough } from 'stream'
|
||||
import { docker, extractIpAddress } from '../services/docker.js'
|
||||
import { docker, startContainerNoBody, containerLifecycleNoBody } from '../services/docker.js'
|
||||
import * as validation from '../utils/validation.js'
|
||||
import { Pushes } from '../../shared/protocol.js'
|
||||
import { peers } from '../core/peer-registry.js'
|
||||
@@ -47,7 +47,8 @@ export function registerContainerHandlers(session) {
|
||||
const container = await docker.createContainer(createOpts)
|
||||
if (args.start) {
|
||||
try {
|
||||
await container.start()
|
||||
// Empty-body start (Engine API ≥1.24 rejects non-empty start body)
|
||||
await startContainerNoBody(container.id)
|
||||
} catch (startErr) {
|
||||
// Rollback: remove container if start failed after create
|
||||
try {
|
||||
@@ -89,17 +90,12 @@ export function registerContainerHandlers(session) {
|
||||
const limit = args.limit != null ? Math.min(Number(args.limit) || 50, 1000) : null
|
||||
const page = limit != null ? containers.slice(offset, offset + limit) : containers.slice(offset)
|
||||
|
||||
const detailed = await Promise.all(
|
||||
page.map(async (container) => {
|
||||
try {
|
||||
const details = await docker.getContainer(container.Id).inspect()
|
||||
return { ...container, ipAddress: extractIpAddress(details) }
|
||||
} catch (error) {
|
||||
logger.error('Failed to inspect container', { id: container.Id, error: error.message })
|
||||
return { ...container, ipAddress: 'Error Retrieving IP' }
|
||||
}
|
||||
})
|
||||
)
|
||||
// IP comes from list payload NetworkSettings — no N× inspect on every refresh
|
||||
// (inspect was slow, raced with event pushes, and caused client list flicker).
|
||||
const detailed = page.map((container) => ({
|
||||
...container,
|
||||
ipAddress: ipFromListContainer(container),
|
||||
}))
|
||||
return {
|
||||
type: 'containers',
|
||||
data: detailed,
|
||||
@@ -116,7 +112,8 @@ export function registerContainerHandlers(session) {
|
||||
})
|
||||
|
||||
session.respond('startContainer', async (args) => {
|
||||
await docker.getContainer(args.id).start()
|
||||
// Must not send a JSON body — Docker API ≥1.24 rejects non-empty start body
|
||||
await startContainerNoBody(args.id)
|
||||
return { success: true, message: `Container ${args.id} started` }
|
||||
})
|
||||
|
||||
@@ -125,7 +122,12 @@ export function registerContainerHandlers(session) {
|
||||
if (args.t != null || args.timeout != null) {
|
||||
opts.t = Number(args.t ?? args.timeout)
|
||||
}
|
||||
await docker.getContainer(args.id).stop(opts)
|
||||
// Only pass opts when we have real stop parameters (avoid empty-body quirks)
|
||||
if (Object.keys(opts).length) {
|
||||
await docker.getContainer(args.id).stop(opts)
|
||||
} else {
|
||||
await docker.getContainer(args.id).stop()
|
||||
}
|
||||
return { success: true, message: `Container ${args.id} stopped` }
|
||||
})
|
||||
|
||||
@@ -134,27 +136,33 @@ export function registerContainerHandlers(session) {
|
||||
if (args.t != null || args.timeout != null) {
|
||||
opts.t = Number(args.t ?? args.timeout)
|
||||
}
|
||||
await docker.getContainer(args.id).restart(opts)
|
||||
if (Object.keys(opts).length) {
|
||||
await docker.getContainer(args.id).restart(opts)
|
||||
} else {
|
||||
await docker.getContainer(args.id).restart()
|
||||
}
|
||||
return { success: true, message: `Container ${args.id} restarted` }
|
||||
})
|
||||
|
||||
session.respond('killContainer', async (args) => {
|
||||
const opts = {}
|
||||
if (args.signal) opts.signal = String(args.signal)
|
||||
await docker.getContainer(args.id).kill(opts)
|
||||
if (args.signal) {
|
||||
await docker.getContainer(args.id).kill({ signal: String(args.signal) })
|
||||
} else {
|
||||
await docker.getContainer(args.id).kill()
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: `Container ${args.id} killed${opts.signal ? ` (${opts.signal})` : ''}`,
|
||||
message: `Container ${args.id} killed${args.signal ? ` (${args.signal})` : ''}`,
|
||||
}
|
||||
})
|
||||
|
||||
session.respond('pauseContainer', async (args) => {
|
||||
await docker.getContainer(args.id).pause()
|
||||
await containerLifecycleNoBody(args.id, 'pause')
|
||||
return { success: true, message: `Container ${args.id} paused` }
|
||||
})
|
||||
|
||||
session.respond('unpauseContainer', async (args) => {
|
||||
await docker.getContainer(args.id).unpause()
|
||||
await containerLifecycleNoBody(args.id, 'unpause')
|
||||
return { success: true, message: `Container ${args.id} unpaused` }
|
||||
})
|
||||
|
||||
@@ -503,7 +511,7 @@ export function registerContainerHandlers(session) {
|
||||
const container = docker.getContainer(containerId)
|
||||
switch (operation) {
|
||||
case 'start':
|
||||
await container.start()
|
||||
await startContainerNoBody(containerId)
|
||||
break
|
||||
case 'stop':
|
||||
await container.stop()
|
||||
@@ -512,13 +520,14 @@ export function registerContainerHandlers(session) {
|
||||
await container.restart()
|
||||
break
|
||||
case 'pause':
|
||||
await container.pause()
|
||||
await containerLifecycleNoBody(containerId, 'pause')
|
||||
break
|
||||
case 'unpause':
|
||||
await container.unpause()
|
||||
await containerLifecycleNoBody(containerId, 'unpause')
|
||||
break
|
||||
case 'kill':
|
||||
await container.kill(args.signal ? { signal: args.signal } : {})
|
||||
if (args.signal) await container.kill({ signal: args.signal })
|
||||
else await container.kill()
|
||||
break
|
||||
case 'remove':
|
||||
await container.remove({ force: true })
|
||||
@@ -635,7 +644,7 @@ async function duplicateContainer(args, session) {
|
||||
NetworkMode: String(netmode),
|
||||
},
|
||||
})
|
||||
await newContainer.start()
|
||||
await startContainerNoBody(newContainer.id)
|
||||
|
||||
await broadcastContainers()
|
||||
return { success: true, message: `Container '${name}' duplicated and started successfully.` }
|
||||
@@ -741,7 +750,7 @@ async function recreateContainer(args, session) {
|
||||
const created = await docker.createContainer(createOpts)
|
||||
|
||||
if (shouldStart) {
|
||||
await created.start()
|
||||
await startContainerNoBody(created.id)
|
||||
}
|
||||
|
||||
await broadcastContainers()
|
||||
@@ -756,10 +765,31 @@ async function recreateContainer(args, session) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prefer NetworkSettings from docker list; fall back to inspect extract shape.
|
||||
* @param {object} container
|
||||
* @returns {string}
|
||||
*/
|
||||
function ipFromListContainer(container) {
|
||||
const nets = container?.NetworkSettings?.Networks
|
||||
if (nets && typeof nets === 'object') {
|
||||
for (const net of Object.values(nets)) {
|
||||
if (net?.IPAddress) return net.IPAddress
|
||||
}
|
||||
}
|
||||
// Some list responses put IP on NetworkSettings.IPAddress
|
||||
if (container?.NetworkSettings?.IPAddress) return container.NetworkSettings.IPAddress
|
||||
return 'No IP Assigned'
|
||||
}
|
||||
|
||||
export async function broadcastContainers() {
|
||||
try {
|
||||
const containers = await docker.listContainers({ all: true })
|
||||
peers.broadcast(Pushes.containers, { type: 'containers', data: containers })
|
||||
const data = containers.map((c) => ({
|
||||
...c,
|
||||
ipAddress: ipFromListContainer(c),
|
||||
}))
|
||||
peers.broadcast(Pushes.containers, { type: 'containers', data })
|
||||
} catch (err) {
|
||||
logger.error('Failed to broadcast containers', { error: err.message })
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Template / container deploy RPC handler.
|
||||
*/
|
||||
import { docker } from '../services/docker.js'
|
||||
import { docker, startContainerNoBody } from '../services/docker.js'
|
||||
import * as validation from '../utils/validation.js'
|
||||
import { broadcastContainers } from './containers.js'
|
||||
import logger from '../utils/logger.js'
|
||||
@@ -256,7 +256,7 @@ export function registerDeployHandlers(session) {
|
||||
}
|
||||
|
||||
try {
|
||||
await container.start()
|
||||
await startContainerNoBody(container.id)
|
||||
} catch (startErr) {
|
||||
const formatted = formatDeployError(startErr, {
|
||||
stage: 'start',
|
||||
|
||||
@@ -41,6 +41,71 @@ export const docker = new Docker({
|
||||
|
||||
export { socketPath as dockerSocketPath }
|
||||
|
||||
/**
|
||||
* Promise wrapper around docker-modem dial.
|
||||
* @param {object} optsf
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function modemDial(optsf) {
|
||||
return new Promise((resolve, reject) => {
|
||||
docker.modem.dial(optsf, (err, data) => {
|
||||
if (err) reject(err)
|
||||
else resolve(data)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a container with an empty POST body.
|
||||
*
|
||||
* dockerode always passes `options: {}` into modem.dial for `.start()`.
|
||||
* Some Engine/API combinations (and proxies) still treat that as a non-empty
|
||||
* JSON body, which Docker rejects since API v1.24:
|
||||
* "starting container with non-empty request body was deprecated..."
|
||||
*
|
||||
* Dial without an `options` field so no body is serialized.
|
||||
*
|
||||
* @param {string} id container id or name
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function startContainerNoBody(id) {
|
||||
const cid = String(id || '').replace(/^\//, '')
|
||||
if (!cid) throw new Error('Container id required')
|
||||
await modemDial({
|
||||
// No trailing '?' — avoids query/body serialization of empty opts
|
||||
path: `/containers/${encodeURIComponent(cid)}/start`,
|
||||
method: 'POST',
|
||||
statusCodes: {
|
||||
204: true,
|
||||
304: true, // already started
|
||||
404: 'no such container',
|
||||
500: 'server error',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Body-less container lifecycle POSTs (pause / unpause).
|
||||
* @param {string} id
|
||||
* @param {'pause'|'unpause'} action
|
||||
*/
|
||||
export async function containerLifecycleNoBody(id, action) {
|
||||
const cid = String(id || '').replace(/^\//, '')
|
||||
if (!cid) throw new Error('Container id required')
|
||||
if (action !== 'pause' && action !== 'unpause') {
|
||||
throw new Error(`Invalid lifecycle action: ${action}`)
|
||||
}
|
||||
await modemDial({
|
||||
path: `/containers/${encodeURIComponent(cid)}/${action}`,
|
||||
method: 'POST',
|
||||
statusCodes: {
|
||||
204: true,
|
||||
404: 'no such container',
|
||||
500: 'server error',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize docker.listVolumes() response shapes across API versions.
|
||||
* @param {object|Array} volumesResult
|
||||
|
||||
@@ -5,15 +5,44 @@
|
||||
import { docker, extractVolumesList } from './docker.js'
|
||||
import { peers } from '../core/peer-registry.js'
|
||||
import { Pushes } from '../../shared/protocol.js'
|
||||
import { broadcastContainers } from '../handlers/containers.js'
|
||||
import logger from '../utils/logger.js'
|
||||
|
||||
let dockerEventStream = null
|
||||
let reconnectTimer = null
|
||||
let stopped = false
|
||||
let reconnectAttempt = 0
|
||||
/** Coalesce noisy container events into one list broadcast */
|
||||
let containerListBroadcastTimer = null
|
||||
|
||||
const BASE_DELAY_MS = 2000
|
||||
const MAX_DELAY_MS = 30_000
|
||||
/** Only these container actions should refresh the fleet list (not exec/health spam) */
|
||||
const CONTAINER_LIST_ACTIONS = new Set([
|
||||
'create',
|
||||
'start',
|
||||
'stop',
|
||||
'die',
|
||||
'kill',
|
||||
'destroy',
|
||||
'remove',
|
||||
'pause',
|
||||
'unpause',
|
||||
'restart',
|
||||
'rename',
|
||||
'update',
|
||||
'oom',
|
||||
])
|
||||
|
||||
function scheduleContainerListBroadcast() {
|
||||
if (containerListBroadcastTimer) return
|
||||
containerListBroadcastTimer = setTimeout(() => {
|
||||
containerListBroadcastTimer = null
|
||||
broadcastContainers().catch((err) => {
|
||||
logger.debug('container list broadcast failed', { error: err.message })
|
||||
})
|
||||
}, 400)
|
||||
}
|
||||
|
||||
export async function startDockerEventStream() {
|
||||
stopped = false
|
||||
@@ -64,9 +93,13 @@ async function openEventStream() {
|
||||
data: event,
|
||||
})
|
||||
|
||||
// Do not rebroadcast the full list on every exec_*/health_status event —
|
||||
// that thrashed clients (rows flicker / disappear on each refresh).
|
||||
if (event.Type === 'container') {
|
||||
const containers = await docker.listContainers({ all: true })
|
||||
peers.broadcast(Pushes.containers, { type: 'containers', data: containers })
|
||||
const action = String(event.Action || event.status || '').split(':')[0]
|
||||
if (CONTAINER_LIST_ACTIONS.has(action)) {
|
||||
scheduleContainerListBroadcast()
|
||||
}
|
||||
}
|
||||
|
||||
if (event.Type === 'image') {
|
||||
|
||||
@@ -7,6 +7,7 @@ import path from 'path'
|
||||
import os from 'os'
|
||||
import yaml from 'js-yaml'
|
||||
import logger from './logger.js'
|
||||
import { startContainerNoBody } from '../services/docker.js'
|
||||
|
||||
/**
|
||||
* Parse docker-compose YAML with js-yaml and normalize service fields.
|
||||
@@ -446,7 +447,7 @@ async function deployViaDockerode(docker, parsed, stackName, opts = {}) {
|
||||
const container = await docker.createContainer(containerConfig)
|
||||
createdIds.push(container.id)
|
||||
try {
|
||||
await container.start()
|
||||
await startContainerNoBody(container.id)
|
||||
} catch (startErr) {
|
||||
if (rollback) {
|
||||
for (const id of createdIds) {
|
||||
|
||||
Reference in New Issue
Block a user