Stabilize multi-peer UI and fix container start API body
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:
Raven Scott
2026-07-11 16:52:57 -04:00
parent 79f57d556b
commit c49d7301d0
10 changed files with 986 additions and 149 deletions
+59 -29
View File
@@ -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 })
}
+2 -2
View File
@@ -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',
+65
View File
@@ -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
+35 -2
View File
@@ -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') {
+2 -1
View File
@@ -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) {