/** * 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} */ 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, }