collabSupport/services/renderers/dectStatusRenderer.js
jmcqueen 7dc326c404 Split voice commands into status vs diag and redesign MPP phone output.
Replace /phonestatus follow-ups with /voicestatus, /wanstatus, /phonediag, and /dectdiag; extend /voicediag with relay probes and section-based MPP diagnostics.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-29 13:55:41 -04:00

356 lines
12 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// src/services/renderers/dectStatusRenderer.js
//
// Full CLI-style DECT base dump for `/dectdiag`.
import { formatDisplayTime, simpleTimeAgo } from '../../utils/time.js';
import { handsetsForBase } from '../dectStatus/buildHandsetContext.js';
import { mergeRssiWithHandsets } from '../dectStatus/matchHandsetByMac.js';
/**
* @param {Array} results collectAll() output
* @param {object} opts
* @param {string} opts.storeNum
* @param {boolean} [opts.footer=true]
* @param {object} [opts.relay] optional hub.status() snapshot
* @param {Array} [opts.discoveryWarnings]
* @param {object} [opts.handsetCtx] buildHandsetContext() output
* @returns {string}
*/
export function renderDectStatusMarkdown(results, opts = {}) {
const {
storeNum,
footer = true,
relay = null,
discoveryWarnings = [],
handsetCtx = null,
} = opts;
const list = Array.isArray(results) ? results : [];
const lines = [`**DECT Status — Store ${storeNum}**`, ''];
if (handsetCtx?.dectNetwork) {
const net = handsetCtx.dectNetwork;
lines.push(`_DECT network: ${net.name || '—'} (assigned handsets: ${net.handsetsCount ?? '—'})_`);
lines.push('');
}
if (relay) {
if (relay.connected) {
const agent = relay.agent?.hostname || relay.agent?.version || 'connected';
lines.push(`_Relay: online (${agent})_`);
} else {
lines.push('_Relay: **offline** — base collect will fail until dect-relay-agent reconnects_');
}
lines.push('');
}
if (list.length === 0) {
lines.push('_No reachable DECT basestations discovered for this store._');
if (discoveryWarnings.length > 0) {
lines.push('');
lines.push('**Discovery notes:**');
for (const w of discoveryWarnings) {
const who = w.mac || w.ip || 'base';
lines.push(`- ${who}: ${w.reason}`);
}
}
lines.push(...renderUnassignedHandsets(handsetCtx));
if (footer) {
lines.push('');
lines.push(`_Pulled at ${formatDisplayTime()}._`);
}
return lines.join('\n').trim();
}
for (let i = 0; i < list.length; i++) {
if (i > 0) lines.push('', '---', '');
lines.push(...renderOneBaseFull(list[i], handsetCtx));
}
lines.push(...renderUnassignedHandsets(handsetCtx));
if (discoveryWarnings.length > 0) {
lines.push('', '**Discovery notes (skipped bases):**');
for (const w of discoveryWarnings) {
const who = w.mac || w.ip || 'base';
lines.push(`- ${who}: ${w.reason}`);
}
}
if (footer) {
lines.push('');
lines.push(
`_Full base dump at ${formatDisplayTime()} via the DECT relay. ` +
`Use the action cards below for reboot / factory-reset (chat only)._`,
);
}
return lines.join('\n').trim();
}
function renderOneBaseFull(r, handsetCtx) {
const label = r.base?.name || `Basestation ${r.base?.mac || '?'}`;
const ip = r.base?.ip || '?';
const mac = r.base?.mac || '?';
const webexId = r.base?.webexId || null;
const lines = [];
if (!r.ok) {
lines.push(`⚠️ **${label}**`);
lines.push(`- IP: \`${ip}\` · MAC: \`${mac}\``);
lines.push(`- Collect failed: ${r.error?.message || 'unknown error'}`);
if (r.error?.hint) lines.push(`- _${r.error.hint}_`);
if (r.elapsedMs != null) lines.push(`- Elapsed: ${r.elapsedMs}ms`);
lines.push(...renderHandsetsAndRfSection({}, handsetCtx, webexId));
return lines;
}
const p = r.data || {};
const verdict = r.verdict || {};
const icon = verdict.healthy ? '✅' : '⚠️';
lines.push(`${icon} **${label}**`);
lines.push(...renderHealthNotes(verdict));
lines.push(...renderHandsetsAndRfSection(p, handsetCtx, webexId));
lines.push(...renderBaseSection(p, { mac, ip, elapsedMs: r.elapsedMs }));
lines.push(...renderRebootLogSection(p.rebootLog));
lines.push(...renderTrafficSection(p.network, p.rtp));
return lines;
}
function renderHealthNotes(verdict) {
const warnings = verdict.warnings || [];
const info = verdict.info || [];
if (warnings.length === 0 && info.length === 0) return [];
const lines = [];
for (const w of warnings) lines.push(`- ⚠️ ${w}`);
for (const i of info) lines.push(`- ${i}`);
return lines;
}
function renderBaseSection(p, { mac, ip, elapsedMs }) {
const lines = ['', '**Base**'];
const modelBits = [p.device?.model, p.device?.systemType].filter(Boolean);
if (modelBits.length) lines.push(`- ${modelBits.join(' · ')}`);
const identityBits = [
p.device?.macAddress || mac,
p.device?.ipAddress || ip,
p.device?.rfpiAddress,
p.device?.rfBand ? `${p.device.rfBand} band` : null,
].filter(Boolean);
if (identityBits.length) lines.push(`- ${identityBits.join(' · ')}`);
const unitBits = [p.device?.unitName, p.device?.unitIndex].filter(Boolean);
const statusBits = [
formatMultiCell(p.multiCell),
p.baseStatus,
p.conflictInfo,
].filter(Boolean);
const opsBits = [...unitBits, ...statusBits].filter(Boolean);
if (opsBits.length) lines.push(`- ${opsBits.join(' · ')}`);
const fw = p.firmware?.version;
const uptimeBits = [
p.time?.operatingTime ? `uptime ${p.time.operatingTime}` : null,
p.time?.currentLocalTime ? `local ${p.time.currentLocalTime}` : null,
elapsedMs != null ? `collect ${elapsedMs}ms` : null,
].filter(Boolean);
const fwLine = [fw, ...uptimeBits].filter(Boolean);
if (fwLine.length) lines.push(`- FW ${fwLine.join(' · ')}`);
return lines;
}
function renderRebootLogSection(rebootLog) {
const log = Array.isArray(rebootLog) ? rebootLog : [];
const lines = ['', '**Reboot log** (newest first)'];
if (log.length === 0) {
lines.push('- _(none)_');
return lines;
}
for (const entry of log) {
if (entry.unrecognized) {
lines.push(`- ??? ${entry.raw || ''}`);
continue;
}
const tag = (entry.reasonCode === 80 || entry.reasonCode === 43) ? '⚡' : '•';
lines.push(
`- ${tag} #${entry.sequence} ${entry.at} **${entry.reasonName}** (${entry.reasonCode}) fw=${entry.firmwareAtBoot || '?'}`,
);
}
return lines;
}
function renderTrafficSection(net, rtp) {
const lines = [];
const hasNet = net && Object.values(net).some((v) => v != null);
const hasRtp = rtp && Object.values(rtp).some((v) => v != null);
if (!hasNet && !hasRtp) return lines;
lines.push('', '**Network & RTP** (since boot)');
if (hasNet) {
lines.push(`- **TX:** ${formatNetCounters(net, 'tx')}`);
lines.push(`- **RX:** ${formatNetCounters(net, 'rx')}`);
}
if (hasRtp) {
lines.push(
`- **RTP:** ${netVal(rtp.total)} total · ${netVal(rtp.current)} active · ` +
`${netVal(rtp.currentLocal)} local · ${netVal(rtp.currentRelay)} relay`,
);
}
return lines;
}
const NET_COUNTER_FIELDS = {
tx: [
['txPackets', 'pkts'],
['txBlocked', 'blocked'],
['txDropped', 'dropped'],
['txErrors', 'errors'],
['txBroadcasts', 'bcast'],
],
rx: [
['rxPackets', 'pkts'],
['rxBlocked', 'blocked'],
['rxDropped', 'dropped'],
['rxErrors', 'errors'],
['rxBroadcasts', 'bcast'],
],
};
function formatNetCounters(net, direction) {
return NET_COUNTER_FIELDS[direction]
.map(([key, label]) => `${netVal(net[key])} ${label}`)
.join(' · ');
}
function netVal(v) {
return Number.isFinite(v) ? v : 0;
}
function formatMultiCell(multiCell) {
if (!multiCell) return null;
const role = multiCell.role ? capitalize(multiCell.role) : null;
const state = multiCell.state ? multiCell.state : null;
if (role && state) return `multi-cell ${role} (${state})`;
if (role) return `multi-cell ${role}`;
return multiCell.raw ? `multi-cell ${multiCell.raw}` : null;
}
function capitalize(s) {
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
/**
* Webex handset inventory + base status.xml handset/RSSI/presence/SIP data.
*/
function renderHandsetsAndRfSection(parsed, handsetCtx, webexId) {
const lines = ['', '**Handsets & RF**'];
let hasDetail = false;
const linesReg = webexId && handsetCtx?.linesRegisteredByWebexId
? handsetCtx.linesRegisteredByWebexId.get(webexId)
: null;
if (linesReg != null) {
lines.push(`- Webex lines registered on base: **${linesReg}**`);
hasDetail = true;
}
const registered = handsetsForBase(handsetCtx, webexId);
const devices = Array.isArray(parsed?.devices) ? parsed.devices : [];
const coveredMacs = new Set(
devices.map((d) => normalizeMacLoose(d.mac)).filter(Boolean),
);
for (const h of registered) {
const idx = h.index != null ? `${h.index}-` : '';
const reg = h.lastRegistrationTime
? simpleTimeAgo(h.lastRegistrationTime)
: '—';
const macBit = h.mac && h.mac !== '—' ? ` · ${h.mac}` : '';
lines.push(
`- **Webex** ${idx}${h.name || 'Handset'} (ext ${h.extension || '—'}) · last reg ${reg}${macBit}`,
);
hasDetail = true;
}
for (const d of devices) {
const who = d.displayName || `Handset ${d.index ?? '?'}`;
const rssi = d.rssiDbm != null ? `${d.rssiDbm} dBm` : '?';
const bat = d.batteryPercent != null ? ` · battery ${d.batteryPercent}%` : '';
const rpn = d.lockedRpn || d.registeredRpn;
const rpnBit = rpn ? ` · ${rpn}` : '';
const sip = d.sipState ? ` · SIP ${d.sipState}` : '';
lines.push(`- **Base** ${who} (${d.deviceType || '?'}) · **${rssi}**${bat}${rpnBit}${sip}`);
hasDetail = true;
}
const rssiRows = Array.isArray(parsed?.rssi) ? parsed.rssi : [];
const merged = mergeRssiWithHandsets(rssiRows, registered);
for (const row of merged) {
if (row.mac && coveredMacs.has(normalizeMacLoose(row.mac))) continue;
const rssi = row.rssiDbm != null ? `${row.rssiDbm} dBm` : '?';
const who = row.handsetName
? `${row.handsetName} (ext ${row.extension || '—'})`
: (row.mac || `RPN ${row.rpn ?? '?'}`);
lines.push(`- **RSSI** ${who}: **${rssi}**`);
hasDetail = true;
}
const presence = Array.isArray(parsed?.devicePresence) ? parsed.devicePresence : [];
for (const p of presence) {
if (p.extension != null) {
const state = p.present === true ? 'present' : (p.present === false ? 'absent' : p.raw);
lines.push(`- **Presence** ext ${p.extension} (${p.deviceType || '?'}) · ${state}`);
hasDetail = true;
continue;
}
if (p.present != null) {
lines.push(`- **Presence** ${p.key}: ${p.present ? 'present' : 'absent'}`);
hasDetail = true;
}
}
const sip = Array.isArray(parsed?.sipIdentityStatus) ? parsed.sipIdentityStatus : [];
for (const s of sip) {
const status = s.status || s.value || '?';
const lineMatch = s.key?.match(/^Line_(\d+)$/i);
const who = s.sipIdx != null
? `line ${s.sipIdx}`
: (lineMatch ? `line ${lineMatch[1]}` : s.key);
const server = s.serverName ? ` (${s.serverName})` : '';
lines.push(`- **SIP** ${who}${server}: **${status}**`);
hasDetail = true;
}
if (!hasDetail && registered.length === 0) {
lines.push('- _(no handset or RF data from Webex or base status.xml)_');
}
return lines;
}
function normalizeMacLoose(mac) {
if (!mac) return null;
const hex = String(mac).replace(/[^0-9a-fA-F]/g, '').toLowerCase();
return hex.length === 12 ? hex : null;
}
function renderUnassignedHandsets(handsetCtx) {
const unassigned = handsetCtx?.unassignedHandsets || [];
if (unassigned.length === 0) return [];
const lines = ['', '**Unassigned handsets** (Webex)'];
for (const h of unassigned) {
const idx = h.index != null ? `${h.index}-` : '';
const reg = h.lastRegistrationTime
? simpleTimeAgo(h.lastRegistrationTime)
: '—';
lines.push(`- **${idx}${h.name || 'Handset'}** (ext ${h.extension || '—'}) · last reg ${reg}`);
}
return lines;
}