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>
297 lines
9.6 KiB
JavaScript
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;
|
|
}
|
|
}
|