// src/services/dectCollectorService.js // // Fan-out layer over the DECT relay hub. Callers hand it a list of // bases (from services/dectDiscovery.js), it dispatches one RPC per // base in parallel and returns a normalized per-base result array. // // Kept intentionally thin: it doesn't render, it doesn't decide what // to do with warnings, it doesn't touch Meraki. Whoever calls this // (the /phonestatus follow-up, the /dectstatus command in Phase 2, // the Jira poller in Phase 3) owns presentation. import { getDectRelayHub, RelayErrorCodes } from './dectRelayHub.js'; import { parseStatusXml, summarizeBaseHealth } from '../integrations/cisco-dect/statusXml.js'; import { logger } from '../utils/logger.js'; const LOG_SCOPE = 'dect:collector'; const DEFAULT_TIMEOUT_MS = Number(process.env.DECT_COLLECT_TIMEOUT_MS) || 15_000; /** * @typedef {object} BaseTarget * @property {string} mac * @property {string} ip * @property {string} name */ /** * @typedef {object} BaseCollectResult * @property {BaseTarget} base * @property {boolean} ok * @property {object|null} data parsed status object (when ok) * @property {object|null} verdict { healthy, warnings, info } (when ok) * @property {number|null} elapsedMs * @property {object|null} error { code, message } (when !ok) */ /** * Run `collect` against every base in the list, in parallel. One base * failing (timeout, offline, bad creds) does NOT fail the batch — * that base's entry just has ok:false. Ordering of returned entries * matches the input. * * @param {BaseTarget[]} bases * @param {object} [opts] * @param {number} [opts.timeoutMs] per-base RPC timeout override * @param {object} [opts.hub] inject a hub for tests * @returns {Promise} */ export async function collectAll(bases, opts = {}) { const list = Array.isArray(bases) ? bases : []; if (list.length === 0) return []; const hub = opts.hub || getDectRelayHub(); const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS; logger(LOG_SCOPE, `Fanning out collect() to ${list.length} base(s)`, 'debug'); const results = await Promise.all(list.map((base) => collectOne(hub, base, timeoutMs))); const okCount = results.filter((r) => r.ok).length; logger(LOG_SCOPE, `Collect finished: ${okCount}/${list.length} succeeded`, 'debug'); return results; } /** * Single-base variant. Mostly here for the eventual /dectstatus * command's individual "refresh this base" flow — collectAll uses it * internally. */ /** * @typedef {object} BaseCollectRawResult * @property {BaseTarget} base * @property {boolean} ok * @property {string|null} rawXml * @property {number|null} byteLength * @property {object|null} data * @property {object|null} verdict * @property {Array|null} sectionInventory * @property {number|null} elapsedMs * @property {object|null} error */ /** * Run `collect-raw` against every base (includes raw XML + inventory). * * @param {BaseTarget[]} bases * @param {object} [opts] * @returns {Promise} */ export async function collectRawAll(bases, opts = {}) { const list = Array.isArray(bases) ? bases : []; if (list.length === 0) return []; const hub = opts.hub || getDectRelayHub(); const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS; logger(LOG_SCOPE, `Fanning out collect-raw() to ${list.length} base(s)`, 'debug'); const results = await Promise.all(list.map((base) => collectRawOne(hub, base, timeoutMs))); const okCount = results.filter((r) => r.ok).length; logger(LOG_SCOPE, `Collect-raw finished: ${okCount}/${list.length} succeeded`, 'debug'); return results; } export async function collectRawOne(hub, base, timeoutMs = DEFAULT_TIMEOUT_MS) { if (!base?.ip) { return { base, ok: false, rawXml: null, byteLength: null, data: null, verdict: null, sectionInventory: null, elapsedMs: null, error: { code: 'NO_IP', message: 'base has no IP address' }, }; } const started = Date.now(); try { let result; let elapsedMs; try { ({ result, elapsedMs } = await hub.collectRaw(base.ip, { timeoutMs })); } catch (err) { if (err?.code === 'UNKNOWN_COMMAND' || /unknown command type/i.test(err?.message || '')) { ({ result, elapsedMs } = await hub.collectRawLegacy(base.ip, { timeoutMs })); } else { throw err; } } if (!result?.rawXml) { return { base, ok: false, rawXml: null, byteLength: null, data: result?.parsed || null, verdict: result?.verdict || null, sectionInventory: null, elapsedMs: elapsedMs ?? (Date.now() - started), error: { code: 'AGENT_OUTDATED', message: 'Relay agent returned parsed status but not raw XML', hint: 'Redeploy dect-relay-agent on the DC host (separate from the bot container). ' + 'From the repo root: ./dect-relay-agent/bundle.sh, then install on the DC host ' + 'and docker compose restart dect-relay-agent. Agent v0.2.0+ supports includeRaw on collect.', }, }; } return { base, ok: true, rawXml: result.rawXml, byteLength: result.byteLength ?? null, data: result.parsed || null, verdict: result.verdict || null, sectionInventory: result.sectionInventory || null, elapsedMs: elapsedMs ?? (Date.now() - started), error: null, }; } catch (err) { const code = err?.code || 'UNKNOWN'; const hint = hintFor(code) || (/unknown command type/i.test(err?.message || '') ? 'Redeploy dect-relay-agent on the DC host — the bot container does not run the relay agent.' : null); return { base, ok: false, rawXml: null, byteLength: null, data: null, verdict: null, sectionInventory: null, elapsedMs: Date.now() - started, error: { code, message: err?.message || String(err), hint, }, }; } } export async function collectOne(hub, base, timeoutMs = DEFAULT_TIMEOUT_MS) { if (!base?.ip) { return { base, ok: false, data: null, verdict: null, elapsedMs: null, error: { code: 'NO_IP', message: 'base has no IP address' }, }; } const started = Date.now(); try { let result; let elapsedMs; try { ({ result, elapsedMs } = await hub.collectRaw(base.ip, { timeoutMs })); } catch (err) { if (err?.code === 'UNKNOWN_COMMAND' || /unknown command type/i.test(err?.message || '')) { try { ({ result, elapsedMs } = await hub.collectRawLegacy(base.ip, { timeoutMs })); } catch { ({ result, elapsedMs } = await hub.collect(base.ip, { timeoutMs })); } } else { throw err; } } // Prefer bot-side parsing so handset/RSSI fields stay current even when // the DC relay agent ships an older statusXml.js. const data = result?.rawXml ? parseStatusXml(result.rawXml) : (result?.parsed || result || null); return { base, ok: true, data, verdict: data ? summarizeBaseHealth(data) : (result?.verdict || null), elapsedMs: elapsedMs ?? (Date.now() - started), error: null, }; } catch (err) { // We keep the code+message split so renderers can decide whether // to show a hint ("relay is offline" vs "wrong password" are very // different remediations). const code = err?.code || 'UNKNOWN'; return { base, ok: false, data: null, verdict: null, elapsedMs: Date.now() - started, error: { code, message: err?.message || String(err), // For NOT_CONNECTED there's no per-base fix — surface a hint. hint: hintFor(code), }, }; } } /** * Run one of the mutating actions against a base. Same envelope shape * as collectOne (ok / error / elapsedMs) so callers can log it * uniformly. Actions handled here mirror the CLI script's subcommands. * * @param {BaseTarget} base * @param {string} action 'reboot' | 'force-reboot' | 'reboot-chain' | * 'force-reboot-chain' | 'factory-reset' | * 'reconfigure-tree' * @param {object} [opts] * @param {number} [opts.timeoutMs] * @param {object} [opts.hub] */ export async function execAction(base, action, opts = {}) { if (!base?.ip) { return { base, ok: false, elapsedMs: null, error: { code: 'NO_IP', message: 'base has no IP address' }, }; } const hub = opts.hub || getDectRelayHub(); const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS; const started = Date.now(); try { const { result, elapsedMs } = await hub.execAction(base.ip, action, {}, { timeoutMs }); return { base, ok: true, action, elapsedMs: elapsedMs ?? (Date.now() - started), result: result || null, error: null, }; } catch (err) { const code = err?.code || 'UNKNOWN'; return { base, ok: false, action, elapsedMs: Date.now() - started, error: { code, message: err?.message || String(err), hint: hintFor(code), }, }; } } function hintFor(code) { switch (code) { case RelayErrorCodes.NOT_CONNECTED: return 'DECT relay agent is not connected. Check that dect-relay-agent is running in the data center.'; case RelayErrorCodes.TIMEOUT: return 'Relay accepted the request but the base did not respond in time. The base may be offline, rebooting, or unreachable.'; case RelayErrorCodes.DISCONNECTED: return 'Relay agent disconnected while this command was in flight. Try again in a moment.'; default: return null; } }