Improve deploy errors and use bottom job tray as sole feedback.
CI / test (push) Successful in 9m56s

Map Docker deploy failures to actionable how-to-fix messages, keep full
detail through RPC sanitization, and stop top-center toasts from doubling
the live job tray so operators only see one notification per action.
This commit is contained in:
2026-07-10 22:40:13 -04:00
parent a42f5e4dcf
commit 1b99224f16
14 changed files with 1089 additions and 133 deletions
+88
View File
@@ -0,0 +1,88 @@
import test from 'brittle'
import {
extractDockerMessage,
extractDockerStatus,
formatDeployError,
sanitizeClientError,
} from '../server/utils/dockerErrors.js'
test('extractDockerMessage prefers json.message', (t) => {
const err = new Error('(HTTP code 409) unexpected - short')
err.json = { message: 'port is already allocated' }
err.statusCode = 409
t.is(extractDockerMessage(err), 'port is already allocated')
t.is(extractDockerStatus(err), 409)
})
test('formatDeployError port conflict is actionable', (t) => {
const err = new Error('(HTTP code 500) server error - Bind for 0.0.0.0:8080 failed: port is already allocated')
err.statusCode = 500
const out = formatDeployError(err, {
stage: 'start',
containerName: 'web',
image: 'nginx:alpine',
})
t.ok(/port conflict/i.test(out.message) || /port/i.test(out.message))
t.ok(/how to fix/i.test(out.message))
t.ok(/8080|host port/i.test(out.message))
})
test('formatDeployError name conflict', (t) => {
const err = new Error(
'Conflict. The container name "/api" is already in use by container "abc123". You have to remove (or rename) that container to be able to reuse that name.'
)
err.statusCode = 409
const out = formatDeployError(err, { stage: 'create', containerName: 'api', image: 'node:20' })
t.ok(/already taken|already/i.test(out.message))
t.ok(/how to fix/i.test(out.message))
t.ok(/different name|remove/i.test(out.message))
})
test('formatDeployError image not found', (t) => {
const err = new Error('manifest for totally-fake/image:nope not found: manifest unknown')
err.statusCode = 404
const out = formatDeployError(err, {
stage: 'pull',
containerName: 'x',
image: 'totally-fake/image:nope',
})
t.ok(/not found/i.test(out.message))
t.ok(/how to fix/i.test(out.message))
t.ok(/image name and tag/i.test(out.message))
})
test('formatDeployError bind mount path', (t) => {
const err = new Error(
'invalid mount config for type "bind": bind source path does not exist: /does/not/exist'
)
const out = formatDeployError(err, { stage: 'create', containerName: 'db', image: 'postgres' })
t.ok(/volume|bind|mount/i.test(out.message))
t.ok(/how to fix/i.test(out.message))
t.ok(/host path|named volume/i.test(out.message))
})
test('sanitizeClientError keeps long deploy messages', (t) => {
const long =
'Port conflict while starting "web" — Host port 8080 is already bound by another process or container. — How to fix: Change the host port in Port mappings, stop the other container using that port, or remove the conflicting publish rule. — (Docker HTTP 500)'
const err = new Error(long)
err.code = 'DOCKER_CONFLICT'
const safe = sanitizeClientError(err)
t.ok(safe.length > 200)
t.ok(/how to fix/i.test(safe))
t.absent(/an error occurred\. please try again/i.test(safe))
})
test('sanitizeClientError redacts secrets but keeps detail', (t) => {
const err = new Error('login failed password=supersecret token=abc123 for registry')
const safe = sanitizeClientError(err)
t.ok(/REDACTED/.test(safe))
t.absent(/supersecret/.test(safe))
t.ok(/login failed/i.test(safe))
})
test('sanitizeClientError does not nuke long messages', (t) => {
const msg = 'x'.repeat(250) + ' useful-detail'
const safe = sanitizeClientError(new Error(msg))
t.ok(safe.length > 200)
t.ok(safe.includes('useful') || safe.endsWith('…') || safe.endsWith('...'))
})
+23
View File
@@ -74,3 +74,26 @@ test('presentError dedupes identical messages', (t) => {
presentError(err, 'deployContainer', { showAlert })
t.is(called, 1)
})
test('explainError surfaces How to fix from deploy messages', (t) => {
const err = new Error(
'Port conflict while starting "web" — Host port 8080 is already bound. — How to fix: Change the host port mapping.'
)
err.code = 'DOCKER_CONFLICT'
const info = explainError(err, 'deployContainer')
t.ok(/port/i.test(info.title + info.message))
t.ok(/host port|change/i.test(info.recovery))
t.is(info.silent, false)
})
test('presentError can suppress toast when job drawer owns UI', (t) => {
let opts = null
const showAlert = (_type, _text, o) => {
opts = o
}
const err = new Error('deploy boom unique suppress toast')
presentError(err, 'deployContainer', { showAlert, force: true, toast: false, tray: false })
t.ok(opts)
t.is(opts.toast, false)
t.is(opts.tray, false)
})
+22
View File
@@ -0,0 +1,22 @@
import test from 'brittle'
import {
markFeedbackShown,
wasFeedbackRecentlyShown,
showAlert,
} from '../libs/uiUtils.js'
test('markFeedbackShown dedupes subsequent showAlert', (t) => {
const msg = `unique-feedback-dedupe-${Date.now()}`
markFeedbackShown('success', msg)
t.ok(wasFeedbackRecentlyShown('success', msg))
// showAlert should no-op (no throw) when recently shown
showAlert('success', msg)
t.pass()
})
test('wasFeedbackRecentlyShown is case/whitespace tolerant via key', (t) => {
const base = ` Case Mix Feedback ${Date.now()} `
markFeedbackShown('danger', base)
t.ok(wasFeedbackRecentlyShown('danger', base.trim()))
})