Wire CP-78xx probe discovery, relay phone-probe commands, and a chat follow-up message so store desk phones get registration, switch, and provisioning detail alongside DECT and WAN diagnostics. Co-authored-by: Cursor <cursoragent@cursor.com>
204 lines
6.8 KiB
JavaScript
204 lines
6.8 KiB
JavaScript
// services/renderers/mppPhoneDiagnosticsRenderer.js
|
|
//
|
|
// MPP desk phone diagnostics follow-up for /phonestatus (via relay).
|
|
|
|
import { formatDisplayTime } from '../../utils/time.js';
|
|
|
|
/**
|
|
* @param {Array} results probeAll() output
|
|
* @param {object} opts
|
|
* @param {string} opts.storeNum
|
|
* @param {boolean} [opts.verbose=false] Tier 3 fields
|
|
* @param {boolean} [opts.footer=true]
|
|
* @returns {string}
|
|
*/
|
|
export function renderMppPhoneDiagnosticsMarkdown(results, opts = {}) {
|
|
const { storeNum, verbose = false, footer = true } = opts;
|
|
const list = Array.isArray(results) ? results : [];
|
|
if (list.length === 0) return '';
|
|
|
|
let out = `**MPP Desk Phone Diagnostics — Store ${storeNum}**\n\n`;
|
|
|
|
for (const r of list) {
|
|
out += renderOnePhone(r, { verbose });
|
|
out += '\n';
|
|
}
|
|
|
|
if (footer) {
|
|
out += `\n*Phone diagnostics pulled at ${formatDisplayTime()} via the DECT relay.*`;
|
|
if (!verbose) {
|
|
out += ' Pass `verbose` or `debug` for extension/debug counters.';
|
|
}
|
|
out += ' Raw capture: `node scripts/probePhone.js raw <store>`';
|
|
}
|
|
|
|
return out.trim();
|
|
}
|
|
|
|
function renderOnePhone(r, { verbose }) {
|
|
const label = r.phone?.name || r.phone?.product || `Phone ${r.phone?.mac || '?'}`;
|
|
const ip = r.phone?.ip || r.parsed?.network?.ipv4 || '?';
|
|
|
|
if (!r.ok) {
|
|
return `⚠️ **${label}** (${ip}) — probe failed: ${r.error?.message || 'unknown error'}` +
|
|
(r.error?.hint ? `\n _${r.error.hint}_` : '');
|
|
}
|
|
|
|
const mpp = r.mpp || {};
|
|
const parsed = mpp.parsed || r.parsed || {};
|
|
const verdict = mpp.verdict || r.verdict || {};
|
|
const device = parsed.device || {};
|
|
const network = parsed.network || {};
|
|
const phoneStatus = parsed.phoneStatus || {};
|
|
const line1 = parsed.lines?.find((l) => l.index === 1) || parsed.lines?.[0];
|
|
const neighbor = mpp.networkNeighbor || {};
|
|
const download = mpp.download || {};
|
|
const system = mpp.system || {};
|
|
|
|
const icon = verdict.healthy ? '✅' : '⚠️';
|
|
const fwShort = shortenFirmware(device.firmware);
|
|
let out = `${icon} **${label}** (${ip})\n`;
|
|
out += ` ${device.product || '?'} · ${device.mac || '?'} · ${fwShort}\n`;
|
|
|
|
if (line1?.registrationState) {
|
|
out += ` Ext ${line1.index}: ${line1.registrationState}`;
|
|
if (line1.lastRegistrationIp) out += ` → ${line1.lastRegistrationIp}`;
|
|
if (line1.lastRegistrationAt) out += ` (last ${line1.lastRegistrationAt})`;
|
|
out += '\n';
|
|
}
|
|
|
|
const uptime = phoneStatus.elapsedTime || '?';
|
|
const vlan = network.vlan ? `VLAN ${network.vlan}` : null;
|
|
const link = phoneStatus.linkSpeed || neighbor.portSpeed || null;
|
|
const netBits = [uptime !== '?' ? `uptime ${uptime}` : null, vlan, link].filter(Boolean);
|
|
if (netBits.length) out += ` ${netBits.join(' · ')}\n`;
|
|
|
|
if (neighbor.switchDevice) {
|
|
const swIp = neighbor.switchIp ? ` (${neighbor.switchIp})` : '';
|
|
out += ` Switch: ${neighbor.switchDevice} ${neighbor.switchPort || ''}${swIp}\n`;
|
|
}
|
|
|
|
if (parsed.rebootHistory?.length) {
|
|
out += ` Last reboot: ${formatReboot(parsed.rebootHistory[0])}\n`;
|
|
}
|
|
|
|
if (download.latestProvisioning) {
|
|
const prov = download.latestProvisioning;
|
|
const host = prov.url ? hostFromUrl(prov.url) : '?';
|
|
const result = prov.succeeded ? 'OK' : (prov.failed ? 'FAILED' : (prov.result || '?'));
|
|
out += ` Last resync: ${host} · ${result}\n`;
|
|
}
|
|
|
|
if (download.latestFirmwareUpgrade) {
|
|
const fw = download.latestFirmwareUpgrade;
|
|
if (fw.succeeded) {
|
|
out += ` Firmware upgrade: OK\n`;
|
|
} else if (fw.failed) {
|
|
out += ` Firmware upgrade: ${fw.result}\n`;
|
|
}
|
|
}
|
|
|
|
if (phoneStatus.dot1xStatus) {
|
|
out += ` 802.1X: ${phoneStatus.dot1xStatus}${phoneStatus.dot1xProtocol ? ` (${phoneStatus.dot1xProtocol})` : ''}\n`;
|
|
}
|
|
|
|
if (phoneStatus.ledLine1Color) {
|
|
out += ` Line 1 LED: ${phoneStatus.ledLine1Color}${phoneStatus.ledLine1Cadence ? ` ${phoneStatus.ledLine1Cadence}` : ''}\n`;
|
|
}
|
|
|
|
if (parsed.sip?.messagesSent || parsed.sip?.messagesRecv) {
|
|
out += ` SIP msgs: ${parsed.sip.messagesSent || 0} sent / ${parsed.sip.messagesRecv || 0} recv`;
|
|
if (phoneStatus.sipBytesSent) out += ` (${phoneStatus.sipBytesSent}B / ${phoneStatus.sipBytesRecv || 0}B)`;
|
|
out += '\n';
|
|
}
|
|
|
|
if (system.webServer) {
|
|
out += ` Web server: ${system.webServer}`;
|
|
if (system.proxyMode) out += ` · proxy ${system.proxyMode}`;
|
|
out += '\n';
|
|
}
|
|
|
|
if (line1?.messageWaiting && line1.messageWaiting !== 'No') {
|
|
out += ` Message waiting: ${line1.messageWaiting}\n`;
|
|
}
|
|
if (line1?.hotelingState && line1.hotelingState !== 'Disabled') {
|
|
out += ` Hoteling: ${line1.hotelingState}\n`;
|
|
}
|
|
|
|
if (Array.isArray(verdict.warnings) && verdict.warnings.length > 0) {
|
|
for (const w of verdict.warnings) {
|
|
out += ` ⚠️ ${w}\n`;
|
|
}
|
|
}
|
|
|
|
if (verbose) {
|
|
out += renderVerboseTier(r, parsed, neighbor, mpp.probes);
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function renderVerboseTier(r, parsed, neighbor, probes) {
|
|
let out = '';
|
|
|
|
const otherLines = (parsed.lines || []).filter((l) => l.index !== 1);
|
|
for (const line of otherLines) {
|
|
out += ` Ext ${line.index}: ${line.registrationState || '?'}\n`;
|
|
}
|
|
|
|
const ps = parsed.phoneStatus || {};
|
|
if (ps.ipv6Status || ps.ipv6Address) {
|
|
out += ` IPv6: ${ps.ipv6Status || '?'} ${ps.ipv6Address || ''}\n`.trimEnd() + '\n';
|
|
}
|
|
if (ps.tr069Feature) out += ` TR-069: ${ps.tr069Feature}\n`;
|
|
if (ps.multicastRx != null || ps.multicastTx != null) {
|
|
out += ` Paging multicast: rx ${ps.multicastRx || 0} / tx ${ps.multicastTx || 0}\n`;
|
|
}
|
|
if (ps.streamingRx != null) out += ` XML streaming rx: ${ps.streamingRx}\n`;
|
|
|
|
const counters = neighbor.errorCounters || {};
|
|
const counterKeys = Object.keys(counters);
|
|
if (counterKeys.length > 0) {
|
|
out += ' Ethernet errors:\n';
|
|
for (const k of counterKeys.slice(0, 8)) {
|
|
out += ` ${k}: ${counters[k]}\n`;
|
|
}
|
|
}
|
|
|
|
const probeList = Array.isArray(probes) ? probes : [];
|
|
if (probeList.length > 0) {
|
|
out += ' Probe paths:\n';
|
|
out += ' ```\n';
|
|
out += ' STATUS BYTES PATH\n';
|
|
for (const p of probeList) {
|
|
const status = p.status == null ? 'ERR' : String(p.status);
|
|
out += ` ${status.padEnd(6)} ${String(p.sizeBytes || 0).padEnd(6)} ${p.path}\n`;
|
|
}
|
|
out += ' ```\n';
|
|
}
|
|
|
|
return out;
|
|
}
|
|
|
|
function shortenFirmware(fw) {
|
|
if (!fw || typeof fw !== 'string') return '?';
|
|
const m = fw.match(/(sip78xx[^\s]+)/i);
|
|
return m ? m[1] : (fw.length > 36 ? `${fw.slice(0, 36)}…` : fw);
|
|
}
|
|
|
|
function formatReboot(raw) {
|
|
if (!raw) return '?';
|
|
const m = raw.match(/^(\w+)\(([^)]+)\)/);
|
|
return m ? `${m[1]} ${m[2]}` : raw;
|
|
}
|
|
|
|
function hostFromUrl(url) {
|
|
if (!url || typeof url !== 'string') return '?';
|
|
try {
|
|
const normalized = url.startsWith('http') ? url : `https://${url}`;
|
|
const u = new URL(normalized);
|
|
return u.hostname;
|
|
} catch {
|
|
return url.length > 30 ? `${url.slice(0, 30)}…` : url;
|
|
}
|
|
}
|