+168
-6
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user