Files
peardock/client/errors.js
T
Raven Scott bbf0607aaf Fix container remove timeouts and make force-remove reliable.
Lifecycle RPCs now use a 120s operation timeout, remove cleans up
stats/terminal/log streams then SIGKILLs before force-remove, and the
UI awaits the request instead of a fragile 30s wait race.
2026-07-15 14:12:13 -04:00

545 lines
17 KiB
JavaScript

/**
* Human-friendly RPC / UI error model.
*/
const CODE_MAP = {
PERMISSION_DENIED: {
title: 'Permission denied',
recovery: 'Your role cannot perform this action. Ask an admin or open Access.',
severity: 'warning',
},
RATE_LIMIT_EXCEEDED: {
title: 'Rate limited',
recovery: 'Wait a moment, then try again.',
severity: 'warning',
},
ENGINE_CAPABILITY: {
title: 'Docker API too old',
recovery: 'This option needs a newer Docker Engine API. Remove the option or upgrade Docker.',
severity: 'danger',
},
DOCKER_CONFLICT: {
title: 'Docker conflict',
recovery:
'A name or port is already in use. Rename the container, change the host port, or remove the existing resource.',
severity: 'warning',
},
CONTAINER_NAME_CONFLICT: {
title: 'Container name already in use',
recovery:
'Choose Replace to stop and remove the existing container, then deploy with your new settings — or pick a different name.',
severity: 'warning',
},
DOCKER_ERROR: {
title: 'Docker engine error',
recovery: 'Read the error detail below, fix the configuration, and retry deploy.',
severity: 'danger',
},
FEATURE_DISABLED: {
title: 'Feature disabled',
recovery: 'Enable the feature flag on the server (e.g. remove ENABLE_SWARM=0) and reconnect.',
severity: 'warning',
},
INVALID_ARGS: {
title: 'Invalid input',
recovery: 'Check the highlighted fields and try again.',
severity: 'warning',
},
PEER_DENIED: {
title: 'Peer not allowed',
recovery: 'This client key is revoked or not on the allowlist.',
severity: 'danger',
},
PEER_NOT_FOUND: {
title: 'Server not found on the network',
recovery:
'Confirm peardock-server is running, the public key is correct (64 hex from SERVER_PUBLIC_KEY / journal), and the host can reach the internet (HyperDHT). Wait a few seconds after start for the server to announce, then retry.',
severity: 'danger',
},
PEER_CONNECTION_FAILED: {
title: 'Could not reach peardock server',
recovery:
'The server public key was found but the connection failed. Check the server process, NAT/firewall, and try again.',
severity: 'danger',
},
INVITE_INVALID: {
title: 'Invite invalid',
recovery: 'Request a new peardock invite (pd1.…) from an admin.',
severity: 'warning',
},
CAPABILITY_INVALID: {
title: 'Capability invalid',
recovery: 'The connection grant failed HMAC verification. Request a new peardock invite.',
severity: 'danger',
},
CAPABILITY_EXPIRED: {
title: 'Capability expired',
recovery: 'This invite grant has expired. Ask an admin for a new peardock invite.',
severity: 'warning',
},
CAPABILITY_SPENT: {
title: 'Capability revoked or replaced',
recovery:
'This invite grant was deleted or replaced. Ask an admin for a new peardock invite and paste it in Add peer (do not reuse the old string).',
severity: 'warning',
},
ADMIN_PROOF_FAILED: {
title: 'Admin authentication failed',
recovery: 'Check that SERVER_SEED matches this server public key (64 hex characters).',
severity: 'danger',
},
UNKNOWN_METHOD: {
title: 'Unsupported operation',
recovery: 'The peer server is missing this method. Update peardock server and reconnect.',
severity: 'warning',
},
TIMEOUT_EXCEEDED: {
title: 'Operation timed out',
recovery:
'Docker may still be finishing in the background — refresh the list. If the resource remains, retry the action or check the Docker daemon and peer connectivity.',
severity: 'warning',
},
OPERATION_TIMEOUT: {
title: 'Operation timed out',
recovery:
'Docker may still be finishing in the background — refresh the list. If the resource remains, retry the action or check the Docker daemon.',
severity: 'warning',
},
CHANNEL_CLOSED: {
title: 'Connection closed',
recovery: 'The peer disconnected. peardock will try to reconnect automatically.',
severity: 'warning',
},
CHANNEL_DESTROYED: {
title: 'Connection closed',
recovery: 'The peer connection was destroyed. Reconnect if needed.',
severity: 'warning',
},
}
/** Methods that run in the background — failures should not spam the tray */
const BACKGROUND_METHODS = new Set([
'ping',
'getHostSnapshot',
'getMetrics',
'getDockerEvents',
'getSystemDf',
'getSystemInfo',
'getStatsHistory',
'listContainers',
'listImages',
'listNetworks',
'listVolumes',
'listStacks',
'suggestNetworkIPAM',
'suggestResourceName',
'suggestFromImage',
'suggestDefaults',
'listUsedHostPorts',
'warmSnapshot',
])
/** @type {Map<string, number>} */
const recentErrorKeys = new Map()
const DEDUPE_MS = 12_000
/**
* protomux-rpc wraps handler throws as REQUEST_ERROR("Request failed", cause).
* Prefer the cause (server sanitizeError message + code).
* @param {unknown} err
* @returns {Error|unknown}
*/
export function unwrapError(err) {
let cur = err
let depth = 0
while (cur && depth < 6) {
const msg = String(cur.message || '')
const code = cur.code || extractCode(msg)
const isGenericWrapper =
code === 'REQUEST_ERROR' ||
/^REQUEST_ERROR:\s*Request failed$/i.test(msg) ||
/^Request failed$/i.test(msg.trim())
if (isGenericWrapper && cur.cause) {
cur = cur.cause
depth += 1
continue
}
break
}
return cur || err
}
/**
* @param {unknown} err
* @param {string} [method]
*/
export function explainError(err, method) {
const root = unwrapError(err)
const rawMessage = root?.message || err?.message || String(err || 'Unknown error')
const message = stripPrefix(rawMessage)
// Prefer the unwrapped cause's code; ignore outer REQUEST_ERROR wrapper code
const unwrapped = root && root !== err
const code =
root?.code ||
(!unwrapped ? err?.code : null) ||
extractCode(rawMessage) ||
extractCode(message) ||
null
const mapped = code ? CODE_MAP[code] : null
if (mapped) {
return {
code,
method: method || err?.method || null,
title: mapped.title,
message: isGenericRequestFailed(message) ? mapped.title : message,
recovery: mapped.recovery,
severity: mapped.severity,
silent: false,
}
}
// Bare "timeout of Nms exceeded" without a code (protomux-rpc) — not a lost peer
if (/timeout of \d+ms exceeded/i.test(message)) {
return {
code: code || 'TIMEOUT_EXCEEDED',
method: method || err?.method || null,
title: 'Operation timed out',
message: isGenericRequestFailed(message)
? 'The server took too long to finish this Docker operation'
: message,
recovery:
'Docker may still be finishing in the background — refresh the list. If the resource remains, retry the action or check the Docker daemon and peer connectivity.',
severity: 'warning',
silent: false,
}
}
if (
/not connected|timeout|ECONN|disconnect|CHANNEL_|PEER_NOT_FOUND|PEER_CONNECTION_FAILED|peer not found/i.test(
message + (code || '')
)
) {
const isMissing =
code === 'PEER_NOT_FOUND' || /peer not found|PEER_NOT_FOUND/i.test(message + (code || ''))
return {
code: code || (isMissing ? 'PEER_NOT_FOUND' : 'NETWORK'),
method: method || err?.method || null,
title: isMissing ? 'Server not found on the network' : 'Connection problem',
message: isGenericRequestFailed(message)
? isMissing
? 'Peer not found'
: 'Lost connection to peer'
: message,
recovery: isMissing
? 'Confirm peardock-server is running, the public key matches SERVER_PUBLIC_KEY, and both sides can use HyperDHT. Retry after the server has announced.'
: 'Check the peer is online. peardock will try to reconnect automatically.',
severity: isMissing ? 'danger' : 'warning',
silent: false,
}
}
// Docker deploy / engine patterns only — never for connect / handshake auth
const dockerHint =
method === 'connect' || method === 'handshake' ? null : classifyDockerMessage(message, method)
if (dockerHint) {
return {
code: code || dockerHint.code,
method: method || err?.method || null,
title: dockerHint.title,
message: dockerHint.message,
recovery: dockerHint.recovery,
severity: dockerHint.severity,
silent: false,
}
}
// Bare "Request failed" with no useful detail — not user-actionable noise
if (isGenericRequestFailed(message)) {
return {
code: code || 'REQUEST_ERROR',
method: method || err?.method || null,
title: method ? `${method} failed` : 'Request failed',
message: method
? `The peer could not complete “${method}”.`
: 'The peer could not complete the request.',
recovery: 'Check the server logs and Docker engine health, then retry.',
severity: 'warning',
// Always silent for empty wrapper noise — real causes are unwrapped above
silent: true,
}
}
return {
code: code || 'UNKNOWN',
method: method || err?.method || null,
title: method ? `${method} failed` : 'Something went wrong',
message,
recovery: 'Retry the action. If it keeps failing, check server logs.',
severity: 'danger',
silent: false,
}
}
/**
* Richer titles / recovery for common Docker + deploy failure text.
* @param {string} message
* @param {string} [method]
*/
function classifyDockerMessage(message, method) {
const m = String(message || '')
const lower = m.toLowerCase()
const isDeploy = /deploy/i.test(method || '') || /deploy|container|pull image|port conflict/i.test(lower)
// Server already produced a full "How to fix:" message — surface it whole
if (/how to fix:/i.test(m)) {
const parts = m.split(/\s*—\s*How to fix:\s*/i)
const head = parts[0] || m
const recovery = parts[1] || 'Fix the configuration shown in the error and retry.'
// First segment often "Title — detail"
const titleParts = head.split(/\s*—\s*/)
return {
code: 'DOCKER_ERROR',
title: titleParts[0] || (isDeploy ? 'Deploy failed' : 'Docker error'),
message: titleParts.slice(1).join(' — ') || head,
recovery: recovery.replace(/\s*\(Docker HTTP \d+\)\s*$/i, '').trim(),
severity: /conflict|already|port/i.test(lower) ? 'warning' : 'danger',
}
}
if (/port is already allocated|address already in use|port conflict/i.test(lower)) {
return {
code: 'DOCKER_CONFLICT',
title: 'Host port already in use',
message: m,
recovery:
'Change the host port mapping, stop the other container using that port, or remove the publish rule.',
severity: 'warning',
}
}
if (
/container name|already in use by container|name .* already|already exists/i.test(lower) &&
/use|conflict|taken|exists|replace/i.test(lower)
) {
return {
code: 'CONTAINER_NAME_CONFLICT',
title: 'Container name already taken',
message: m,
recovery:
'Choose Replace when prompted to stop and remove the existing container, or pick a different name.',
severity: 'warning',
}
}
// Keep this narrow — bare "not found" also appears in PEER_NOT_FOUND and path errors
if (
/manifest unknown|repository does not exist|no such image|pull access denied for .* not found|image .* not found|Error: No such image/i.test(
lower
) ||
(/no such image|image not found|manifest for .* not found/i.test(lower) &&
!/peer|network|volume|container|path|file/i.test(lower))
) {
return {
code: 'DOCKER_ERROR',
title: 'Image not found',
message: m,
recovery: 'Check the image name and tag. Private registries need the full path and credentials.',
severity: 'danger',
}
}
if (/pull access denied|unauthorized|authentication required/i.test(lower)) {
return {
code: 'DOCKER_ERROR',
title: 'Registry authentication required',
message: m,
recovery: 'Log in to the registry on the host or store credentials in Vault, then retry.',
severity: 'warning',
}
}
if (/bind source path does not exist|invalid mount|no such file or directory/i.test(lower)) {
return {
code: 'DOCKER_ERROR',
title: 'Bind mount path missing',
message: m,
recovery:
'Create the host directory on the Docker host, fix the path, or use a named volume instead.',
severity: 'danger',
}
}
if (/network .* not found|no such network/i.test(lower)) {
return {
code: 'DOCKER_ERROR',
title: 'Network not found',
message: m,
recovery: 'Select an existing network or create one under Networks.',
severity: 'warning',
}
}
if (/is the docker daemon running|cannot connect to the docker|docker socket/i.test(lower)) {
return {
code: 'DOCKER_ERROR',
title: 'Docker Engine unreachable',
message: m,
recovery: 'Start Docker on the peardock server host and ensure socket permissions are correct.',
severity: 'danger',
}
}
if (isDeploy && m.length > 20) {
return {
code: 'DOCKER_ERROR',
title: method === 'deployContainer' ? 'Container deploy failed' : 'Operation failed',
message: m,
recovery: 'Read the detail above, adjust the form fields that caused it, and retry.',
severity: 'danger',
}
}
return null
}
function isGenericRequestFailed(message) {
const m = String(message || '')
.replace(/^REQUEST_ERROR:\s*/i, '')
.trim()
return !m || /^request failed$/i.test(m)
}
export function isBackgroundMethod(method) {
if (!method) return false
return BACKGROUND_METHODS.has(String(method))
}
function extractCode(message) {
const m = String(message || '').match(/\b([A-Z][A-Z0-9_]{5,})\b/)
return m ? m[1] : null
}
function stripPrefix(message) {
return String(message)
.replace(/^REQUEST_ERROR:\s*/i, '')
.replace(/^UNKNOWN_METHOD:\s*/i, '')
.replace(/^TIMEOUT_EXCEEDED:\s*/i, '')
.replace(/^CHANNEL_CLOSED:\s*/i, '')
.replace(/^CHANNEL_DESTROYED:\s*/i, '')
.replace(/^Error:\s*/i, '')
.slice(0, 900)
}
/**
* Build a richer Error for throwing / emitting (preserves method + unwrapped message).
* @param {unknown} err
* @param {string} [method]
*/
export function normalizeRpcError(err, method) {
const root = unwrapError(err)
const message = stripPrefix(root?.message || err?.message || 'Request failed')
const out = new Error(
isGenericRequestFailed(message) && method
? `${method} failed on peer`
: message || 'Request failed'
)
out.code = root?.code || err?.code || 'REQUEST_ERROR'
out.method = method || err?.method || null
out.cause = err
return out
}
/**
* Present error via toast (showAlert already writes the notification tray).
* Suppresses background noise and short-window duplicates.
*
* @param {unknown} err
* @param {string} [method]
* @param {{ showAlert?: Function, silent?: boolean, force?: boolean }} [opts]
*/
export function presentError(
err,
method,
{ showAlert, silent = false, force = false, toast, tray } = {}
) {
const info = explainError(err, method)
const effectiveSilent =
silent === true ||
info.silent === true ||
(isBackgroundMethod(method || info.method) && isGenericRequestFailed(info.message))
if (effectiveSilent && !force) {
console.warn(
`[RPC quiet] ${info.method || method || 'request'}:`,
info.message,
info.code ? `(${info.code})` : ''
)
return info
}
const key = `${info.code}|${info.method || ''}|${info.message}`
const now = Date.now()
const last = recentErrorKeys.get(key) || 0
if (!force && now - last < DEDUPE_MS) {
console.debug('[RPC deduped]', key)
return info
}
recentErrorKeys.set(key, now)
// prune map
if (recentErrorKeys.size > 80) {
for (const [k, t] of recentErrorKeys) {
if (now - t > DEDUPE_MS * 2) recentErrorKeys.delete(k)
}
}
const text = `${info.title}: ${info.message}${info.recovery ? ` — ${info.recovery}` : ''}`
if (typeof showAlert === 'function') {
const alertOpts = {
duration: info.severity === 'warning' ? 8000 : 12000,
key: `rpc-err:${key}`,
// Top-center toasts off unless caller forces them — job tray / bell only
toast: toast === true,
}
if (tray !== undefined) alertOpts.tray = tray
showAlert(info.severity === 'warning' ? 'warning' : 'danger', text, alertOpts)
}
return info
}
/**
* Format a raw error string (from handleErrorResponse) into a clean message.
* Returns null if the error is useless noise that should not be shown.
* @param {string|object} errorField
* @param {string} [method]
* @returns {{ message: string, severity: string, silent: boolean }|null}
*/
export function formatResponseError(errorField, method) {
const raw =
typeof errorField === 'string'
? errorField
: errorField?.message || errorField?.toString?.() || ''
if (!raw) return null
// Synthesize an Error so explainError can unwrap
const err = new Error(raw)
if (typeof errorField === 'object' && errorField?.code) err.code = errorField.code
if (typeof errorField === 'object' && errorField?.cause) err.cause = errorField.cause
const info = explainError(err, method || errorField?.method)
if (
info.silent ||
(isBackgroundMethod(method || info.method) && isGenericRequestFailed(stripPrefix(raw)))
) {
return { message: info.message, severity: info.severity, silent: true }
}
return {
message: `${info.title}: ${info.message}`,
severity: info.severity,
silent: false,
}
}
export default {
explainError,
presentError,
unwrapError,
normalizeRpcError,
formatResponseError,
isBackgroundMethod,
}