// 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; }