Persist peers to disk cache and stop Request failed spam
CI / test (push) Successful in 9m57s

Save configured connections to ~/.config/peardock/cache/peers.json (with localStorage migration), unwrap protomux REQUEST_ERROR causes for real messages, and quiet/dedupe background RPC failures so the notification tray is not flooded.
This commit is contained in:
2026-07-10 22:16:28 -04:00
parent 20d078fdc6
commit 60816ed78b
9 changed files with 904 additions and 268 deletions
+99 -174
View File
@@ -1,4 +1,10 @@
import { manager, Methods } from './client/manager.js';
import {
loadPeers,
savePeers,
clearPeers,
getPeersCachePath,
} from './client/peerCache.js';
import { startTerminal, appendTerminalOutput } from './libs/terminal.js';
import { startDockerTerminal, cleanUpDockerTerminal } from './libs/dockerTerminal.js';
import {
@@ -14,7 +20,7 @@ import { showContainerSkeleton, createProgressBar, updateProgressBar, removeProg
import { closeAllModals, showStatusIndicator, hideStatusIndicator, updateStatusIndicator, showAlert } from './libs/uiUtils.js';
import notificationManager from './libs/notifications.js';
import { initOpsApp } from './ui/ops-app.js';
import { presentError } from './client/errors.js';
import { presentError, formatResponseError, isBackgroundMethod } from './client/errors.js';
import { warmSnapshot } from './client/snapshot.js';
import {
getTerminalCtor,
@@ -342,36 +348,8 @@ function waitForPeerResponse(expectedMessageFragment, timeout = 900000) {
});
}
// Utility functions for managing cookies and localStorage
// Pear/desktop apps often do not persist document.cookie — always prefer localStorage.
const COOKIE_SIZE_LIMIT = 4000; // 4KB limit for cookies
const CONNECTIONS_STORAGE_KEY = 'peardock_connections';
const USE_LOCALSTORAGE_KEY = 'peardock_use_localstorage';
function setCookie(name, value, days = 365) {
const date = new Date();
date.setTime(date.getTime() + days * 24 * 60 * 60 * 1000);
const expires = `expires=${date.toUTCString()}`;
const cookieValue = `${name}=${encodeURIComponent(value)};${expires};path=/`;
try {
document.cookie = cookieValue;
} catch (err) {
console.warn(`[WARN] Failed to set cookie ${name}: ${err.message}`);
}
}
function getCookie(name) {
try {
const cookies = document.cookie.split('; ');
for (let i = 0; i < cookies.length; i++) {
const [key, value] = cookies[i].split('=');
if (key === name) return decodeURIComponent(value);
}
} catch {
// ignore
}
return null;
}
// Peer persistence: ~/.config/peardock/cache/peers.json (primary)
// localStorage is only a mirror / migration source via client/peerCache.js
function deleteCookie(name) {
try {
@@ -382,40 +360,20 @@ function deleteCookie(name) {
}
/**
* Read raw saved connections JSON from localStorage (primary) then cookies (legacy).
* @returns {string|null}
* Load saved peers from disk cache (migrates legacy localStorage on first run).
* @returns {Record<string, object>}
*/
function readConnectionsRaw() {
try {
const fromLs = localStorage.getItem(CONNECTIONS_STORAGE_KEY);
if (fromLs) return fromLs;
} catch (err) {
console.warn(`[WARN] localStorage read failed: ${err.message}`);
}
// Legacy: cookie-only storage (and older flag values 'true' / '1')
return getCookie('connections');
}
// Load saved server public keys (localStorage first — survives Pear restarts)
function loadConnections() {
let savedConnections = null;
try {
savedConnections = readConnectionsRaw();
} catch (err) {
console.warn(`[WARN] Failed to load connections: ${err.message}`);
}
/** @type {Record<string, object>} */
let parsed = {};
if (savedConnections) {
try {
parsed = JSON.parse(savedConnections);
} catch (err) {
console.error(`[ERROR] Corrupt connections storage: ${err.message}`);
parsed = {};
}
try {
parsed = loadPeers() || {};
} catch (err) {
console.warn(`[WARN] Failed to load peer cache: ${err.message}`);
parsed = {};
}
// Also merge manager-compatible list if present
// Merge manager list if it has extras
try {
const fromManager = manager.loadSaved?.() || [];
for (const entry of fromManager) {
@@ -436,13 +394,12 @@ function loadConnections() {
const result = {};
for (const topicId in parsed) {
const entry = parsed[topicId] || {};
// publicKeyHex is modern; topicHex is legacy hyperswarm topic storage
const publicKeyHex = String(entry.publicKeyHex || entry.topicHex || entry.topic || '').toLowerCase();
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) continue;
const id = publicKeyHex.substring(0, 12);
result[id] = {
publicKeyHex,
topicHex: publicKeyHex, // keep field name for older UI bindings
topicHex: publicKeyHex,
alias: entry.alias || null,
inviteToken: entry.inviteToken || null,
peer: null,
@@ -456,8 +413,9 @@ function loadConnections() {
return result;
}
// Save configured peers to localStorage (primary) + cookie backup when small
/**
* Save configured peers to ~/.config/peardock/cache/peers.json
*/
function saveConnections() {
const serializableConnections = {};
@@ -473,19 +431,17 @@ function saveConnections() {
};
}
const serialized = JSON.stringify(serializableConnections);
try {
localStorage.setItem(CONNECTIONS_STORAGE_KEY, serialized);
localStorage.setItem(USE_LOCALSTORAGE_KEY, '1');
console.log('[INFO] Saved connections to localStorage', Object.keys(serializableConnections).length);
const result = savePeers(serializableConnections);
console.log(
'[INFO] Saved',
result.count,
'peer(s) to',
result.path || getPeersCachePath(),
result.ok ? '' : '(file write failed; localStorage mirror may still apply)'
);
} catch (err) {
console.error(`[ERROR] Failed to save connections to localStorage: ${err.message}`);
}
// Cookie backup for web contexts (may not work in Pear)
if (serialized.length <= COOKIE_SIZE_LIMIT) {
setCookie('connections', serialized);
console.error(`[ERROR] Failed to save peer cache: ${err.message}`);
}
}
@@ -578,23 +534,19 @@ function createPeerListItem(topicId, conn) {
/** Clear all saved peers (Settings action). */
function resetAllPeers() {
console.log('[INFO] Resetting connections and clearing storage.');
console.log('[INFO] Resetting connections and clearing peer cache at', getPeersCachePath());
Object.keys(connections).forEach((topicId) => {
disconnectConnection(topicId);
});
deleteCookie('connections');
try {
localStorage.removeItem(USE_LOCALSTORAGE_KEY);
localStorage.removeItem(CONNECTIONS_STORAGE_KEY);
clearPeers();
} catch (err) {
console.warn(`[WARN] Failed to clear localStorage: ${err.message}`);
console.warn(`[WARN] Failed to clear peer cache: ${err.message}`);
}
if (typeof connectionList !== 'undefined' && connectionList) {
connectionList.innerHTML = '';
}
if (typeof resetConnectionsView === 'function') {
// list already cleared; keep helper for any side effects
}
showWelcomePage();
showAlert('success', 'All saved peers removed');
}
@@ -661,104 +613,70 @@ console.log('[INFO] Client app initialized');
* @param {Object} response - Server response object
* @returns {string|null} - Formatted error message or null if no error
*/
/**
* Handle error field on RPC responses / manager.send failures.
* Suppresses protomux "REQUEST_ERROR: Request failed" spam and background poll noise.
* @returns {string|null} user-facing message if shown
*/
function handleErrorResponse(response) {
// Check if response has an error
if (!response || !response.error) {
return null;
}
const errorMessage = typeof response.error === 'string'
? response.error
: (response.error?.message || response.error?.toString() || 'Unknown error');
// Background / silent errors: log only (never tray spam)
if (response.silent || isBackgroundMethod(response.method)) {
console.warn(
'[RPC quiet]',
response.method || 'request',
typeof response.error === 'string' ? response.error : response.error?.message || response.error
);
return null;
}
// Parse Docker API errors
let formattedMessage = errorMessage;
let errorType = 'danger';
let operation = 'Operation';
const formatted = formatResponseError(response.error, response.method);
if (!formatted || formatted.silent) {
console.warn('[RPC]', response.method || 'request', response.error);
return null;
}
// Extract operation type from error message or response context
if (errorMessage.includes('volume')) {
operation = 'Volume';
if (errorMessage.includes('in use')) {
// Extract volume name and container ID if available
const volumeMatch = errorMessage.match(/remove\s+([^\s:]+)/);
const containerMatch = errorMessage.match(/\[([a-f0-9]+)\]/);
if (volumeMatch && containerMatch) {
formattedMessage = `Cannot remove volume "${volumeMatch[1]}": volume is in use by container ${containerMatch[1].substring(0, 12)}`;
} else if (volumeMatch) {
formattedMessage = `Cannot remove volume "${volumeMatch[1]}": volume is in use`;
} else {
formattedMessage = 'Cannot remove volume: volume is in use by a container';
}
} else if (errorMessage.includes('not found')) {
formattedMessage = 'Volume not found';
} else if (errorMessage.includes('create')) {
formattedMessage = `Failed to create volume: ${errorMessage.replace(/.*create\s+volume[:\s]+/i, '')}`;
} else if (errorMessage.includes('remove')) {
formattedMessage = `Failed to remove volume: ${errorMessage.replace(/.*remove\s+volume[:\s]+/i, '')}`;
}
} else if (errorMessage.includes('container')) {
operation = 'Container';
if (errorMessage.includes('not found')) {
formattedMessage = 'Container not found';
} else if (errorMessage.includes('already exists')) {
formattedMessage = 'Container with this name already exists';
} else if (errorMessage.includes('in use')) {
formattedMessage = 'Container is in use and cannot be removed';
} else if (errorMessage.includes('409') || errorMessage.includes('conflict')) {
formattedMessage = 'Container operation conflict: resource is in use';
}
} else if (errorMessage.includes('image')) {
operation = 'Image';
if (errorMessage.includes('not found')) {
formattedMessage = 'Image not found';
} else if (errorMessage.includes('pull')) {
formattedMessage = `Failed to pull image: ${errorMessage.replace(/.*pull[:\s]+/i, '')}`;
}
} else if (errorMessage.includes('network')) {
operation = 'Network';
if (errorMessage.includes('not found')) {
formattedMessage = 'Network not found';
} else if (errorMessage.includes('already exists')) {
formattedMessage = 'Network with this name already exists';
}
} else if (errorMessage.includes('HTTP code 409') || errorMessage.includes('conflict')) {
formattedMessage = 'Resource conflict: the resource is currently in use';
} else if (errorMessage.includes('HTTP code 404') || errorMessage.includes('not found')) {
formattedMessage = 'Resource not found';
} else if (errorMessage.includes('HTTP code 403') || errorMessage.includes('permission') || errorMessage.includes('access denied')) {
operation = 'Permission';
formattedMessage = 'Access denied: insufficient permissions';
errorType = 'warning';
} else if (errorMessage.includes('timeout') || errorMessage.includes('ETIMEDOUT')) {
operation = 'Network';
formattedMessage = 'Operation timed out: server did not respond in time';
} else if (errorMessage.includes('ECONNREFUSED') || errorMessage.includes('connection')) {
operation = 'Network';
formattedMessage = 'Connection failed: unable to reach server';
} else if (errorMessage.includes('validation') || errorMessage.includes('invalid')) {
operation = 'Validation';
formattedMessage = `Invalid input: ${errorMessage.replace(/.*validation[:\s]+/i, '').replace(/.*invalid[:\s]+/i, '')}`;
let formattedMessage = formatted.message;
let errorType = formatted.severity === 'warning' ? 'warning' : 'danger';
// Domain-specific polish for Docker engine errors
const lower = formattedMessage.toLowerCase();
if (lower.includes('volume') && lower.includes('in use')) {
formattedMessage = 'Cannot remove volume: it is still in use by a container';
} else if (lower.includes('permission denied') || lower.includes('permission_denied')) {
errorType = 'warning';
}
// Clean up the message - remove technical details that aren't user-friendly
formattedMessage = formattedMessage
.replace(/^REQUEST_ERROR:\s*/i, '')
.replace(/\(HTTP code \d+\)\s*/g, '')
.replace(/\[.*?\]/g, '')
.replace(/\s+/g, ' ')
.trim();
// Truncate if too long
if (formattedMessage.length > 200) {
formattedMessage = formattedMessage.substring(0, 197) + '...';
}
// Send to notification center
if (typeof notificationManager !== 'undefined') {
// Skip useless generic leftover
if (/^request failed$/i.test(formattedMessage) || /^request_error:\s*request failed$/i.test(formattedMessage)) {
console.warn('[RPC] suppressed generic Request failed', response.method || '');
return null;
}
// Prefer toast path (also writes tray once) over raw notificationManager double-add
if (typeof showAlert === 'function') {
showAlert(errorType, formattedMessage, {
autoDismiss: true,
duration: errorType === 'warning' ? 6000 : 8000,
key: `rpc:${response.method || ''}:${formattedMessage.slice(0, 80)}`,
});
} else if (typeof notificationManager !== 'undefined') {
notificationManager.add(errorType, formattedMessage, {
autoDismiss: errorType === 'warning' ? true : false, // Keep errors visible longer
duration: errorType === 'warning' ? 8000 : 10000
autoDismiss: true,
duration: errorType === 'warning' ? 8000 : 10000,
});
}
@@ -5739,8 +5657,9 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// Restore configured peers from localStorage and re-dial
// Restore configured peers from ~/.config/peardock/cache/peers.json and re-dial
try {
console.log('[INFO] Peer cache path:', getPeersCachePath());
const savedConnections = loadConnections();
const keys = Object.keys(savedConnections);
console.log('[INFO] Loading saved connections:', keys.length, keys);
@@ -5968,23 +5887,29 @@ function switchConnection(topicId) {
window.switchConnection = switchConnection;
// Send a command to the active peer via protomux-rpc
function sendCommand(command, args = {}) {
// opts.silent: suppress user-facing error toast (background polls)
function sendCommand(command, args = {}, opts = {}) {
if (!manager.active?.connected) {
console.debug('[DEBUG] No active peer to send command (this is normal during initialization).');
return Promise.resolve(null);
}
console.log(`[DEBUG] RPC ${command}`, args);
return manager.send(command, args).then((response) => {
if (response) {
// Route request responses through the same UI pipeline as pushes
handleRpcMessage(response, manager.active);
}
return response;
}).catch((err) => {
console.error(`[ERROR] RPC ${command} failed:`, err?.message || err);
presentError(err, command, { showAlert, notificationManager });
return null;
});
const silent = opts.silent === true;
if (!silent) console.log(`[DEBUG] RPC ${command}`, args);
return manager
.send(command, args, { silent })
.then((response) => {
if (response) {
// Route request responses through the same UI pipeline as pushes
handleRpcMessage(response, manager.active);
}
return response;
})
.catch((err) => {
// manager.send normally swallows; keep presentError for unexpected throws
console.error(`[ERROR] RPC ${command} failed:`, err?.message || err);
presentError(err, command, { showAlert, silent });
return null;
});
}
// Attach sendCommand to the global window object
+23 -5
View File
@@ -7,6 +7,15 @@ import b4a from 'b4a'
import { EventEmitter } from 'events'
import { PROTOCOL, Pushes, PushToType, Methods } from '../shared/protocol.js'
import { encodings } from '../shared/encodings.js'
import { normalizeRpcError } from './errors.js'
/**
* @param {unknown} err
* @param {string} method
*/
function improveRpcError(err, method) {
return normalizeRpcError(err, method)
}
/**
* @typedef {object} ConnectionOptions
@@ -166,6 +175,8 @@ export class PearDockConnection extends EventEmitter {
/**
* RPC request to the server.
* protomux wraps handler throws as REQUEST_ERROR("Request failed", cause);
* we rethrow with the unwrapped server message so UI does not show that junk.
* @param {string} method
* @param {object} [args]
* @param {object} [opts]
@@ -173,12 +184,19 @@ export class PearDockConnection extends EventEmitter {
*/
async request(method, args = {}, opts = {}) {
if (!this.rpc || this.rpc.closed) {
throw new Error('Not connected')
const err = new Error('Not connected')
err.code = 'NOT_CONNECTED'
err.method = method
throw err
}
try {
return await this.rpc.request(method, args, {
...encodings,
timeout: opts.timeout ?? this.timeoutMs,
})
} catch (err) {
throw improveRpcError(err, method)
}
return this.rpc.request(method, args, {
...encodings,
timeout: opts.timeout ?? this.timeoutMs,
})
}
/**
+231 -18
View File
@@ -38,6 +38,79 @@ const CODE_MAP = {
recovery: 'Request a new invite token from an admin.',
severity: 'warning',
},
UNKNOWN_METHOD: {
title: 'Unsupported operation',
recovery: 'The peer server is missing this method. Update peardock server and reconnect.',
severity: 'warning',
},
TIMEOUT_EXCEEDED: {
title: 'Timed out',
recovery: 'The peer did not respond in time. Check the network and try again.',
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
}
/**
@@ -45,64 +118,204 @@ const CODE_MAP = {
* @param {string} [method]
*/
export function explainError(err, method) {
const message = err?.message || String(err || 'Unknown error')
const code = err?.code || extractCode(message)
const mapped = CODE_MAP[code]
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 || null,
method: method || err?.method || null,
title: mapped.title,
message: stripPrefix(message),
message: isGenericRequestFailed(message) ? mapped.title : message,
recovery: mapped.recovery,
severity: mapped.severity,
silent: false,
}
}
if (/not connected|timeout|ECONN|disconnect/i.test(message)) {
if (/not connected|timeout|ECONN|disconnect|CHANNEL_/i.test(message + (code || ''))) {
return {
code: 'NETWORK',
method: method || null,
code: code || 'NETWORK',
method: method || err?.method || null,
title: 'Connection problem',
message: stripPrefix(message),
message: isGenericRequestFailed(message) ? 'Lost connection to peer' : message,
recovery: 'Check the peer is online. peardock will try to reconnect automatically.',
severity: 'danger',
severity: 'warning',
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 || null,
method: method || err?.method || null,
title: method ? `${method} failed` : 'Something went wrong',
message: stripPrefix(message),
message,
recovery: 'Retry the action. If it keeps failing, check server logs.',
severity: 'danger',
silent: false,
}
}
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_]{6,})\b/)
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, 400)
}
/**
* Present error via toast (showAlert already writes the notification tray).
* Do not call notificationManager.add a second time — wrong signature and double noise.
* Build a richer Error for throwing / emitting (preserves method + unwrapped message).
* @param {unknown} err
* @param {string} [method]
*/
export function presentError(err, method, { showAlert } = {}) {
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 } = {}) {
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') {
showAlert(info.severity === 'warning' ? 'warning' : 'danger', text, {
duration: 7000,
duration: info.severity === 'warning' ? 6000 : 8000,
key: `rpc-err:${key}`,
})
}
return info
}
export default { explainError, presentError }
/**
* 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,
}
+40 -57
View File
@@ -5,9 +5,13 @@ import { EventEmitter } from 'events'
import { PearDockConnection } from './connection.js'
import { Methods } from '../shared/protocol.js'
import { CONFIG } from '../config.js'
const STORAGE_KEY = CONFIG.STORAGE.CONNECTIONS_KEY
const USE_LS_KEY = CONFIG.STORAGE.USE_LOCALSTORAGE_KEY
import {
loadPeers,
savePeers,
listSavedPeers,
getPeersCachePath,
} from './peerCache.js'
import { normalizeRpcError, isBackgroundMethod } from './errors.js'
const MAX_RECONNECT_ATTEMPTS = 12
const BASE_RECONNECT_MS = CONFIG.CONNECTION.RECONNECT_DELAY_MS || 5000
@@ -247,24 +251,37 @@ export class ConnectionManager extends EventEmitter {
* Convenience: send a named method (same names as Methods).
* Fire-and-forget request that still awaits (for errors).
* UI historically called sendCommand without awaiting.
* @param {string} method
* @param {object} [args]
* @param {{ silent?: boolean }} [opts]
*/
send(method, args = {}) {
send(method, args = {}, opts = {}) {
if (!this.active?.connected) {
console.debug('[DEBUG] No active connection for', method)
return Promise.resolve(null)
}
return this.active.request(method, args).catch((err) => {
this.emit('message', {
error: err.message,
code: err.code || 'UNKNOWN_ERROR',
}, this.active)
const normalized = normalizeRpcError(err, method)
// Emit structured error so handleRpcMessage can quiet background noise.
// Only force silent when the caller opts in (auto-refresh) — user actions still surface real errors.
this.emit(
'message',
{
error: normalized.message,
code: normalized.code || 'UNKNOWN_ERROR',
method,
silent: opts.silent === true,
cause: err?.cause?.message || undefined,
},
this.active
)
return null
})
}
/**
* Persist connection keys (not live sockets).
* Always writes localStorage (Pear/desktop does not reliably keep cookies).
* Writes ~/.config/peardock/cache/peers.json (primary) + localStorage mirror.
* Merges with any existing stored peers so a transient disconnect does not
* wipe the configured roster — full remove goes through disconnect().
* @param {{ removeId?: string }} [opts]
@@ -272,8 +289,7 @@ export class ConnectionManager extends EventEmitter {
persist(opts = {}) {
let existing = {}
try {
const raw = localStorage.getItem(STORAGE_KEY)
if (raw) existing = JSON.parse(raw) || {}
existing = loadPeers() || {}
} catch {
existing = {}
}
@@ -301,63 +317,30 @@ export class ConnectionManager extends EventEmitter {
}
}
const json = JSON.stringify(serializable)
try {
localStorage.setItem(STORAGE_KEY, json)
localStorage.setItem(USE_LS_KEY, '1')
const result = savePeers(serializable)
if (result.ok) {
console.log(
'[INFO] Persisted',
result.count,
'peer(s) →',
result.path || getPeersCachePath()
)
}
} catch (err) {
console.error('[ERROR] Failed to persist connections', err)
}
// Optional cookie backup for browser contexts
try {
if (json.length <= CONFIG.STORAGE.COOKIE_SIZE_LIMIT) {
document.cookie = `connections=${encodeURIComponent(json)};path=/;max-age=31536000`
}
} catch {
// ignore
}
}
/**
* Load saved public keys (does not auto-connect).
* Load saved public keys from disk cache (does not auto-connect).
* @returns {Array<{ id: string, publicKeyHex: string, alias: string|null, inviteToken?: string|null }>}
*/
loadSaved() {
let raw = null
try {
raw = localStorage.getItem(STORAGE_KEY)
} catch {
// ignore
}
if (!raw) {
try {
const match = document.cookie.match(/(?:^|; )connections=([^;]*)/)
if (match) raw = decodeURIComponent(match[1])
} catch {
// ignore
}
}
if (!raw) return []
try {
const parsed = JSON.parse(raw)
return Object.entries(parsed).map(([id, value]) => {
// migrate old topicHex → publicKeyHex
const publicKeyHex = (
value.publicKeyHex ||
value.topicHex ||
value.topic ||
''
).toLowerCase()
return {
id: id || publicKeyHex.slice(0, 12),
publicKeyHex,
alias: value.alias || null,
inviteToken: value.inviteToken || null,
}
}).filter((e) => /^[0-9a-f]{64}$/.test(e.publicKeyHex))
} catch {
return listSavedPeers()
} catch (err) {
console.warn('[WARN] loadSaved failed', err?.message || err)
return []
}
}
+299
View File
@@ -0,0 +1,299 @@
/**
* Peer connection roster cache.
*
* Primary path (desktop / Pear):
* ~/.config/peardock/cache/peers.json
*
* Falls back to localStorage when the filesystem is unavailable
* (pure browser / restricted environments).
*/
import fs from 'fs'
import path from 'path'
import os from 'os'
export const PEERS_CACHE_VERSION = 1
export const LOCALSTORAGE_KEY = 'peardock_connections'
export const LOCALSTORAGE_FLAG = 'peardock_use_localstorage'
/**
* Absolute path to the peers cache file.
* @returns {string}
*/
export function getPeersCachePath() {
const home =
process.env.PEARDOCK_HOME ||
process.env.HOME ||
process.env.USERPROFILE ||
(typeof os.homedir === 'function' ? os.homedir() : '') ||
''
return path.join(home, '.config', 'peardock', 'cache', 'peers.json')
}
/**
* Directory containing peers.json
* @returns {string}
*/
export function getPeersCacheDir() {
return path.dirname(getPeersCachePath())
}
/**
* Normalize one peer entry.
* @param {object|string} value
* @param {string} [idHint]
* @returns {{ id: string, publicKeyHex: string, alias: string|null, inviteToken: string|null }|null}
*/
export function normalizePeerEntry(value, idHint = '') {
if (!value) return null
if (typeof value === 'string') {
const publicKeyHex = value.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) return null
return {
id: publicKeyHex.slice(0, 12),
publicKeyHex,
alias: null,
inviteToken: null,
}
}
const publicKeyHex = String(
value.publicKeyHex || value.topicHex || value.topic || ''
)
.trim()
.toLowerCase()
if (!/^[0-9a-f]{64}$/.test(publicKeyHex)) return null
const id = String(idHint || value.id || publicKeyHex.slice(0, 12)).slice(0, 12)
return {
id,
publicKeyHex,
alias: value.alias || null,
inviteToken: value.inviteToken || null,
}
}
/**
* Parse raw JSON (file or localStorage) into an id → peer map.
* Accepts:
* - { version, peers: { id: {...} } }
* - flat { id: {...} } (legacy)
* @param {string|object} raw
* @returns {Record<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null }>}
*/
export function parsePeersPayload(raw) {
let parsed = raw
if (typeof raw === 'string') {
try {
parsed = JSON.parse(raw)
} catch {
return {}
}
}
if (!parsed || typeof parsed !== 'object') return {}
const source =
parsed.peers && typeof parsed.peers === 'object' && !Array.isArray(parsed.peers)
? parsed.peers
: parsed
/** @type {Record<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null }>} */
const out = {}
for (const [key, value] of Object.entries(source)) {
// Skip meta keys if someone stored a flat object with version
if (key === 'version' || key === 'updatedAt' || key === 'peers') continue
const entry = normalizePeerEntry(value, key)
if (!entry) continue
out[entry.id] = {
publicKeyHex: entry.publicKeyHex,
alias: entry.alias,
inviteToken: entry.inviteToken,
}
}
return out
}
/**
* Build on-disk payload.
* @param {Record<string, { publicKeyHex?: string, topicHex?: string, alias?: string|null, inviteToken?: string|null }>} peersMap
*/
export function buildPeersPayload(peersMap) {
const peers = {}
for (const [id, value] of Object.entries(peersMap || {})) {
const entry = normalizePeerEntry(value, id)
if (!entry) continue
peers[entry.id] = {
publicKeyHex: entry.publicKeyHex,
alias: entry.alias,
inviteToken: entry.inviteToken,
}
}
return {
version: PEERS_CACHE_VERSION,
updatedAt: new Date().toISOString(),
peers,
}
}
function ensureCacheDir() {
const dir = getPeersCacheDir()
fs.mkdirSync(dir, { recursive: true })
return dir
}
/**
* Read peers from ~/.config/peardock/cache/peers.json
* @returns {Record<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null }>}
*/
export function readPeersFromFile() {
const file = getPeersCachePath()
try {
if (!fs.existsSync(file)) return {}
const raw = fs.readFileSync(file, 'utf8')
return parsePeersPayload(raw)
} catch (err) {
console.warn('[WARN] peerCache: failed to read file', err?.message || err)
return {}
}
}
/**
* Write peers to ~/.config/peardock/cache/peers.json (atomic replace).
* @param {Record<string, object>} peersMap
* @returns {boolean}
*/
export function writePeersToFile(peersMap) {
const file = getPeersCachePath()
const payload = buildPeersPayload(peersMap)
const json = JSON.stringify(payload, null, 2)
try {
ensureCacheDir()
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`
fs.writeFileSync(tmp, json, { encoding: 'utf8', mode: 0o600 })
fs.renameSync(tmp, file)
try {
fs.chmodSync(file, 0o600)
} catch {
// ignore chmod failures on some platforms
}
return true
} catch (err) {
console.error('[ERROR] peerCache: failed to write file', err?.message || err)
// Best-effort cleanup of temp files is ignored
return false
}
}
/**
* Remove the peers cache file (and leave empty dir).
* @returns {boolean}
*/
export function clearPeersFile() {
try {
const file = getPeersCachePath()
if (fs.existsSync(file)) fs.unlinkSync(file)
return true
} catch (err) {
console.warn('[WARN] peerCache: failed to clear file', err?.message || err)
return false
}
}
/**
* Read peers: file first, then migrate from localStorage if file empty.
* @returns {Record<string, { publicKeyHex: string, alias: string|null, inviteToken: string|null }>}
*/
export function loadPeers() {
let peers = readPeersFromFile()
if (Object.keys(peers).length > 0) return peers
// Migrate legacy browser storage once
try {
if (typeof localStorage !== 'undefined') {
const raw = localStorage.getItem(LOCALSTORAGE_KEY)
if (raw) {
peers = parsePeersPayload(raw)
if (Object.keys(peers).length > 0) {
writePeersToFile(peers)
console.log(
'[INFO] peerCache: migrated',
Object.keys(peers).length,
'peer(s) from localStorage →',
getPeersCachePath()
)
}
}
}
} catch {
// ignore
}
return peers
}
/**
* Persist peers to file (+ mirror localStorage as secondary cache).
* @param {Record<string, object>} peersMap
* @returns {{ ok: boolean, path: string, count: number }}
*/
export function savePeers(peersMap) {
const payload = buildPeersPayload(peersMap)
const ok = writePeersToFile(payload.peers)
// Mirror for quick in-session reads / legacy code paths
try {
if (typeof localStorage !== 'undefined') {
// Keep flat map for older readers
localStorage.setItem(LOCALSTORAGE_KEY, JSON.stringify(payload.peers))
localStorage.setItem(LOCALSTORAGE_FLAG, '1')
}
} catch {
// ignore
}
return {
ok,
path: getPeersCachePath(),
count: Object.keys(payload.peers).length,
}
}
/**
* Clear file + localStorage peer roster.
*/
export function clearPeers() {
clearPeersFile()
try {
if (typeof localStorage !== 'undefined') {
localStorage.removeItem(LOCALSTORAGE_KEY)
localStorage.removeItem(LOCALSTORAGE_FLAG)
}
} catch {
// ignore
}
}
/**
* List form used by ConnectionManager.loadSaved()
* @returns {Array<{ id: string, publicKeyHex: string, alias: string|null, inviteToken: string|null }>}
*/
export function listSavedPeers() {
const map = loadPeers()
return Object.entries(map).map(([id, value]) => ({
id,
publicKeyHex: value.publicKeyHex,
alias: value.alias || null,
inviteToken: value.inviteToken || null,
}))
}
export default {
getPeersCachePath,
getPeersCacheDir,
loadPeers,
savePeers,
clearPeers,
listSavedPeers,
parsePeersPayload,
buildPeersPayload,
normalizePeerEntry,
readPeersFromFile,
writePeersToFile,
}
+4 -2
View File
@@ -44,10 +44,12 @@ export const CONFIG = {
// Storage
STORAGE: {
COOKIE_SIZE_LIMIT: 4000, // 4KB cookie limit
CONNECTIONS_KEY: 'peardock_connections',
COOKIE_SIZE_LIMIT: 4000, // 4KB cookie limit (legacy)
CONNECTIONS_KEY: 'peardock_connections', // localStorage mirror key
USE_LOCALSTORAGE_KEY: 'peardock_use_localstorage',
TEMPLATES_KEY: 'peardock_templates',
/** Desktop peer roster: ~/.config/peardock/cache/peers.json */
PEERS_CACHE_REL: '.config/peardock/cache/peers.json',
},
// Docker command validation
+76
View File
@@ -0,0 +1,76 @@
import test from 'brittle'
import {
unwrapError,
explainError,
presentError,
formatResponseError,
normalizeRpcError,
isBackgroundMethod,
} from '../client/errors.js'
test('unwrapError peels REQUEST_ERROR wrapper to cause', (t) => {
const cause = new Error('Docker socket not found')
cause.code = 'ENGINE_DOWN'
const outer = new Error('REQUEST_ERROR: Request failed')
outer.code = 'REQUEST_ERROR'
outer.cause = cause
const root = unwrapError(outer)
t.is(root.message, 'Docker socket not found')
t.is(root.code, 'ENGINE_DOWN')
})
test('explainError uses unwrapped message not Request failed', (t) => {
const cause = new Error('permission denied while trying to connect to Docker')
const outer = new Error('REQUEST_ERROR: Request failed')
outer.code = 'REQUEST_ERROR'
outer.cause = cause
const info = explainError(outer, 'listContainers')
t.ok(info.message.toLowerCase().includes('docker') || info.message.toLowerCase().includes('permission'))
t.absent(/request failed/i.test(info.message))
})
test('generic Request failed without cause is quiet for background methods', (t) => {
const err = new Error('REQUEST_ERROR: Request failed')
err.code = 'REQUEST_ERROR'
const info = explainError(err, 'listContainers')
t.ok(info.silent || isBackgroundMethod('listContainers'))
})
test('formatResponseError suppresses bare Request failed spam', (t) => {
const formatted = formatResponseError('REQUEST_ERROR: Request failed', 'listVolumes')
t.ok(formatted)
t.ok(formatted.silent === true || !/REQUEST_ERROR/i.test(formatted.message))
})
test('normalizeRpcError sets method and readable message', (t) => {
const cause = new Error('Invalid arguments: name required')
cause.code = 'INVALID_ARGS'
const outer = new Error('Request failed')
outer.code = 'REQUEST_ERROR'
outer.cause = cause
const n = normalizeRpcError(outer, 'createVolume')
t.is(n.method, 'createVolume')
t.ok(n.message.includes('name') || n.message.includes('Invalid'))
})
test('presentError silent skips showAlert', (t) => {
let called = 0
const showAlert = () => {
called += 1
}
const err = new Error('REQUEST_ERROR: Request failed')
err.code = 'REQUEST_ERROR'
presentError(err, 'ping', { showAlert, silent: true })
t.is(called, 0)
})
test('presentError dedupes identical messages', (t) => {
let called = 0
const showAlert = () => {
called += 1
}
const err = new Error('Something unique for dedupe test xyz')
presentError(err, 'deployContainer', { showAlert, force: true })
presentError(err, 'deployContainer', { showAlert })
t.is(called, 1)
})
+116
View File
@@ -0,0 +1,116 @@
import test from 'brittle'
import fs from 'fs'
import os from 'os'
import path from 'path'
import {
parsePeersPayload,
buildPeersPayload,
normalizePeerEntry,
writePeersToFile,
readPeersFromFile,
loadPeers,
savePeers,
clearPeers,
getPeersCachePath,
listSavedPeers,
} from '../client/peerCache.js'
const KEY =
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'
const KEY2 =
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'
test('normalizePeerEntry accepts publicKeyHex', (t) => {
const e = normalizePeerEntry({ publicKeyHex: KEY, alias: 'prod' })
t.is(e.publicKeyHex, KEY)
t.is(e.id, KEY.slice(0, 12))
t.is(e.alias, 'prod')
})
test('normalizePeerEntry rejects invalid keys', (t) => {
t.absent(normalizePeerEntry({ publicKeyHex: 'nope' }))
t.absent(normalizePeerEntry(null))
})
test('parsePeersPayload supports versioned and legacy flat maps', (t) => {
const versioned = parsePeersPayload({
version: 1,
peers: {
[KEY.slice(0, 12)]: { publicKeyHex: KEY, alias: 'a' },
},
})
t.is(versioned[KEY.slice(0, 12)].publicKeyHex, KEY)
const flat = parsePeersPayload({
[KEY.slice(0, 12)]: { publicKeyHex: KEY, alias: 'b' },
})
t.is(flat[KEY.slice(0, 12)].alias, 'b')
})
test('buildPeersPayload wraps peers with version', (t) => {
const payload = buildPeersPayload({
[KEY.slice(0, 12)]: { publicKeyHex: KEY, alias: 'x' },
})
t.is(payload.version, 1)
t.ok(payload.updatedAt)
t.is(payload.peers[KEY.slice(0, 12)].publicKeyHex, KEY)
})
test('file cache round-trip under temp PEARDOCK_HOME', (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'peardock-peers-cache-'))
const prev = process.env.PEARDOCK_HOME
process.env.PEARDOCK_HOME = tmp
t.teardown(() => {
if (prev === undefined) delete process.env.PEARDOCK_HOME
else process.env.PEARDOCK_HOME = prev
try {
fs.rmSync(tmp, { recursive: true, force: true })
} catch {
// ignore
}
})
const expectedPath = path.join(tmp, '.config', 'peardock', 'cache', 'peers.json')
t.is(getPeersCachePath(), expectedPath)
const peers = {
[KEY.slice(0, 12)]: { publicKeyHex: KEY, alias: 'alpha', inviteToken: 'tok' },
[KEY2.slice(0, 12)]: { publicKeyHex: KEY2, alias: null },
}
const result = savePeers(peers)
t.ok(result.ok)
t.is(result.count, 2)
t.is(result.path, expectedPath)
t.ok(fs.existsSync(expectedPath))
const loaded = loadPeers()
t.is(loaded[KEY.slice(0, 12)].alias, 'alpha')
t.is(loaded[KEY.slice(0, 12)].inviteToken, 'tok')
t.is(loaded[KEY2.slice(0, 12)].publicKeyHex, KEY2)
const list = listSavedPeers()
t.is(list.length, 2)
clearPeers()
t.absent(fs.existsSync(expectedPath))
t.is(Object.keys(readPeersFromFile()).length, 0)
})
test('writePeersToFile is atomic enough to read back', (t) => {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'peardock-peers-atomic-'))
const prev = process.env.PEARDOCK_HOME
process.env.PEARDOCK_HOME = tmp
t.teardown(() => {
if (prev === undefined) delete process.env.PEARDOCK_HOME
else process.env.PEARDOCK_HOME = prev
try {
fs.rmSync(tmp, { recursive: true, force: true })
} catch {
// ignore
}
})
t.ok(writePeersToFile({ [KEY.slice(0, 12)]: { publicKeyHex: KEY } }))
const again = readPeersFromFile()
t.is(again[KEY.slice(0, 12)].publicKeyHex, KEY)
})
+16 -12
View File
@@ -67,13 +67,15 @@ export function startListAutoRefresh(seconds) {
const view = typeof window !== 'undefined' ? window.currentView : null
const send = typeof window !== 'undefined' ? window.sendCommand : null
if (!send) return
if (view === 'containers' || view === 'dashboard') send('listContainers')
else if (view === 'images') send('listImages')
else if (view === 'networks') send('listNetworks')
else if (view === 'volumes') send('listVolumes')
else if (view === 'stacks') send('listStacks')
else if (view === 'host') loadHostView()
else if (view === 'events') loadEventsView()
// silent: background polls must not spam "REQUEST_ERROR: Request failed"
const quiet = { silent: true }
if (view === 'containers' || view === 'dashboard') send('listContainers', {}, quiet)
else if (view === 'images') send('listImages', {}, quiet)
else if (view === 'networks') send('listNetworks', {}, quiet)
else if (view === 'volumes') send('listVolumes', {}, quiet)
else if (view === 'stacks') send('listStacks', {}, quiet)
else if (view === 'host') loadHostView({ silent: true })
else if (view === 'events') loadEventsView({ silent: true })
}, sec * 1000)
}
@@ -353,7 +355,8 @@ export async function createSmartNetwork() {
}
}
export async function loadHostView() {
export async function loadHostView(opts = {}) {
const silent = opts.silent === true
const summary = document.getElementById('host-engine-summary')
const metricsEl = document.getElementById('host-metrics-summary')
const raw = document.getElementById('host-info-raw')
@@ -361,7 +364,7 @@ export async function loadHostView() {
if (summary) summary.textContent = 'Not connected'
return
}
if (summary) summary.innerHTML = '<span class="text-muted">Loading…</span>'
if (summary && !silent) summary.innerHTML = '<span class="text-muted">Loading…</span>'
try {
const [snap, metrics, sys] = await Promise.all([
getSnapshot({ force: true }),
@@ -396,12 +399,13 @@ export async function loadHostView() {
raw.textContent = JSON.stringify(sys?.data || eng.info || {}, null, 2)
}
} catch (err) {
presentError(err, 'getHostSnapshot', { showAlert, notificationManager })
presentError(err, 'getHostSnapshot', { showAlert, silent })
}
}
let eventsPaused = false
export async function loadEventsView() {
export async function loadEventsView(opts = {}) {
const silent = opts.silent === true
const host = document.getElementById('events-full-list')
if (!host || !manager.active?.connected) return
try {
@@ -426,7 +430,7 @@ export async function loadEventsView() {
})
.join('')
} catch (err) {
presentError(err, 'getDockerEvents', { showAlert, notificationManager })
presentError(err, 'getDockerEvents', { showAlert, silent })
}
}