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>
357 lines
12 KiB
JavaScript
357 lines
12 KiB
JavaScript
// src/services/renderers/dectStatusRenderer.js
|
||
//
|
||
// Full CLI-style DECT base dump for `/dectstatus`. Complements the
|
||
// compact follow-up from renderDectDiagnosticsMarkdown (phonestatus).
|
||
|
||
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;
|
||
}
|