breaking
CI / test (push) Successful in 9m56s

This commit is contained in:
Raven Scott
2026-07-10 20:36:11 -04:00
parent d6ce72af66
commit 0bb6ba1692
31 changed files with 10231 additions and 3772 deletions
+168 -6
View File
@@ -14,10 +14,18 @@ function connOrActive(connection) {
export const api = {
Methods,
handshake(info = {}, connection) {
return connOrActive(connection).request(Methods.handshake, {
clientName: info.clientName || 'peardock-api',
clientVersion: info.clientVersion || null,
})
},
ping(connection) {
return connOrActive(connection).ping()
},
// —— Containers ——
listContainers(connection) {
return connOrActive(connection).request(Methods.listContainers, {})
},
@@ -30,42 +38,196 @@ export const api = {
return connOrActive(connection).request(Methods.startContainer, { id })
},
stopContainer(id, connection) {
return connOrActive(connection).request(Methods.stopContainer, { id })
stopContainer(id, opts = {}, connection) {
return connOrActive(connection).request(Methods.stopContainer, { id, ...opts })
},
restartContainer(id, connection) {
return connOrActive(connection).request(Methods.restartContainer, { id })
restartContainer(id, opts = {}, connection) {
return connOrActive(connection).request(Methods.restartContainer, { id, ...opts })
},
removeContainer(id, connection) {
return connOrActive(connection).request(Methods.removeContainer, { id })
killContainer(id, opts = {}, connection) {
return connOrActive(connection).request(Methods.killContainer, { id, ...opts })
},
pauseContainer(id, connection) {
return connOrActive(connection).request(Methods.pauseContainer, { id })
},
unpauseContainer(id, connection) {
return connOrActive(connection).request(Methods.unpauseContainer, { id })
},
removeContainer(id, opts = {}, connection) {
return connOrActive(connection).request(Methods.removeContainer, { id, ...opts })
},
renameContainer(id, name, connection) {
return connOrActive(connection).request(Methods.renameContainer, { id, name })
},
updateContainer(id, update, connection) {
return connOrActive(connection).request(Methods.updateContainer, { id, ...update })
},
containerTop(id, opts = {}, connection) {
return connOrActive(connection).request(Methods.containerTop, { id, ...opts })
},
containerStats(id, connection) {
return connOrActive(connection).request(Methods.containerStats, { id })
},
waitContainer(id, opts = {}, connection) {
return connOrActive(connection).request(Methods.waitContainer, { id, ...opts })
},
pruneContainers(opts = {}, connection) {
return connOrActive(connection).request(Methods.pruneContainers, opts)
},
archiveGet(id, path, opts = {}, connection) {
return connOrActive(connection).request(Methods.archiveContainerGet, { id, path, ...opts })
},
archivePut(id, path, data, opts = {}, connection) {
return connOrActive(connection).request(Methods.archiveContainerPut, {
id,
path,
data,
...opts,
})
},
deployContainer(args, connection) {
return connOrActive(connection).request(Methods.deployContainer, args)
},
bulkContainerOperation(containerIds, operation, opts = {}, connection) {
return connOrActive(connection).request(Methods.bulkContainerOperation, {
containerIds,
operation,
...opts,
})
},
// —— Images ——
listImages(connection) {
return connOrActive(connection).request(Methods.listImages, {})
},
pullImage(image, connection) {
return connOrActive(connection).request(Methods.pullImage, { image })
},
removeImage(id, opts = {}, connection) {
return connOrActive(connection).request(Methods.removeImage, { id, ...opts })
},
inspectImage(id, connection) {
return connOrActive(connection).request(Methods.inspectImage, { id })
},
imageHistory(id, connection) {
return connOrActive(connection).request(Methods.imageHistory, { id })
},
searchImages(term, opts = {}, connection) {
return connOrActive(connection).request(Methods.searchImages, { term, ...opts })
},
pruneImages(opts = {}, connection) {
return connOrActive(connection).request(Methods.pruneImages, opts)
},
tagImage(id, repo, tag = 'latest', connection) {
return connOrActive(connection).request(Methods.tagImage, { id, repo, tag })
},
buildImage(args, connection) {
return connOrActive(connection).request(Methods.buildImage, args)
},
// —— Networks / volumes ——
listNetworks(connection) {
return connOrActive(connection).request(Methods.listNetworks, {})
},
createNetwork(args, connection) {
return connOrActive(connection).request(Methods.createNetwork, args)
},
removeNetwork(id, connection) {
return connOrActive(connection).request(Methods.removeNetwork, { id })
},
pruneNetworks(opts = {}, connection) {
return connOrActive(connection).request(Methods.pruneNetworks, opts)
},
listVolumes(connection) {
return connOrActive(connection).request(Methods.listVolumes, {})
},
createVolume(args, connection) {
return connOrActive(connection).request(Methods.createVolume, args)
},
removeVolume(name, connection) {
return connOrActive(connection).request(Methods.removeVolume, { name })
},
pruneVolumes(opts = {}, connection) {
return connOrActive(connection).request(Methods.pruneVolumes, opts)
},
// —— Stacks ——
listStacks(connection) {
return connOrActive(connection).request(Methods.listStacks, {})
},
deployStack(composeContent, stackName, connection) {
return connOrActive(connection).request(Methods.deployStack, { composeContent, stackName })
},
removeStack(stackName, connection) {
return connOrActive(connection).request(Methods.removeStack, { stackName })
},
stackPs(stackName, connection) {
return connOrActive(connection).request(Methods.stackPs, { stackName })
},
stackLogs(stackName, opts = {}, connection) {
return connOrActive(connection).request(Methods.stackLogs, { stackName, ...opts })
},
stackPull(stackName, opts = {}, connection) {
return connOrActive(connection).request(Methods.stackPull, { stackName, ...opts })
},
// —— System ——
getSystemInfo(connection) {
return connOrActive(connection).request(Methods.getSystemInfo, {})
},
getSystemDf(connection) {
return connOrActive(connection).request(Methods.getSystemDf, {})
},
getDockerEvents(opts = {}, connection) {
return connOrActive(connection).request(Methods.getDockerEvents, opts)
},
registryLogin(args, connection) {
return connOrActive(connection).request(Methods.registryLogin, args)
},
getAuthStatus(connection) {
return connOrActive(connection).request(Methods.getAuthStatus, {})
},
// —— Streams ——
startTerminal(containerId, connection) {
return connOrActive(connection).request(Methods.startTerminal, { containerId })
},
+44 -3
View File
@@ -37,6 +37,11 @@ export class PearDockConnection extends EventEmitter {
this.connectedAt = null
this.latency = null
this.healthStatus = 'unknown'
/** @type {'idle'|'dialing'|'handshaking'|'ready'|'degraded'|'closed'} */
this.state = 'idle'
this.role = null
this.protocolVersion = null
this.dockerHealth = null
}
/**
@@ -46,6 +51,7 @@ export class PearDockConnection extends EventEmitter {
async connect() {
if (this.connected) return this
this.state = 'dialing'
this.dht = new DHT()
this.socket = this.dht.connect(this.publicKey)
@@ -114,10 +120,29 @@ export class PearDockConnection extends EventEmitter {
})
this.rpc.on('close', () => this._onDisconnect())
// Application-layer handshake (role + protocol version)
this.state = 'handshaking'
try {
const hs = await this.request(Methods.handshake, {
clientName: 'peardock-ui',
clientVersion: '2.0.0',
})
this.role = hs?.role || null
this.protocolVersion = hs?.protocolVersion ?? null
this.emit('handshake', hs)
} catch (err) {
// Older servers without handshake still usable
this.emit('handshake-error', err)
}
this.connected = true
this.connectedAt = Date.now()
this.healthStatus = 'healthy'
this.state = 'ready'
this.emit('connect')
// Non-blocking Docker health sample
this.ping().catch(() => {})
return this
}
@@ -171,17 +196,33 @@ export class PearDockConnection extends EventEmitter {
*/
async ping() {
const start = Date.now()
await this.request(Methods.ping, {})
const res = await this.request(Methods.ping, {})
this.latency = Date.now() - start
this.lastHealthCheck = Date.now()
this.healthStatus = 'healthy'
this.dockerHealth = res?.docker || null
if (res?.role) this.role = res.role
if (res?.protocolVersion != null) this.protocolVersion = res.protocolVersion
if (res?.docker && res.docker.ok === false) {
this.healthStatus = 'degraded'
this.state = 'degraded'
} else {
this.healthStatus = 'healthy'
if (this.connected) this.state = 'ready'
}
this.emit('health', {
latency: this.latency,
docker: this.dockerHealth,
status: this.healthStatus,
})
return this.latency
}
_onDisconnect() {
if (!this.connected) return
if (!this.connected && this.state === 'closed') return
this.connected = false
this.healthStatus = 'disconnected'
this.state = 'closed'
this.emit('disconnect')
}
+119 -2
View File
@@ -9,6 +9,10 @@ import { CONFIG } from '../config.js'
const STORAGE_KEY = CONFIG.STORAGE.CONNECTIONS_KEY
const USE_LS_KEY = CONFIG.STORAGE.USE_LOCALSTORAGE_KEY
const MAX_RECONNECT_ATTEMPTS = 12
const BASE_RECONNECT_MS = CONFIG.CONNECTION.RECONNECT_DELAY_MS || 5000
const MAX_RECONNECT_MS = 60_000
export class ConnectionManager extends EventEmitter {
constructor() {
super()
@@ -16,6 +20,10 @@ export class ConnectionManager extends EventEmitter {
this.connections = new Map()
/** @type {PearDockConnection|null} */
this.active = null
/** @type {Map<string, { publicKeyHex: string, alias?: string|null, attempts: number, timer: ReturnType<typeof setTimeout>|null, intentional: boolean }>} */
this._reconnect = new Map()
/** @type {ReturnType<typeof setInterval>|null} */
this._healthTimer = null
}
get activeId() {
@@ -24,13 +32,35 @@ export class ConnectionManager extends EventEmitter {
/**
* @param {string} publicKeyHex
* @param {{ alias?: string }} [meta]
* @param {{ alias?: string, skipReconnectReset?: boolean }} [meta]
* @returns {Promise<PearDockConnection>}
*/
async connect(publicKeyHex, meta = {}) {
const key = publicKeyHex.toLowerCase()
const id = key.slice(0, 12)
this._clearReconnectTimer(id)
if (!meta.skipReconnectReset) {
const prev = this._reconnect.get(id)
this._reconnect.set(id, {
publicKeyHex: key,
alias: meta.alias ?? prev?.alias ?? null,
attempts: 0,
timer: null,
intentional: false,
})
} else {
const entry = this._reconnect.get(id) || {
publicKeyHex: key,
alias: meta.alias || null,
attempts: 0,
timer: null,
intentional: false,
}
if (meta.alias) entry.alias = meta.alias
this._reconnect.set(id, entry)
}
if (this.connections.has(id)) {
const existing = this.connections.get(id)
if (existing.connected) {
@@ -45,21 +75,30 @@ export class ConnectionManager extends EventEmitter {
timeoutMs: CONFIG.CONNECTION.TIMEOUT_MS,
})
if (meta.alias) conn.alias = meta.alias
else if (this._reconnect.get(id)?.alias) conn.alias = this._reconnect.get(id).alias
conn.on('message', (msg) => this.emit('message', msg, conn))
conn.on('health', (info) => this.emit('health', info, conn))
conn.on('disconnect', () => {
this.emit('disconnect', conn)
if (this.active === conn) {
this.active = null
this.emit('active', null)
}
this._scheduleReconnect(id)
})
conn.on('error', (err) => this.emit('error', err, conn))
await conn.connect()
const entry = this._reconnect.get(id)
if (entry) {
entry.attempts = 0
entry.intentional = false
}
this.connections.set(id, conn)
this.persist()
this.setActive(id)
this._ensureHealthLoop()
this.emit('connect', conn)
return conn
}
@@ -79,15 +118,26 @@ export class ConnectionManager extends EventEmitter {
*/
async disconnect(id) {
const conn = this.connections.get(id)
if (!conn) return
const entry = this._reconnect.get(id)
if (entry) {
entry.intentional = true
this._clearReconnectTimer(id)
}
if (!conn) {
this._reconnect.delete(id)
this.persist()
return
}
if (this.active === conn) {
this.active = null
this.emit('active', null)
}
this.connections.delete(id)
await conn.close().catch(() => {})
this._reconnect.delete(id)
this.persist()
this.emit('remove', id)
if (this.connections.size === 0) this._stopHealthLoop()
}
async disconnectAll() {
@@ -97,6 +147,73 @@ export class ConnectionManager extends EventEmitter {
}
}
/**
* @param {string} id
*/
_clearReconnectTimer(id) {
const entry = this._reconnect.get(id)
if (entry?.timer) {
clearTimeout(entry.timer)
entry.timer = null
}
}
/**
* Exponential backoff reconnect after unexpected disconnect.
* @param {string} id
*/
_scheduleReconnect(id) {
const entry = this._reconnect.get(id)
if (!entry || entry.intentional) return
if (entry.attempts >= MAX_RECONNECT_ATTEMPTS) {
this.emit('reconnect-failed', { id, publicKeyHex: entry.publicKeyHex, attempts: entry.attempts })
return
}
this._clearReconnectTimer(id)
const delay = Math.min(MAX_RECONNECT_MS, BASE_RECONNECT_MS * 2 ** entry.attempts)
entry.attempts += 1
this.emit('reconnecting', {
id,
publicKeyHex: entry.publicKeyHex,
attempt: entry.attempts,
delayMs: delay,
})
entry.timer = setTimeout(async () => {
entry.timer = null
try {
await this.connect(entry.publicKeyHex, {
alias: entry.alias || undefined,
skipReconnectReset: true,
})
this.emit('reconnected', { id, publicKeyHex: entry.publicKeyHex })
} catch (err) {
this.emit('reconnect-error', { id, error: err, attempt: entry.attempts })
this._scheduleReconnect(id)
}
}, delay)
}
_ensureHealthLoop() {
if (this._healthTimer) return
const interval = CONFIG.CONNECTION.HEALTH_CHECK_INTERVAL_MS || 10000
this._healthTimer = setInterval(() => {
for (const conn of this.connections.values()) {
if (conn.connected) {
conn.ping().catch(() => {})
}
}
}, interval)
}
_stopHealthLoop() {
if (this._healthTimer) {
clearInterval(this._healthTimer)
this._healthTimer = null
}
}
/**
* RPC on the active connection.
* @param {string} method