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
+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') {