forked from snxraven/peardock
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.
545 lines
17 KiB
JavaScript
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,
|
|
}
|