collabSupport/services/dectCollectorService.js
jmcqueen 2b8c4e06aa Improve /dectstatus with handset RF context and cleaner base cards.
Surface handset registrations and RSSI, tighten reboot health to 7 days,
and consolidate Base / Handsets & RF / Network & RTP sections.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:02:55 -04:00

297 lines
9.6 KiB
JavaScript

// 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<BaseCollectResult[]>}
*/
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<BaseCollectRawResult[]>}
*/
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;
}
}