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>
This commit is contained in:
parent
f7953b8eb5
commit
7dc326c404
37 changed files with 1097 additions and 860 deletions
|
|
@ -1,27 +1,9 @@
|
||||||
// src/commands/dectStatus.js
|
// src/commands/dectDiag.js
|
||||||
//
|
//
|
||||||
// /dectstatus <store> — full DECT basestation diagnostics via the
|
// /dectdiag <store> — full DECT basestation diagnostics via the
|
||||||
// on-prem relay agent. Complements the compact
|
// on-prem relay agent: status.xml dump (device,
|
||||||
// follow-up that /phonestatus already posts:
|
// firmware, reboot log, network, RTP) plus chat-only
|
||||||
// this is the full status.xml dump (device,
|
// action cards for reboot / force-reboot / factory-reset.
|
||||||
// firmware, reboot log, network, RTP) plus chat-only action cards
|
|
||||||
// for reboot / force-reboot / factory-reset.
|
|
||||||
//
|
|
||||||
// Chat surface
|
|
||||||
// 1. Markdown full dump for every discovered 10.x base.
|
|
||||||
// 2. One adaptive card per reachable base with Reboot / Force Reboot /
|
|
||||||
// Factory Reset buttons. Each button opens a confirm card; confirm
|
|
||||||
// runs execAction() through the relay and audits the outcome.
|
|
||||||
//
|
|
||||||
// HTTP surface
|
|
||||||
// Same dump via ?storeNum=<n>. No action cards (mutating UI is chat-
|
|
||||||
// only). Non-mutating for registry purposes — card submits never hit
|
|
||||||
// the HTTP path.
|
|
||||||
//
|
|
||||||
// Relay dependency
|
|
||||||
// Requires DECT_RELAY_AGENT_TOKEN + a live dect-relay-agent. When the
|
|
||||||
// relay is offline we still run discovery and report the offline
|
|
||||||
// state explicitly rather than failing the whole command.
|
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { collectPhoneStatus } from '../services/phoneService.js';
|
import { collectPhoneStatus } from '../services/phoneService.js';
|
||||||
|
|
@ -71,7 +53,7 @@ export const DECT_STATUS_CARD_ACTIONS = new Set([
|
||||||
CANCEL_ACTION,
|
CANCEL_ACTION,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export async function handleDectStatus(bot, trigger) {
|
export async function handleDectDiag(bot, trigger) {
|
||||||
const query = trigger.query || {};
|
const query = trigger.query || {};
|
||||||
const args = trigger.args || [];
|
const args = trigger.args || [];
|
||||||
const storeNum = (args[0]?.trim() || query.storeNum || query.store || query.s || '').trim();
|
const storeNum = (args[0]?.trim() || query.storeNum || query.store || query.s || '').trim();
|
||||||
|
|
@ -80,33 +62,31 @@ export async function handleDectStatus(bot, trigger) {
|
||||||
await bot.say(
|
await bot.say(
|
||||||
'markdown',
|
'markdown',
|
||||||
'Please provide a 2–4 digit store number.\n' +
|
'Please provide a 2–4 digit store number.\n' +
|
||||||
'Example: `/dectstatus 782`\n' +
|
'Example: `/dectdiag 782`\n' +
|
||||||
'HTTP: `?storeNum=782`',
|
'HTTP: `?storeNum=782`',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optional single-base filter: second arg as IP or MAC.
|
|
||||||
const filterRaw = (args[1] || query.base || query.ip || query.mac || '').trim();
|
const filterRaw = (args[1] || query.base || query.ip || query.mac || '').trim();
|
||||||
|
|
||||||
logger('dect:status', `Collecting DECT status for store ${storeNum}` +
|
logger('dect:diag', `Collecting DECT diagnostics for store ${storeNum}` +
|
||||||
(filterRaw ? ` (filter=${filterRaw})` : ''));
|
(filterRaw ? ` (filter=${filterRaw})` : ''));
|
||||||
|
|
||||||
// Relay status snapshot for the header — cheap, no I/O.
|
|
||||||
let relayStatus = null;
|
let relayStatus = null;
|
||||||
try {
|
try {
|
||||||
if (process.env.DECT_RELAY_AGENT_TOKEN) {
|
if (process.env.DECT_RELAY_AGENT_TOKEN) {
|
||||||
relayStatus = getDectRelayHub().status();
|
relayStatus = getDectRelayHub().status();
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger('dect:status', `Relay status unavailable: ${err.message}`, 'warn');
|
logger('dect:diag', `Relay status unavailable: ${err.message}`, 'warn');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!process.env.DECT_RELAY_AGENT_TOKEN) {
|
if (!process.env.DECT_RELAY_AGENT_TOKEN) {
|
||||||
await bot.say(
|
await bot.say(
|
||||||
'markdown',
|
'markdown',
|
||||||
'⚠️ DECT relay is not configured on this bot (`DECT_RELAY_AGENT_TOKEN` unset). ' +
|
'⚠️ DECT relay is not configured on this bot (`DECT_RELAY_AGENT_TOKEN` unset). ' +
|
||||||
'Set the token and run dect-relay-agent in the data center to enable `/dectstatus`.',
|
'Set the token and run dect-relay-agent in the data center to enable `/dectdiag`.',
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -115,7 +95,7 @@ export async function handleDectStatus(bot, trigger) {
|
||||||
try {
|
try {
|
||||||
phoneData = await collectPhoneStatus(storeNum);
|
phoneData = await collectPhoneStatus(storeNum);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger('dect:status', `collectPhoneStatus failed for store ${storeNum}: ${err.message}`, 'error');
|
logger('dect:diag', `collectPhoneStatus failed for store ${storeNum}: ${err.message}`, 'error');
|
||||||
await bot.say('markdown', `❌ Failed to look up store ${storeNum}: ${err.message}`);
|
await bot.say('markdown', `❌ Failed to look up store ${storeNum}: ${err.message}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -139,7 +119,7 @@ export async function handleDectStatus(bot, trigger) {
|
||||||
await bot.say(
|
await bot.say(
|
||||||
'markdown',
|
'markdown',
|
||||||
targets.length
|
targets.length
|
||||||
? `⏳ Collecting status from **${targets.length}** DECT base(s) at store ${storeNum}…`
|
? `⏳ Collecting diagnostics from **${targets.length}** DECT base(s) at store ${storeNum}…`
|
||||||
: `Looking up DECT bases for store ${storeNum}…`,
|
: `Looking up DECT bases for store ${storeNum}…`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -153,7 +133,6 @@ export async function handleDectStatus(bot, trigger) {
|
||||||
});
|
});
|
||||||
await bot.say('markdown', md || 'No data available.');
|
await bot.say('markdown', md || 'No data available.');
|
||||||
|
|
||||||
// Action cards are chat-only (need adaptive-card UX + room).
|
|
||||||
if (trigger.person) {
|
if (trigger.person) {
|
||||||
const okBases = results.filter((r) => r.ok && r.base?.ip);
|
const okBases = results.filter((r) => r.ok && r.base?.ip);
|
||||||
for (const r of okBases) {
|
for (const r of okBases) {
|
||||||
|
|
@ -168,7 +147,7 @@ export async function handleDectStatus(bot, trigger) {
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger(
|
logger(
|
||||||
'dect:status',
|
'dect:diag',
|
||||||
`Failed to post action card for ${r.base?.ip}: ${err.message}`,
|
`Failed to post action card for ${r.base?.ip}: ${err.message}`,
|
||||||
'warn',
|
'warn',
|
||||||
);
|
);
|
||||||
|
|
@ -192,7 +171,7 @@ export async function handleDectStatusAction(bot, trigger) {
|
||||||
const dectAction = inputs.dectAction;
|
const dectAction = inputs.dectAction;
|
||||||
const meta = DECT_STATUS_ACTIONS[dectAction];
|
const meta = DECT_STATUS_ACTIONS[dectAction];
|
||||||
if (!meta) {
|
if (!meta) {
|
||||||
logger('dect:status:action', `Unknown dectAction "${dectAction}" — ignoring`, 'warn');
|
logger('dect:diag:action', `Unknown dectAction "${dectAction}" — ignoring`, 'warn');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const storeNum = String(inputs.storeNum || '').trim();
|
const storeNum = String(inputs.storeNum || '').trim();
|
||||||
|
|
@ -200,7 +179,7 @@ export async function handleDectStatusAction(bot, trigger) {
|
||||||
const baseMac = String(inputs.baseMac || '').trim();
|
const baseMac = String(inputs.baseMac || '').trim();
|
||||||
const baseName = String(inputs.baseName || baseIp || 'base').trim();
|
const baseName = String(inputs.baseName || baseIp || 'base').trim();
|
||||||
if (!storeNum || !baseIp) {
|
if (!storeNum || !baseIp) {
|
||||||
logger('dect:status:action', 'request missing storeNum/baseIp — ignoring', 'warn');
|
logger('dect:diag:action', 'request missing storeNum/baseIp — ignoring', 'warn');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -240,17 +219,17 @@ export async function handleDectStatusAction(bot, trigger) {
|
||||||
if (actionType === CONFIRM_ACTION || actionType === CANCEL_ACTION) {
|
if (actionType === CONFIRM_ACTION || actionType === CANCEL_ACTION) {
|
||||||
const { cardId } = inputs;
|
const { cardId } = inputs;
|
||||||
if (!cardId) {
|
if (!cardId) {
|
||||||
logger('dect:status:action', `Missing cardId on ${actionType} — ignoring`);
|
logger('dect:diag:action', `Missing cardId on ${actionType} — ignoring`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!pendingDectActions.has(cardId)) {
|
if (!pendingDectActions.has(cardId)) {
|
||||||
logger('dect:status:action', `Card ${cardId} is expired or unknown`);
|
logger('dect:diag:action', `Card ${cardId} is expired or unknown`);
|
||||||
await bot.say('markdown', '⚠️ That action card has expired. Re-run `/dectstatus` and try again.');
|
await bot.say('markdown', '⚠️ That action card has expired. Re-run `/dectdiag` and try again.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = pendingDectActions.get(cardId);
|
const data = pendingDectActions.get(cardId);
|
||||||
pendingDectActions.delete(cardId); // one-shot
|
pendingDectActions.delete(cardId);
|
||||||
|
|
||||||
if (actionType === CANCEL_ACTION) {
|
if (actionType === CANCEL_ACTION) {
|
||||||
await bot.say(
|
await bot.say(
|
||||||
|
|
@ -265,7 +244,6 @@ export async function handleDectStatusAction(bot, trigger) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CONFIRM
|
|
||||||
const meta = DECT_STATUS_ACTIONS[data.dectAction] || {
|
const meta = DECT_STATUS_ACTIONS[data.dectAction] || {
|
||||||
id: data.dectAction,
|
id: data.dectAction,
|
||||||
label: data.dectAction,
|
label: data.dectAction,
|
||||||
|
|
@ -290,7 +268,7 @@ export async function handleDectStatusAction(bot, trigger) {
|
||||||
'markdown',
|
'markdown',
|
||||||
`✅ **${meta.label}** issued on **${data.baseName}** (${data.baseIp})` +
|
`✅ **${meta.label}** issued on **${data.baseName}** (${data.baseIp})` +
|
||||||
(result.elapsedMs != null ? ` in ${result.elapsedMs}ms` : '') +
|
(result.elapsedMs != null ? ` in ${result.elapsedMs}ms` : '') +
|
||||||
`.\n_Re-run \`/dectstatus ${data.storeNum}\` in a minute to confirm the base is back._`,
|
`.\n_Re-run \`/dectdiag ${data.storeNum}\` in a minute to confirm the base is back._`,
|
||||||
);
|
);
|
||||||
logger(
|
logger(
|
||||||
'dect:audit',
|
'dect:audit',
|
||||||
|
|
@ -312,12 +290,10 @@ export async function handleDectStatusAction(bot, trigger) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
logger('dect:status:action', `Ignoring unhandled action type ${actionType}`, 'debug');
|
logger('dect:diag:action', `Ignoring unhandled action type ${actionType}`, 'debug');
|
||||||
void roomId;
|
void roomId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Cards ──────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function buildBaseActionCard({ storeNum, base }) {
|
function buildBaseActionCard({ storeNum, base }) {
|
||||||
const actions = Object.values(DECT_STATUS_ACTIONS).map((meta) => ({
|
const actions = Object.values(DECT_STATUS_ACTIONS).map((meta) => ({
|
||||||
type: 'Action.Submit',
|
type: 'Action.Submit',
|
||||||
|
|
@ -402,8 +378,6 @@ function buildConfirmCard({ cardId, storeNum, baseName, baseIp, meta }) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function filterBases(bases, raw) {
|
function filterBases(bases, raw) {
|
||||||
const needle = String(raw).trim().toLowerCase();
|
const needle = String(raw).trim().toLowerCase();
|
||||||
const needleMac = needle.replace(/[^0-9a-f]/g, '');
|
const needleMac = needle.replace(/[^0-9a-f]/g, '');
|
||||||
|
|
@ -18,9 +18,11 @@ const SHORT_HELP = {
|
||||||
|
|
||||||
// AV / phones
|
// AV / phones
|
||||||
avstatus: 'AV / device status for a store (alias: /wostatus)',
|
avstatus: 'AV / device status for a store (alias: /wostatus)',
|
||||||
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
|
voicestatus: 'Quick DECT + IP phone status for a store',
|
||||||
dectstatus: 'Full DECT base dump via relay (handsets, RSSI, reboot cards)',
|
wanstatus: 'Prisma SD-WAN health + voice traffic quality',
|
||||||
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
|
phonediag: 'MPP desk phone relay diagnostics (CP-7841)',
|
||||||
|
dectdiag: 'Full DECT base dump via relay (handsets, RSSI, reboot cards)',
|
||||||
|
voicediag: 'Deep voice diagnostic: features + WAN + relay probes with fix cards',
|
||||||
callreport: 'Daily call digest: correlated CDR calls + per-call Prisma WAN (store, email, or phone)',
|
callreport: 'Daily call digest: correlated CDR calls + per-call Prisma WAN (store, email, or phone)',
|
||||||
calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)',
|
calltest: 'Twilio voice path test (store AA or direct dial, 60s listen)',
|
||||||
|
|
||||||
|
|
@ -53,41 +55,70 @@ const LONG_HELP = {
|
||||||
'Includes Meraki deep links per device. For interactive topology, open `/av-store-dashboard.html`.',
|
'Includes Meraki deep links per device. For interactive topology, open `/av-store-dashboard.html`.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
phonestatus: {
|
voicestatus: {
|
||||||
title: '/phonestatus',
|
title: '/voicestatus',
|
||||||
usage: [
|
usage: [
|
||||||
'/phonestatus <store>',
|
'/voicestatus <store>',
|
||||||
'/phonestatus <store> detailed',
|
'/voicestatus <store> detailed',
|
||||||
'/phonestatus <store> verbose',
|
|
||||||
'/phonestatus <store> detailed verbose',
|
|
||||||
],
|
],
|
||||||
examples: [
|
examples: [
|
||||||
'/phonestatus 782',
|
'/voicestatus 782',
|
||||||
'/phonestatus 782 detailed',
|
'/voicestatus 782 detailed',
|
||||||
'/phonestatus 782 verbose',
|
|
||||||
'/phonestatus 782 debug',
|
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
'Shows DECT basestations + IP phones with Meraki links.',
|
'Shows DECT basestations + IP phones with Meraki links — quick scan only, no follow-up messages.',
|
||||||
'Detailed mode adds firmware, serial, SIP details and errors (main message only).',
|
'Detailed mode adds firmware, serial, SIP details and errors.',
|
||||||
'When MPP desk phones are discovered on 10.x, a follow-up **MPP Desk Phone Diagnostics** message arrives via the relay (registration, switch port, provisioning history). Pass `verbose` or `debug` for extension/debug counters.',
|
'For MPP desk phone relay diagnostics, use `/phonediag <store>`.',
|
||||||
'When the store is Prisma SD-WAN managed (site name `CG<store>` padded to 5 digits), a follow-up **WAN Diagnostics** message arrives with per-path latency/jitter/loss/MOS, site healthscore, and any alarms — averaged over the last 7 days by default (widened from 24h so sporadic Webex Calling stores get enough call samples; override via `WAN_STANDARD_WINDOW_MINUTES` or pass `--window 24h` on /voicediag).',
|
'For Prisma SD-WAN metrics, use `/wanstatus <store>`.',
|
||||||
'If `PRISMA_APP_ID_VOICE` is configured (e.g. pointing at `Webex_Calling_RTP` for Webex Calling shops), the follow-up also includes a **Voice Traffic Quality** section with real DPI-measured MOS / packet loss / jitter for that app — surfaces transient degradation the 7d link-probe averages smooth away.',
|
'For full DECT base dump + reboot/factory-reset controls, use `/dectdiag <store>`.',
|
||||||
'For the full DECT base dump + reboot/factory-reset controls, use `/dectstatus <store>`.',
|
|
||||||
'For an in-depth voice diagnostic with per-user Webex Calling checks + fix cards, use `/voicediag <store>`.',
|
'For an in-depth voice diagnostic with per-user Webex Calling checks + fix cards, use `/voicediag <store>`.',
|
||||||
'Web dashboard: `/phone-store-dashboard.html`.',
|
'Web dashboard: `/phone-store-dashboard.html`.',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
dectstatus: {
|
wanstatus: {
|
||||||
title: '/dectstatus',
|
title: '/wanstatus',
|
||||||
usage: [
|
usage: [
|
||||||
'/dectstatus <store>',
|
'/wanstatus <store>',
|
||||||
'/dectstatus <store> <ip|mac>',
|
'/wanstatus <store> --window 24h',
|
||||||
],
|
],
|
||||||
examples: [
|
examples: [
|
||||||
'/dectstatus 782',
|
'/wanstatus 782',
|
||||||
'/dectstatus 782 10.12.34.56',
|
'/wanstatus 782 --window 7d',
|
||||||
'/dectstatus 782 6c:ab:05:12:34:56',
|
],
|
||||||
|
notes: [
|
||||||
|
'Prisma SD-WAN diagnostics for stores managed under site name `CG<store>` (padded to 5 digits).',
|
||||||
|
'Shows healthscore, per-path latency/jitter/loss/MOS, active alarms — default window 7 days.',
|
||||||
|
'When `PRISMA_APP_ID_VOICE` is configured, includes **Voice Traffic Quality** (DPI-measured MOS / loss / jitter).',
|
||||||
|
'Override window via `--window 24h`, `7d`, etc. HTTP: `?storeNum=<n>&window=24h`.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
phonediag: {
|
||||||
|
title: '/phonediag',
|
||||||
|
usage: [
|
||||||
|
'/phonediag <store>',
|
||||||
|
'/phonediag <store> verbose',
|
||||||
|
],
|
||||||
|
examples: [
|
||||||
|
'/phonediag 782',
|
||||||
|
'/phonediag 782 verbose',
|
||||||
|
'/phonediag 782 debug',
|
||||||
|
],
|
||||||
|
notes: [
|
||||||
|
'MPP desk phone relay diagnostics for CP-7841 phones on 10.x: registration, switch LLDP, provisioning, issues.',
|
||||||
|
'Pass `verbose` or `debug` for SIP counters, extra extensions, and probe path table.',
|
||||||
|
'Requires `DECT_RELAY_AGENT_TOKEN` and a live dect-relay-agent.',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
dectdiag: {
|
||||||
|
title: '/dectdiag',
|
||||||
|
usage: [
|
||||||
|
'/dectdiag <store>',
|
||||||
|
'/dectdiag <store> <ip|mac>',
|
||||||
|
],
|
||||||
|
examples: [
|
||||||
|
'/dectdiag 782',
|
||||||
|
'/dectdiag 782 10.12.34.56',
|
||||||
|
'/dectdiag 782 6c:ab:05:12:34:56',
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
'Aliases: `/dect`.',
|
'Aliases: `/dect`.',
|
||||||
|
|
@ -95,9 +126,8 @@ const LONG_HELP = {
|
||||||
'Also shows **Webex handset registrations** per base (extension, last registration) and **RF signal (RSSI)** from the base when handsets are registered.',
|
'Also shows **Webex handset registrations** per base (extension, last registration) and **RF signal (RSSI)** from the base when handsets are registered.',
|
||||||
'Optional second arg filters to one base by IP, MAC, or name substring.',
|
'Optional second arg filters to one base by IP, MAC, or name substring.',
|
||||||
'Chat-only action cards per reachable base: **Reboot**, **Force Reboot**, **Factory Reset**. Each requires a confirm click; outcomes are audited under `dect:audit`.',
|
'Chat-only action cards per reachable base: **Reboot**, **Force Reboot**, **Factory Reset**. Each requires a confirm click; outcomes are audited under `dect:audit`.',
|
||||||
'Requires `DECT_RELAY_AGENT_TOKEN` on the bot and a live `dect-relay-agent` in the data center. When the relay is offline the command still reports discovery results and the offline state.',
|
'Requires `DECT_RELAY_AGENT_TOKEN` on the bot and a live `dect-relay-agent` in the data center.',
|
||||||
'HTTP equivalent: `?storeNum=<n>[&base=<ip|mac>]` — markdown dump only (no action cards).',
|
'HTTP equivalent: `?storeNum=<n>[&base=<ip|mac>]` — markdown dump only (no action cards).',
|
||||||
'The compact exception-only DECT follow-up after `/phonestatus` is separate; this command is the full dump that follow-up footer points at.',
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
voicediag: {
|
voicediag: {
|
||||||
|
|
@ -119,7 +149,7 @@ const LONG_HELP = {
|
||||||
'/voicediag list-checks',
|
'/voicediag list-checks',
|
||||||
],
|
],
|
||||||
notes: [
|
notes: [
|
||||||
'Runs a full battery of per-user Webex Calling checks (DND, call forwarding, voicemail, call intercept, call waiting, outgoing permission, etc.) plus eleven SD-WAN checks (site, healthscore, link state, latency, jitter, loss, MOS, per-app voice MOS/loss/jitter, alarms) against the store\'s Prisma tenant.',
|
'Runs per-user Webex Calling checks, SD-WAN checks, and relay probes (MPP desk phones + DECT bases when `DECT_RELAY_AGENT_TOKEN` is set).',
|
||||||
'The per-app checks measure REAL voice-traffic quality via Prisma DPI (worst 5-minute window over the configured look-back, default 7d), which catches transient degradation the link-probe averages smooth away. Feature-gated on `PRISMA_APP_ID_VOICE` env (set to a Prisma app id like `Webex_Calling_RTP` or `rtp-base`) — checks return skipped with an explanation when not configured.',
|
'The per-app checks measure REAL voice-traffic quality via Prisma DPI (worst 5-minute window over the configured look-back, default 7d), which catches transient degradation the link-probe averages smooth away. Feature-gated on `PRISMA_APP_ID_VOICE` env (set to a Prisma app id like `Webex_Calling_RTP` or `rtp-base`) — checks return skipped with an explanation when not configured.',
|
||||||
'Default view hides OK checks and highlights errors/warnings/skipped. Pass `detail` (or `detailed`) to also see OK checks with expanded per-link tables + thresholds + roll-ups.',
|
'Default view hides OK checks and highlights errors/warnings/skipped. Pass `detail` (or `detailed`) to also see OK checks with expanded per-link tables + thresholds + roll-ups.',
|
||||||
'Fixable issues (e.g. DND on, forwarding to wrong number) post a per-issue confirmation card. A single "apply all" card lets you fix everything at once after reviewing.',
|
'Fixable issues (e.g. DND on, forwarding to wrong number) post a per-issue confirmation card. A single "apply all" card lets you fix everything at once after reviewing.',
|
||||||
|
|
@ -260,7 +290,7 @@ const LONG_HELP = {
|
||||||
examples: ['/jirapoll', '/jirapoll prime'],
|
examples: ['/jirapoll', '/jirapoll prime'],
|
||||||
notes: [
|
notes: [
|
||||||
'Triggers the same Jira poller that normally runs at the top of every hour. Enriches any unlabeled matching tickets with a phone/av snapshot comment and labels them `bot-enriched`. Tickets the AI classifier decides are out-of-scope get labeled `bot-skipped` so they aren\'t re-classified every hour.',
|
'Triggers the same Jira poller that normally runs at the top of every hour. Enriches any unlabeled matching tickets with a phone/av snapshot comment and labels them `bot-enriched`. Tickets the AI classifier decides are out-of-scope get labeled `bot-skipped` so they aren\'t re-classified every hour.',
|
||||||
'**Communication Services** tickets mentioning call quality (garbled, static, can\'t connect, …) get **phonestatus + callreport (today)**. Spam/robocall tickets get **callreport** only. See `services/jiraPoller/README.md` for adding more rules.',
|
'**Communication Services** tickets mentioning call quality (garbled, static, can\'t connect, …) get **voicestatus + callreport (today)**. Spam/robocall tickets get **callreport** only. See `services/jiraPoller/README.md` for adding more rules.',
|
||||||
'Idempotent — labels + JQL prevent double-processing, so running multiple times in a row is safe.',
|
'Idempotent — labels + JQL prevent double-processing, so running multiple times in a row is safe.',
|
||||||
'`/jirapoll prime` labels every matching ticket without enriching or notifying. Use once after adopting the poller to skip enriching the existing backlog. Same as `JIRA_POLLER_PRIME_ON_START=true` at startup.',
|
'`/jirapoll prime` labels every matching ticket without enriching or notifying. Use once after adopting the poller to skip enriching the existing backlog. Same as `JIRA_POLLER_PRIME_ON_START=true` at startup.',
|
||||||
'A summary of enriched tickets goes to the configured `JIRA_POLLER_ROOM_ID`. The invoking chat also gets a compact result line.',
|
'A summary of enriched tickets goes to the configured `JIRA_POLLER_ROOM_ID`. The invoking chat also gets a compact result line.',
|
||||||
|
|
@ -322,7 +352,7 @@ const LONG_HELP = {
|
||||||
|
|
||||||
const GROUPS = [
|
const GROUPS = [
|
||||||
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] },
|
{ title: 'Work orders', keys: ['wohistory', 'wosummary', 'woattachments'] },
|
||||||
{ title: 'AV & phones', keys: ['avstatus', 'phonestatus', 'dectstatus', 'voicediag', 'callreport', 'calltest'] },
|
{ title: 'AV & phones', keys: ['avstatus', 'voicestatus', 'wanstatus', 'phonediag', 'dectdiag', 'voicediag', 'callreport', 'calltest'] },
|
||||||
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
|
{ title: 'Jira', keys: ['jirahistory', 'jiraticket', 'jirapoll'] },
|
||||||
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
|
{ title: 'Provisioning', keys: ['provision-dect', 'provision-vc', 'vcmonitor'] },
|
||||||
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },
|
{ title: 'Admin & bulk', keys: ['offboarduser', 'webexhost', 'bulkavstatuscsv', 'bulkavswitchcsv', 'devicesbymodel'] },
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
//
|
//
|
||||||
// Adaptive-card inline remediation for Meraki switch multicast policy
|
// Adaptive-card inline remediation for Meraki switch multicast policy
|
||||||
// on a store's network. Not a standalone chat command — the card is
|
// on a store's network. Not a standalone chat command — the card is
|
||||||
// emitted by `/phonestatus` (see commands/phoneStatus.js) whenever the
|
// emitted by `/voicestatus` (see commands/voiceStatus.js) whenever the
|
||||||
// collector reports `data.multicast.needsFix`. Confirm and Cancel
|
// collector reports `data.multicast.needsFix`. Confirm and Cancel
|
||||||
// flow through the framework's `attachmentAction` event dispatched
|
// flow through the framework's `attachmentAction` event dispatched
|
||||||
// from index.js, which follows the same pending-card / one-shot /
|
// from index.js, which follows the same pending-card / one-shot /
|
||||||
|
|
@ -34,7 +34,7 @@ export const DECT_SAFE_MULTICAST_PAYLOAD = Object.freeze({
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build the adaptive-card object shown in-chat under a /phonestatus
|
* Build the adaptive-card object shown in-chat under a /voicestatus
|
||||||
* reply when multicast policy deviates from DECT-safe defaults. The
|
* reply when multicast policy deviates from DECT-safe defaults. The
|
||||||
* card renders whatever the summarizer flagged as deviating so the
|
* card renders whatever the summarizer flagged as deviating so the
|
||||||
* clicker knows exactly what will change.
|
* clicker knows exactly what will change.
|
||||||
|
|
@ -150,7 +150,7 @@ export async function applyDectSafeMulticast(bot, data, _roomId, requester) {
|
||||||
'markdown',
|
'markdown',
|
||||||
`✅ Multicast pinned to DECT-safe defaults on **${networkName || `store ${storeNum}`}**: ` +
|
`✅ Multicast pinned to DECT-safe defaults on **${networkName || `store ${storeNum}`}**: ` +
|
||||||
`IGMP snoop=OFF, flood-unknown=ON, all switch overrides cleared. ` +
|
`IGMP snoop=OFF, flood-unknown=ON, all switch overrides cleared. ` +
|
||||||
`Re-run \`/phonestatus ${storeNum}\` to verify.`,
|
`Re-run \`/voicestatus ${storeNum}\` to verify.`,
|
||||||
);
|
);
|
||||||
logger(
|
logger(
|
||||||
'igmp:audit',
|
'igmp:audit',
|
||||||
|
|
|
||||||
84
commands/phoneDiag.js
Normal file
84
commands/phoneDiag.js
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
// src/commands/phoneDiag.js
|
||||||
|
//
|
||||||
|
// /phonediag <store> [verbose|debug]
|
||||||
|
//
|
||||||
|
// MPP desk phone relay diagnostics (CP-7841 etc.): registration,
|
||||||
|
// switch LLDP, provisioning history, and formatted issues.
|
||||||
|
|
||||||
|
import { collectPhoneStatus } from '../services/phoneService.js';
|
||||||
|
import { discoverDeskPhones } from '../services/phoneDiscovery.js';
|
||||||
|
import { probeAll } from '../services/phoneCollectorService.js';
|
||||||
|
import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
export async function handlePhoneDiag(bot, trigger) {
|
||||||
|
const query = trigger.query || {};
|
||||||
|
const args = trigger.args || [];
|
||||||
|
const storeNum = (args[0]?.trim() || query.storeNum || query.store || query.s || '').trim();
|
||||||
|
|
||||||
|
const isVerbose = argIncludes(args, 'verbose') || argIncludes(args, 'debug')
|
||||||
|
|| query.verbose === 'true' || query.verbose === true
|
||||||
|
|| query.debug === 'true' || query.debug === true;
|
||||||
|
|
||||||
|
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
'Please provide a 2–4 digit store number.\n' +
|
||||||
|
'Examples:\n' +
|
||||||
|
'- `/phonediag 782`\n' +
|
||||||
|
'- `/phonediag 782 verbose`',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!process.env.DECT_RELAY_AGENT_TOKEN) {
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
'⚠️ Phone relay is not configured on this bot (`DECT_RELAY_AGENT_TOKEN` unset). ' +
|
||||||
|
'Set the token and run dect-relay-agent in the data center to enable `/phonediag`.',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('phone:diag', `Collecting MPP phone diagnostics for store ${storeNum}`);
|
||||||
|
|
||||||
|
let phoneData;
|
||||||
|
try {
|
||||||
|
phoneData = await collectPhoneStatus(storeNum);
|
||||||
|
} catch (err) {
|
||||||
|
logger('phone:diag', `collectPhoneStatus failed for store ${storeNum}: ${err.message}`, 'error');
|
||||||
|
await bot.say('markdown', `❌ Failed to look up store ${storeNum}: ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { phones, warnings } = discoverDeskPhones(phoneData || {});
|
||||||
|
if (warnings.length > 0) {
|
||||||
|
logger(
|
||||||
|
'phone:diag',
|
||||||
|
`MPP discovery warnings for store ${storeNum}: ${warnings.map((w) => w.reason).join('; ')}`,
|
||||||
|
'warn',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (phones.length === 0) {
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`No MPP desk phones discovered on 10.x for store ${storeNum}.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`⏳ Probing **${phones.length}** MPP desk phone(s) at store ${storeNum} via relay…`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await probeAll(phones);
|
||||||
|
const md = renderMppPhoneDiagnosticsMarkdown(results, { storeNum, verbose: isVerbose });
|
||||||
|
await bot.say('markdown', md || 'No data available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function argIncludes(args, token) {
|
||||||
|
const needle = String(token).toLowerCase();
|
||||||
|
return (args || []).some((a) => String(a).toLowerCase() === needle);
|
||||||
|
}
|
||||||
|
|
@ -1,289 +0,0 @@
|
||||||
// src/commands/phoneStatus.js
|
|
||||||
//
|
|
||||||
// Chat + HTTP entry point for /phonestatus. The heavy rendering lives in
|
|
||||||
// services/renderers/phoneStatusRenderer.js so the Jira poller can emit
|
|
||||||
// the same markdown (see services/jiraPollerService.js). This handler
|
|
||||||
// stays thin: parse args, call the collector, hand data to the renderer,
|
|
||||||
// respond.
|
|
||||||
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { collectPhoneStatus } from '../services/phoneService.js';
|
|
||||||
import {
|
|
||||||
renderPhoneStatusMarkdown,
|
|
||||||
renderDectDiagnosticsMarkdown,
|
|
||||||
} from '../services/renderers/phoneStatusRenderer.js';
|
|
||||||
import { renderWanDiagnosticsMarkdown } from '../services/renderers/wanDiagnosticsRenderer.js';
|
|
||||||
import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.js';
|
|
||||||
import { buildIgmpFixCard } from './igmpFix.js';
|
|
||||||
import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js';
|
|
||||||
import { extractRequester } from '../utils/requester.js';
|
|
||||||
import { logger } from '../utils/logger.js';
|
|
||||||
import { discoverDectBases } from '../services/dectDiscovery.js';
|
|
||||||
import { discoverDeskPhones } from '../services/phoneDiscovery.js';
|
|
||||||
import { collectAll } from '../services/dectCollectorService.js';
|
|
||||||
import { probeAll } from '../services/phoneCollectorService.js';
|
|
||||||
import { siteNameForStore, findSdwanSiteForStore } from '../integrations/paloalto/sites.js';
|
|
||||||
import { collectSdwanForStore } from '../services/enrichment/sdwanEnrichment.js';
|
|
||||||
|
|
||||||
export async function handlePhoneStatus(bot, trigger) {
|
|
||||||
logger('phone:status', 'Handler entered', 'debug');
|
|
||||||
|
|
||||||
// Support both Webex (args) and HTTP (query) calls
|
|
||||||
const query = trigger.query || {};
|
|
||||||
const args = trigger.args || [];
|
|
||||||
|
|
||||||
let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s;
|
|
||||||
|
|
||||||
const isDetailed = argIncludes(args, 'detailed')
|
|
||||||
|| (query.mode === 'detailed')
|
|
||||||
|| (query.detailed === 'true' || query.detailed === true);
|
|
||||||
|
|
||||||
const isVerbose = argIncludes(args, 'verbose') || argIncludes(args, 'debug')
|
|
||||||
|| query.verbose === 'true' || query.verbose === true
|
|
||||||
|| query.debug === 'true' || query.debug === true;
|
|
||||||
|
|
||||||
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
|
|
||||||
const errorMsg = 'Please provide a 2–4 digit store number.\n' +
|
|
||||||
'Example: `/phonestatus 782` (or `detailed` / `?detailed=true` for more; includes Meraki links) or `https://.../phonestatus?storeNum=782`';
|
|
||||||
await bot.say('markdown', errorMsg);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger('phone:status', `Collecting phone status for store ${storeNum}`, 'debug');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const data = await collectPhoneStatus(storeNum);
|
|
||||||
if (!data) throw new Error('collectPhoneStatus returned undefined');
|
|
||||||
|
|
||||||
// JSON alt-output path (kept in the handler because it bypasses
|
|
||||||
// markdown rendering entirely — no shared renderer applies).
|
|
||||||
if (query.format === 'json' || (args[1] && args[1].toLowerCase() === 'json')) {
|
|
||||||
const jsonPayload = {
|
|
||||||
store: storeNum,
|
|
||||||
mainNumber: data.locationMainNumber,
|
|
||||||
timezone: (data.telephonyProfile && data.telephonyProfile.timeZone) || null,
|
|
||||||
person: data.person ? { displayName: data.person.displayName, phoneNumbers: data.person.phoneNumbers } : null,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
await bot.say('markdown', '```json\n' + JSON.stringify(jsonPayload, null, 2) + '\n```');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Discover reachable DECT bases BEFORE rendering so we can tell
|
|
||||||
// the renderer how many bases the follow-up will cover. Discovery
|
|
||||||
// is a pure filter over what phoneService already fetched — no
|
|
||||||
// network calls, so it doesn't slow the main output. Only chat
|
|
||||||
// triggers get a follow-up; HTTP callers keep the single-message
|
|
||||||
// contract they had before.
|
|
||||||
const dectFollowUpEnabled = !!trigger.person;
|
|
||||||
const { bases: reachableBases, warnings: discoveryWarnings } = dectFollowUpEnabled
|
|
||||||
? discoverDectBases(data)
|
|
||||||
: { bases: [], warnings: [] };
|
|
||||||
const { phones: mppPhones, warnings: mppDiscoveryWarnings } = dectFollowUpEnabled
|
|
||||||
? discoverDeskPhones(data)
|
|
||||||
: { phones: [], warnings: [] };
|
|
||||||
if (discoveryWarnings.length > 0) {
|
|
||||||
logger(
|
|
||||||
'phone:status',
|
|
||||||
`DECT discovery warnings for store ${storeNum}: ${discoveryWarnings.map((w) => w.reason).join('; ')}`,
|
|
||||||
'warn',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (mppDiscoveryWarnings.length > 0) {
|
|
||||||
logger(
|
|
||||||
'phone:status',
|
|
||||||
`MPP discovery warnings for store ${storeNum}: ${mppDiscoveryWarnings.map((w) => w.reason).join('; ')}`,
|
|
||||||
'warn',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (dectFollowUpEnabled) {
|
|
||||||
logger(
|
|
||||||
'phone:status',
|
|
||||||
`MPP discovery for store ${storeNum}: ` +
|
|
||||||
`${mppPhones.length > 0
|
|
||||||
? `${mppPhones.length} desk phone(s) selected — follow-up scheduled (${mppPhones.map((p) => p.ip).join(', ')})`
|
|
||||||
: 'no MPP desk phones selected — no follow-up'}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// WAN follow-up discovery. Cheap Prisma-side check: hits the
|
|
||||||
// 4-hour-cached sites list to confirm this store is Prisma-
|
|
||||||
// managed before we promise a follow-up. Anything that throws
|
|
||||||
// (missing env, auth failure, network error) is swallowed and
|
|
||||||
// treated as "no site" so a broken Prisma integration cannot
|
|
||||||
// break /phonestatus. Chat-only, same as DECT.
|
|
||||||
let wanFollowUpEnabled = false;
|
|
||||||
if (trigger.person) {
|
|
||||||
const expectedSite = safeSiteName(storeNum);
|
|
||||||
try {
|
|
||||||
const site = await findSdwanSiteForStore(storeNum);
|
|
||||||
wanFollowUpEnabled = !!site;
|
|
||||||
// Info-level breadcrumb so operators can see the discovery
|
|
||||||
// outcome without cranking LOG_LEVEL=debug. The success case
|
|
||||||
// is also logged inside findSdwanSiteForStore; this line
|
|
||||||
// provides the "why /phonestatus did / didn't schedule a
|
|
||||||
// WAN follow-up" answer at the phone:status scope.
|
|
||||||
logger(
|
|
||||||
'phone:status',
|
|
||||||
`WAN discovery for store ${storeNum} (expected ${expectedSite}): ` +
|
|
||||||
`${wanFollowUpEnabled ? 'MATCHED — follow-up scheduled' : 'no match — no follow-up'}`,
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
logger(
|
|
||||||
'phone:status',
|
|
||||||
`WAN discovery skipped for store ${storeNum} (expected ${expectedSite}): ${err.message}`,
|
|
||||||
'warn',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const reply = renderPhoneStatusMarkdown(data, {
|
|
||||||
storeNum,
|
|
||||||
detailed: isDetailed,
|
|
||||||
footer: true,
|
|
||||||
dectFollowUpBaseCount: reachableBases.length,
|
|
||||||
mppFollowUpPhoneCount: mppPhones.length,
|
|
||||||
wanFollowUpEnabled,
|
|
||||||
});
|
|
||||||
await bot.say('markdown', reply || 'No data available.');
|
|
||||||
|
|
||||||
// Kick off DECT follow-up. Fire-and-forget from this handler's
|
|
||||||
// perspective — the awaits inside runDectFollowUp() are just so
|
|
||||||
// failures get logged with a stable scope, they don't propagate
|
|
||||||
// back to the user's original /phonestatus call. If the relay is
|
|
||||||
// offline or a base is unreachable we still post the follow-up
|
|
||||||
// (with per-base error lines) so the user isn't left wondering
|
|
||||||
// where the promised diagnostics went.
|
|
||||||
if (dectFollowUpEnabled && reachableBases.length > 0) {
|
|
||||||
runDectFollowUp(bot, storeNum, reachableBases).catch((err) => {
|
|
||||||
logger('phone:status', `DECT follow-up failed for store ${storeNum}: ${err.message}`, 'error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (dectFollowUpEnabled && mppPhones.length > 0) {
|
|
||||||
runMppPhoneFollowUp(bot, storeNum, mppPhones, { verbose: isVerbose }).catch((err) => {
|
|
||||||
logger('phone:status', `MPP phone follow-up failed for store ${storeNum}: ${err.message}`, 'error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// WAN follow-up (mirrors the DECT pattern). Only kicked when
|
|
||||||
// discovery above already confirmed we have a Prisma site for
|
|
||||||
// this store. Fire-and-forget with a stable log scope.
|
|
||||||
if (wanFollowUpEnabled) {
|
|
||||||
runWanFollowUp(bot, storeNum).catch((err) => {
|
|
||||||
logger('phone:status', `WAN follow-up failed for store ${storeNum}: ${err.message}`, 'error');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// IGMP-snooping remediation card — only when (a) the multicast
|
|
||||||
// summary flagged deviation AND (b) we know the networkId (can't
|
|
||||||
// fix what we can't address) AND (c) the invocation came from
|
|
||||||
// chat, not HTTP. HTTP callers don't have adaptive-card UX; the
|
|
||||||
// remediation surface for them is a future gated POST endpoint.
|
|
||||||
// `trigger.person` is populated by the framework for chat triggers
|
|
||||||
// and absent for HTTP triggers (see index.js command dispatch).
|
|
||||||
if (data.multicast?.needsFix && data.multicast.networkId && trigger.person) {
|
|
||||||
const cardId = randomUUID();
|
|
||||||
const requester = extractRequester(trigger);
|
|
||||||
pendingIgmpFixes.set(cardId, {
|
|
||||||
networkId: data.multicast.networkId,
|
|
||||||
networkName: data.multicast.networkName,
|
|
||||||
storeNum,
|
|
||||||
requester,
|
|
||||||
// Only the summary is stashed. The fix payload is a constant,
|
|
||||||
// so we don't need the raw snapshot — keeps the pending-store
|
|
||||||
// memory footprint tiny and avoids the temptation to
|
|
||||||
// read-then-mutate at PUT time.
|
|
||||||
summary: {
|
|
||||||
defaultSnoopOn: data.multicast.defaultSnoopOn,
|
|
||||||
defaultFloodOff: data.multicast.defaultFloodOff,
|
|
||||||
deviatingOverrides: data.multicast.deviatingOverrides || [],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const card = buildIgmpFixCard({
|
|
||||||
storeNum,
|
|
||||||
networkName: data.multicast.networkName,
|
|
||||||
summary: {
|
|
||||||
defaultSnoopOn: data.multicast.defaultSnoopOn,
|
|
||||||
defaultFloodOff: data.multicast.defaultFloodOff,
|
|
||||||
deviatingOverrides: data.multicast.deviatingOverrides || [],
|
|
||||||
},
|
|
||||||
cardId,
|
|
||||||
});
|
|
||||||
await bot.say({
|
|
||||||
markdown: `Multicast policy on store ${storeNum}'s network deviates from DECT-safe defaults. Review and confirm:`,
|
|
||||||
attachments: [{
|
|
||||||
contentType: 'application/vnd.microsoft.card.adaptive',
|
|
||||||
content: card,
|
|
||||||
}],
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (err) {
|
|
||||||
logger('phone:status', `Error collecting phone status for store ${storeNum}: ${err.message}`, 'error');
|
|
||||||
await bot.say('markdown', `Error collecting phone status: ${err.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run the DECT-diagnostics follow-up as a separate message in the
|
|
||||||
* same room. Only invoked from chat triggers. Errors are logged
|
|
||||||
* (never thrown up) — the /phonestatus main output has already been
|
|
||||||
* sent by the time we get here, so a follow-up crash shouldn't leave
|
|
||||||
* the user with a broken chat experience.
|
|
||||||
*
|
|
||||||
* Renderer emits an empty string only when the results list is empty
|
|
||||||
* — which shouldn't happen because we already checked reachableBases
|
|
||||||
* .length > 0 at the call site, but we still guard against it here.
|
|
||||||
*/
|
|
||||||
async function runDectFollowUp(bot, storeNum, bases) {
|
|
||||||
const results = await collectAll(bases);
|
|
||||||
const md = renderDectDiagnosticsMarkdown(results, { storeNum });
|
|
||||||
if (!md) return;
|
|
||||||
await bot.say('markdown', md);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function runMppPhoneFollowUp(bot, storeNum, phones, { verbose = false } = {}) {
|
|
||||||
const results = await probeAll(phones);
|
|
||||||
const md = renderMppPhoneDiagnosticsMarkdown(results, { storeNum, verbose });
|
|
||||||
if (!md) return;
|
|
||||||
await bot.say('markdown', md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prisma SD-WAN follow-up. Runs the enrichment composer (which
|
|
||||||
* hydrates site + elements + healthscore + per-path LQM + alarms in
|
|
||||||
* parallel), hands the result to the pure WAN renderer, and posts.
|
|
||||||
*
|
|
||||||
* The composer never throws — every metric failure is preserved in
|
|
||||||
* `data.errors[]` and rendered as an inline "partial fetch" warning
|
|
||||||
* so the operator can see WHAT failed rather than getting silence.
|
|
||||||
* A missing site (unexpected here since we pre-discovered) short-
|
|
||||||
* circuits with an empty markdown string, and we no-op.
|
|
||||||
*/
|
|
||||||
async function runWanFollowUp(bot, storeNum) {
|
|
||||||
const data = await collectSdwanForStore(storeNum);
|
|
||||||
const md = renderWanDiagnosticsMarkdown(data, { storeNum });
|
|
||||||
if (!md) return;
|
|
||||||
await bot.say('markdown', md);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small helper for a log line that runs before we've committed to
|
|
||||||
* a site lookup — used inside the catch branch of WAN discovery
|
|
||||||
* where we want to show the expected site name even if the lookup
|
|
||||||
* failed. Isolated in a function so the try/catch is single-line
|
|
||||||
* and the intent is obvious.
|
|
||||||
*/
|
|
||||||
function safeSiteName(storeNum) {
|
|
||||||
try {
|
|
||||||
return siteNameForStore(storeNum);
|
|
||||||
} catch {
|
|
||||||
return '<invalid store number>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function argIncludes(args, token) {
|
|
||||||
const needle = String(token).toLowerCase();
|
|
||||||
return (args || []).some((a) => String(a).toLowerCase() === needle);
|
|
||||||
}
|
|
||||||
|
|
@ -13,8 +13,10 @@
|
||||||
|
|
||||||
import { handleHelp } from './help.js';
|
import { handleHelp } from './help.js';
|
||||||
import { handleAvStatus } from './avStatus.js';
|
import { handleAvStatus } from './avStatus.js';
|
||||||
import { handlePhoneStatus } from './phoneStatus.js';
|
import { handleVoiceStatus } from './voiceStatus.js';
|
||||||
import { handleDectStatus } from './dectStatus.js';
|
import { handleWanStatus } from './wanStatus.js';
|
||||||
|
import { handlePhoneDiag } from './phoneDiag.js';
|
||||||
|
import { handleDectDiag } from './dectDiag.js';
|
||||||
import { handleProvisionDect } from './provisionDect.js';
|
import { handleProvisionDect } from './provisionDect.js';
|
||||||
import { handleWoHistory } from './woHistory.js';
|
import { handleWoHistory } from './woHistory.js';
|
||||||
import { handleWoSummary } from './woSummary.js';
|
import { handleWoSummary } from './woSummary.js';
|
||||||
|
|
@ -46,11 +48,13 @@ export const commands = [
|
||||||
{ name: 'help', handler: handleHelp, mutating: false, http: false },
|
{ name: 'help', handler: handleHelp, mutating: false, http: false },
|
||||||
|
|
||||||
{ name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false },
|
{ name: 'avstatus', aliases: ['wostatus'], handler: handleAvStatus, mutating: false },
|
||||||
{ name: 'phonestatus', handler: handlePhoneStatus, mutating: false },
|
{ name: 'voicestatus', handler: handleVoiceStatus, mutating: false },
|
||||||
// /dectstatus — full DECT base dump via the on-prem relay + chat-only
|
{ name: 'wanstatus', handler: handleWanStatus, mutating: false },
|
||||||
|
{ name: 'phonediag', handler: handlePhoneDiag, mutating: false },
|
||||||
|
// /dectdiag — full DECT base dump via the on-prem relay + chat-only
|
||||||
// reboot/factory-reset cards. Read path is non-mutating; card submits
|
// reboot/factory-reset cards. Read path is non-mutating; card submits
|
||||||
// only fire over Webex (see commands/dectStatus.js).
|
// only fire over Webex (see commands/dectDiag.js).
|
||||||
{ name: 'dectstatus', aliases: ['dect'], handler: handleDectStatus, mutating: false },
|
{ name: 'dectdiag', aliases: ['dect'], handler: handleDectDiag, mutating: false },
|
||||||
// /voicediag reads per-user Webex Calling features via
|
// /voicediag reads per-user Webex Calling features via
|
||||||
// /v1/people/{id}/features/* and offers per-issue adaptive-card
|
// /v1/people/{id}/features/* and offers per-issue adaptive-card
|
||||||
// remediation. Reads are non-mutating but the confirm buttons on
|
// remediation. Reads are non-mutating but the confirm buttons on
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,9 @@ import {
|
||||||
buildRemediationRegistry,
|
buildRemediationRegistry,
|
||||||
CHECKS,
|
CHECKS,
|
||||||
} from '../services/voiceDiag/voiceDiagService.js';
|
} from '../services/voiceDiag/voiceDiagService.js';
|
||||||
|
import { pickWindowArg, parseWindowMinutes } from '../utils/windowArgs.js';
|
||||||
|
|
||||||
|
export { parseWindowMinutes };
|
||||||
|
|
||||||
// Built once at module load. `buildRemediationRegistry` throws on
|
// Built once at module load. `buildRemediationRegistry` throws on
|
||||||
// duplicate action ids, so any collision surfaces at import time —
|
// duplicate action ids, so any collision surfaces at import time —
|
||||||
|
|
@ -58,7 +61,7 @@ export async function handleVoiceDiag(bot, trigger) {
|
||||||
let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s;
|
let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s;
|
||||||
|
|
||||||
// Accept every reasonable variant so the footer hint ("pass
|
// Accept every reasonable variant so the footer hint ("pass
|
||||||
// `detailed`") and the /phonestatus muscle memory ("`detailed`" as
|
// `detailed`") and the /voicestatus muscle memory ("`detailed`" as
|
||||||
// the second positional) both work, alongside the flag forms
|
// the second positional) both work, alongside the flag forms
|
||||||
// (`--detail`, `--detailed`) and the `?detailed=true` query param.
|
// (`--detail`, `--detailed`) and the `?detailed=true` query param.
|
||||||
const detailed = argIncludes(args, 'detail')
|
const detailed = argIncludes(args, 'detail')
|
||||||
|
|
@ -145,7 +148,7 @@ export async function handleVoiceDiag(bot, trigger) {
|
||||||
|
|
||||||
// Adaptive-card remediation is chat-only. HTTP callers get the
|
// Adaptive-card remediation is chat-only. HTTP callers get the
|
||||||
// markdown snapshot but no interactive cards (same rule the
|
// markdown snapshot but no interactive cards (same rule the
|
||||||
// /phonestatus + IGMP-fix inline card follows). `trigger.person`
|
// /voicestatus + IGMP-fix inline card follows). `trigger.person`
|
||||||
// is populated for chat, absent for the HTTP fake trigger.
|
// is populated for chat, absent for the HTTP fake trigger.
|
||||||
if (!trigger.person) return;
|
if (!trigger.person) return;
|
||||||
|
|
||||||
|
|
@ -348,10 +351,6 @@ function pickOnlyArg(args) {
|
||||||
return pickFlagValue(args, '--only');
|
return pickFlagValue(args, '--only');
|
||||||
}
|
}
|
||||||
|
|
||||||
function pickWindowArg(args) {
|
|
||||||
return pickFlagValue(args, '--window');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generic `--flag=value` / `--flag value` extractor.
|
// Generic `--flag=value` / `--flag value` extractor.
|
||||||
function pickFlagValue(args, flag) {
|
function pickFlagValue(args, flag) {
|
||||||
const prefix = `${flag.toLowerCase()}=`;
|
const prefix = `${flag.toLowerCase()}=`;
|
||||||
|
|
@ -363,27 +362,6 @@ function pickFlagValue(args, flag) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a window shorthand (`15m`, `1h`, `24h`, `1d`, `1440`) into
|
|
||||||
* minutes. Returns null on unrecognised input so the caller can
|
|
||||||
* decide whether to surface a friendly error or fall back to the
|
|
||||||
* env default.
|
|
||||||
*/
|
|
||||||
export function parseWindowMinutes(raw) {
|
|
||||||
if (raw === null || raw === undefined || raw === '') return undefined;
|
|
||||||
const s = String(raw).trim().toLowerCase();
|
|
||||||
// Bare integer → treat as minutes.
|
|
||||||
if (/^\d+$/.test(s)) return Math.max(1, parseInt(s, 10));
|
|
||||||
const m = s.match(/^(\d+)\s*(m|min|mins|h|hr|hrs|hour|hours|d|day|days)$/);
|
|
||||||
if (!m) return null;
|
|
||||||
const n = parseInt(m[1], 10);
|
|
||||||
const unit = m[2];
|
|
||||||
if (['m', 'min', 'mins'].includes(unit)) return n;
|
|
||||||
if (['h', 'hr', 'hrs', 'hour', 'hours'].includes(unit)) return n * 60;
|
|
||||||
if (['d', 'day', 'days'].includes(unit)) return n * 60 * 24;
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderListChecks() {
|
function renderListChecks() {
|
||||||
let md = '**/voicediag registered checks**\n\n';
|
let md = '**/voicediag registered checks**\n\n';
|
||||||
for (const c of CHECKS) {
|
for (const c of CHECKS) {
|
||||||
|
|
|
||||||
103
commands/voiceStatus.js
Normal file
103
commands/voiceStatus.js
Normal file
|
|
@ -0,0 +1,103 @@
|
||||||
|
// src/commands/voiceStatus.js
|
||||||
|
//
|
||||||
|
// Chat + HTTP entry point for /voicestatus. The heavy rendering lives in
|
||||||
|
// services/renderers/phoneStatusRenderer.js so the Jira poller can emit
|
||||||
|
// the same markdown (see services/jiraPollerService.js). This handler
|
||||||
|
// stays thin: parse args, call the collector, hand data to the renderer,
|
||||||
|
// respond.
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { collectPhoneStatus } from '../services/phoneService.js';
|
||||||
|
import { renderPhoneStatusMarkdown } from '../services/renderers/phoneStatusRenderer.js';
|
||||||
|
import { buildIgmpFixCard } from './igmpFix.js';
|
||||||
|
import { pendingIgmpFixes } from '../utils/pendingIgmpFixes.js';
|
||||||
|
import { extractRequester } from '../utils/requester.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
export async function handleVoiceStatus(bot, trigger) {
|
||||||
|
logger('voice:status', 'Handler entered', 'debug');
|
||||||
|
|
||||||
|
const query = trigger.query || {};
|
||||||
|
const args = trigger.args || [];
|
||||||
|
|
||||||
|
let storeNum = args[0]?.trim() || query.storeNum || query.store || query.s;
|
||||||
|
|
||||||
|
const isDetailed = argIncludes(args, 'detailed')
|
||||||
|
|| (query.mode === 'detailed')
|
||||||
|
|| (query.detailed === 'true' || query.detailed === true);
|
||||||
|
|
||||||
|
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
|
||||||
|
const errorMsg = 'Please provide a 2–4 digit store number.\n' +
|
||||||
|
'Example: `/voicestatus 782` (or `detailed` / `?detailed=true` for more; includes Meraki links) or `https://.../voicestatus?storeNum=782`';
|
||||||
|
await bot.say('markdown', errorMsg);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('voice:status', `Collecting voice status for store ${storeNum}`, 'debug');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = await collectPhoneStatus(storeNum);
|
||||||
|
if (!data) throw new Error('collectPhoneStatus returned undefined');
|
||||||
|
|
||||||
|
if (query.format === 'json' || (args[1] && args[1].toLowerCase() === 'json')) {
|
||||||
|
const jsonPayload = {
|
||||||
|
store: storeNum,
|
||||||
|
mainNumber: data.locationMainNumber,
|
||||||
|
timezone: (data.telephonyProfile && data.telephonyProfile.timeZone) || null,
|
||||||
|
person: data.person ? { displayName: data.person.displayName, phoneNumbers: data.person.phoneNumbers } : null,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
await bot.say('markdown', '```json\n' + JSON.stringify(jsonPayload, null, 2) + '\n```');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reply = renderPhoneStatusMarkdown(data, {
|
||||||
|
storeNum,
|
||||||
|
detailed: isDetailed,
|
||||||
|
footer: true,
|
||||||
|
});
|
||||||
|
await bot.say('markdown', reply || 'No data available.');
|
||||||
|
|
||||||
|
if (data.multicast?.needsFix && data.multicast.networkId && trigger.person) {
|
||||||
|
const cardId = randomUUID();
|
||||||
|
const requester = extractRequester(trigger);
|
||||||
|
pendingIgmpFixes.set(cardId, {
|
||||||
|
networkId: data.multicast.networkId,
|
||||||
|
networkName: data.multicast.networkName,
|
||||||
|
storeNum,
|
||||||
|
requester,
|
||||||
|
summary: {
|
||||||
|
defaultSnoopOn: data.multicast.defaultSnoopOn,
|
||||||
|
defaultFloodOff: data.multicast.defaultFloodOff,
|
||||||
|
deviatingOverrides: data.multicast.deviatingOverrides || [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const card = buildIgmpFixCard({
|
||||||
|
storeNum,
|
||||||
|
networkName: data.multicast.networkName,
|
||||||
|
summary: {
|
||||||
|
defaultSnoopOn: data.multicast.defaultSnoopOn,
|
||||||
|
defaultFloodOff: data.multicast.defaultFloodOff,
|
||||||
|
deviatingOverrides: data.multicast.deviatingOverrides || [],
|
||||||
|
},
|
||||||
|
cardId,
|
||||||
|
});
|
||||||
|
await bot.say({
|
||||||
|
markdown: `Multicast policy on store ${storeNum}'s network deviates from DECT-safe defaults. Review and confirm:`,
|
||||||
|
attachments: [{
|
||||||
|
contentType: 'application/vnd.microsoft.card.adaptive',
|
||||||
|
content: card,
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
logger('voice:status', `Error collecting voice status for store ${storeNum}: ${err.message}`, 'error');
|
||||||
|
await bot.say('markdown', `Error collecting voice status: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function argIncludes(args, token) {
|
||||||
|
const needle = String(token).toLowerCase();
|
||||||
|
return (args || []).some((a) => String(a).toLowerCase() === needle);
|
||||||
|
}
|
||||||
77
commands/wanStatus.js
Normal file
77
commands/wanStatus.js
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
// src/commands/wanStatus.js
|
||||||
|
//
|
||||||
|
// /wanstatus <store> [--window 24h]
|
||||||
|
//
|
||||||
|
// Prisma SD-WAN diagnostics for a store: healthscore, per-path LQM,
|
||||||
|
// voice traffic quality (when configured), and active alarms.
|
||||||
|
|
||||||
|
import { collectSdwanForStore } from '../services/enrichment/sdwanEnrichment.js';
|
||||||
|
import { renderWanDiagnosticsMarkdown } from '../services/renderers/wanDiagnosticsRenderer.js';
|
||||||
|
import { findSdwanSiteForStore, siteNameForStore } from '../integrations/paloalto/sites.js';
|
||||||
|
import { pickWindowArg, parseWindowMinutes } from '../utils/windowArgs.js';
|
||||||
|
import { logger } from '../utils/logger.js';
|
||||||
|
|
||||||
|
export async function handleWanStatus(bot, trigger) {
|
||||||
|
const query = trigger.query || {};
|
||||||
|
const args = trigger.args || [];
|
||||||
|
const storeNum = (args[0]?.trim() || query.storeNum || query.store || query.s || '').trim();
|
||||||
|
|
||||||
|
const windowRaw = pickWindowArg(args) ?? query.window ?? null;
|
||||||
|
const windowMinutes = parseWindowMinutes(windowRaw);
|
||||||
|
if (windowRaw && windowMinutes == null) {
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`Unrecognised window \`${windowRaw}\`. Use \`15m\`, \`1h\`, \`6h\`, \`24h\`, \`1d\`, or \`7d\`.`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!storeNum || !/^\d{2,4}$/.test(storeNum)) {
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
'Please provide a 2–4 digit store number.\n' +
|
||||||
|
'Examples:\n' +
|
||||||
|
'- `/wanstatus 782`\n' +
|
||||||
|
'- `/wanstatus 782 --window 24h`\n' +
|
||||||
|
'HTTP: `?storeNum=782&window=7d`',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger('wan:status', `Collecting WAN status for store ${storeNum}`);
|
||||||
|
|
||||||
|
let site;
|
||||||
|
try {
|
||||||
|
site = await findSdwanSiteForStore(storeNum);
|
||||||
|
} catch (err) {
|
||||||
|
logger('wan:status', `Site lookup failed for store ${storeNum}: ${err.message}`, 'warn');
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`❌ WAN lookup failed for store ${storeNum}: ${err.message}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!site) {
|
||||||
|
const expected = safeSiteName(storeNum);
|
||||||
|
await bot.say(
|
||||||
|
'markdown',
|
||||||
|
`Store ${storeNum} is not Prisma SD-WAN managed (expected site \`${expected}\`).`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await bot.say('markdown', `⏳ Collecting WAN metrics for store ${storeNum}…`);
|
||||||
|
|
||||||
|
const data = await collectSdwanForStore(storeNum, { windowMinutes });
|
||||||
|
const md = renderWanDiagnosticsMarkdown(data, { storeNum });
|
||||||
|
await bot.say('markdown', md || 'No WAN data available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeSiteName(storeNum) {
|
||||||
|
try {
|
||||||
|
return siteNameForStore(storeNum);
|
||||||
|
} catch {
|
||||||
|
return '<invalid store number>';
|
||||||
|
}
|
||||||
|
}
|
||||||
4
index.js
4
index.js
|
|
@ -23,7 +23,7 @@ import { handleDectProvisionAction } from './commands/provisionDect.js';
|
||||||
import {
|
import {
|
||||||
handleDectStatusAction,
|
handleDectStatusAction,
|
||||||
DECT_STATUS_CARD_ACTIONS,
|
DECT_STATUS_CARD_ACTIONS,
|
||||||
} from './commands/dectStatus.js';
|
} from './commands/dectDiag.js';
|
||||||
import {
|
import {
|
||||||
applyOffboardConfirmation,
|
applyOffboardConfirmation,
|
||||||
cancelOffboardCard,
|
cancelOffboardCard,
|
||||||
|
|
@ -840,7 +840,7 @@ if (process.env.DECT_RELAY_AGENT_TOKEN) {
|
||||||
} else {
|
} else {
|
||||||
logger(
|
logger(
|
||||||
'startup',
|
'startup',
|
||||||
'DECT relay disabled — set DECT_RELAY_AGENT_TOKEN in .env (and share the same value with dect-relay-agent) to enable /phonestatus DECT follow-up + /dectstatus',
|
'DECT relay disabled — set DECT_RELAY_AGENT_TOKEN in .env (and share the same value with dect-relay-agent) to enable /phonediag, /dectdiag, and /voicediag relay probes',
|
||||||
'warn',
|
'warn',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -78,13 +78,37 @@ export function downloadStatusWarnings(download) {
|
||||||
const warnings = [];
|
const warnings = [];
|
||||||
if (!download) return warnings;
|
if (!download) return warnings;
|
||||||
if (download.micCert?.failed) {
|
if (download.micCert?.failed) {
|
||||||
warnings.push(`MIC cert: ${download.micCert.provisioningStatus}`);
|
warnings.push('mic_cert_failed');
|
||||||
}
|
}
|
||||||
if (download.latestProvisioning?.failed) {
|
if (download.latestProvisioning?.failed) {
|
||||||
warnings.push(`provisioning: ${download.latestProvisioning.result}`);
|
warnings.push('provisioning_failed');
|
||||||
}
|
}
|
||||||
if (download.latestFirmwareUpgrade?.failed) {
|
if (download.latestFirmwareUpgrade?.failed) {
|
||||||
warnings.push(`firmware upgrade: ${download.latestFirmwareUpgrade.result}`);
|
warnings.push('firmware_upgrade_failed');
|
||||||
}
|
}
|
||||||
return warnings;
|
return warnings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable MIC cert issue for chat rendering (no raw URLs).
|
||||||
|
* @param {{ provisioningStatus?: string|null, info?: string|null, failed?: boolean }|null} micCert
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function formatMicCertForDisplay(micCert) {
|
||||||
|
if (!micCert?.failed && !micCert?.provisioningStatus) return null;
|
||||||
|
const entry = parseHistoryEntry(micCert.provisioningStatus || micCert.info);
|
||||||
|
const at = entry?.timestamp || null;
|
||||||
|
const reason = shortenMicReason(entry?.result || micCert.provisioningStatus || 'download failed');
|
||||||
|
const atSuffix = at ? ` (${at})` : '';
|
||||||
|
return `MIC cert download failed — ${reason}${atSuffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortenMicReason(raw) {
|
||||||
|
if (!raw || typeof raw !== 'string') return 'unknown error';
|
||||||
|
const reasonMatch = raw.match(/Reason:\s*(.+?)\.?$/i);
|
||||||
|
if (reasonMatch) return reasonMatch[1].trim();
|
||||||
|
const failedMatch = raw.match(/MIC Cert Download Failed\.?\s*(.*)$/i);
|
||||||
|
if (failedMatch && failedMatch[1].trim()) return failedMatch[1].replace(/^Reason:\s*/i, '').trim();
|
||||||
|
if (/fail/i.test(raw)) return 'download failed';
|
||||||
|
return raw.length > 80 ? `${raw.slice(0, 77)}…` : raw;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// services/dectStatus/buildHandsetContext.js
|
// services/dectStatus/buildHandsetContext.js
|
||||||
//
|
//
|
||||||
// Group Webex DECT handset inventory for /dectstatus rendering.
|
// Group Webex DECT handset inventory for /dectdiag rendering.
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {object|null} phoneData collectPhoneStatus() output
|
* @param {object|null} phoneData collectPhoneStatus() output
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// src/services/enrichment/sdwanEnrichment.js
|
// src/services/enrichment/sdwanEnrichment.js
|
||||||
//
|
//
|
||||||
// One-stop composer for Prisma SD-WAN data used by /phonestatus's
|
// One-stop composer for Prisma SD-WAN data used by /wanstatus and
|
||||||
// WAN follow-up and /voicediag's WAN check bucket. Same "fire the
|
// WAN follow-up and /voicediag's WAN check bucket. Same "fire the
|
||||||
// fetches in parallel + normalise into a stable shape + preserve
|
// fetches in parallel + normalise into a stable shape + preserve
|
||||||
// per-metric failures in errors[]" pattern used by the Meraki
|
// per-metric failures in errors[]" pattern used by the Meraki
|
||||||
|
|
@ -80,7 +80,7 @@ import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js';
|
||||||
import { coerceAlarmTsMs } from './alarmSemantics.js';
|
import { coerceAlarmTsMs } from './alarmSemantics.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compose everything /voicediag + /phonestatus care about for a
|
* Compose everything /voicediag + /wanstatus care about for a
|
||||||
* store's Prisma SD-WAN posture. Never throws — always returns the
|
* store's Prisma SD-WAN posture. Never throws — always returns the
|
||||||
* shape documented at the top of this file.
|
* shape documented at the top of this file.
|
||||||
*
|
*
|
||||||
|
|
|
||||||
|
|
@ -22,9 +22,9 @@ For tickets with component **Communication Services** and a resolvable store:
|
||||||
|
|
||||||
| Symptom keywords | Checks run |
|
| Symptom keywords | Checks run |
|
||||||
|------------------|------------|
|
|------------------|------------|
|
||||||
| Call quality / connectivity (garbled, static, can't connect, …) | `phonestatus` + `callreport` |
|
| Call quality / connectivity (garbled, static, can't connect, …) | `voicestatus` + `callreport` |
|
||||||
| Spam / robocall / nuisance | `callreport` only |
|
| Spam / robocall / nuisance | `callreport` only |
|
||||||
| Neither | Default: `phone` → phonestatus, `av` → avstatus |
|
| Neither | Default: `phone` → voicestatus, `av` → avstatus |
|
||||||
|
|
||||||
`callreport` uses the **today** business window (9am local → now minus 5 min).
|
`callreport` uses the **today** business window (9am local → now minus 5 min).
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@
|
||||||
export const COMM_SERVICES_COMPONENT = 'Communication Services';
|
export const COMM_SERVICES_COMPONENT = 'Communication Services';
|
||||||
|
|
||||||
export const CHECK_IDS = {
|
export const CHECK_IDS = {
|
||||||
phonestatus: 'phonestatus',
|
voicestatus: 'voicestatus',
|
||||||
callreport: 'callreport',
|
callreport: 'callreport',
|
||||||
avstatus: 'avstatus',
|
avstatus: 'avstatus',
|
||||||
};
|
};
|
||||||
|
|
@ -109,7 +109,7 @@ export function resolveEnrichmentPlan(ticket, classification) {
|
||||||
}
|
}
|
||||||
const checks = [];
|
const checks = [];
|
||||||
if (callQuality) {
|
if (callQuality) {
|
||||||
checks.push(CHECK_IDS.phonestatus, CHECK_IDS.callreport);
|
checks.push(CHECK_IDS.voicestatus, CHECK_IDS.callreport);
|
||||||
} else if (spam) {
|
} else if (spam) {
|
||||||
checks.push(CHECK_IDS.callreport);
|
checks.push(CHECK_IDS.callreport);
|
||||||
}
|
}
|
||||||
|
|
@ -128,7 +128,7 @@ export function resolveEnrichmentPlan(ticket, classification) {
|
||||||
|
|
||||||
if (classification.kind === 'phone') {
|
if (classification.kind === 'phone') {
|
||||||
return {
|
return {
|
||||||
checks: [CHECK_IDS.phonestatus],
|
checks: [CHECK_IDS.voicestatus],
|
||||||
matchedRules: ['default-phone'],
|
matchedRules: ['default-phone'],
|
||||||
storeNum,
|
storeNum,
|
||||||
skip: false,
|
skip: false,
|
||||||
|
|
|
||||||
|
|
@ -14,10 +14,10 @@ import { formatEnrichmentBody } from './formatBody.js';
|
||||||
const LOG_SCOPE = 'jira:poller:enrich';
|
const LOG_SCOPE = 'jira:poller:enrich';
|
||||||
|
|
||||||
const CHECK_RUNNERS = {
|
const CHECK_RUNNERS = {
|
||||||
[CHECK_IDS.phonestatus]: async (storeNum) => {
|
[CHECK_IDS.voicestatus]: async (storeNum) => {
|
||||||
const data = await collectPhoneStatus(storeNum);
|
const data = await collectPhoneStatus(storeNum);
|
||||||
return {
|
return {
|
||||||
title: 'Phone status',
|
title: 'Voice status',
|
||||||
markdown: renderPhoneStatusMarkdown(data, {
|
markdown: renderPhoneStatusMarkdown(data, {
|
||||||
storeNum,
|
storeNum,
|
||||||
detailed: true,
|
detailed: true,
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@
|
||||||
// line — so future non-store enrichment tooling can pick it up later.
|
// line — so future non-store enrichment tooling can pick it up later.
|
||||||
//
|
//
|
||||||
// Comment format — Jira Cloud v3 requires ADF. We emit an italic
|
// Comment format — Jira Cloud v3 requires ADF. We emit an italic
|
||||||
// header paragraph followed by the same markdown the /phonestatus or
|
// header paragraph followed by the same markdown the /voicestatus or
|
||||||
// /avstatus chat command would produce, converted to ADF paragraphs
|
// /avstatus chat command would produce, converted to ADF paragraphs
|
||||||
// via utils/markdownToAdf. This preserves clickable Meraki links,
|
// via utils/markdownToAdf. This preserves clickable Meraki links,
|
||||||
// bold device names, and paragraph structure that the previous
|
// bold device names, and paragraph structure that the previous
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// Thin wrapper for phone data rich/build shape for the phone store dashboard.
|
// Thin wrapper for phone data rich/build shape for the phone store dashboard.
|
||||||
// Delegates to collectPhoneStatus (which already does Webex phones/DECT + Meraki attach + relevant ports).
|
// Delegates to collectPhoneStatus (which already does Webex phones/DECT + Meraki attach + relevant ports).
|
||||||
// Adds optional Meraki topology for viz mirroring the AV dashboard.
|
// Adds optional Meraki topology for viz mirroring the AV dashboard.
|
||||||
// Keeps backward compat for the /phonestatus command (which uses collect directly).
|
// Keeps backward compat for the /voicestatus command (which uses collect directly).
|
||||||
|
|
||||||
import { collectPhoneStatus } from './phoneService.js';
|
import { collectPhoneStatus } from './phoneService.js';
|
||||||
import { getMerakiTopology } from '../integrations/meraki/devices.js';
|
import { getMerakiTopology } from '../integrations/meraki/devices.js';
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
// src/services/renderers/dectStatusRenderer.js
|
// src/services/renderers/dectStatusRenderer.js
|
||||||
//
|
//
|
||||||
// Full CLI-style DECT base dump for `/dectstatus`. Complements the
|
// Full CLI-style DECT base dump for `/dectdiag`.
|
||||||
// compact follow-up from renderDectDiagnosticsMarkdown (phonestatus).
|
|
||||||
|
|
||||||
import { formatDisplayTime, simpleTimeAgo } from '../../utils/time.js';
|
import { formatDisplayTime, simpleTimeAgo } from '../../utils/time.js';
|
||||||
import { handsetsForBase } from '../dectStatus/buildHandsetContext.js';
|
import { handsetsForBase } from '../dectStatus/buildHandsetContext.js';
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,18 @@
|
||||||
// services/renderers/mppPhoneDiagnosticsRenderer.js
|
// services/renderers/mppPhoneDiagnosticsRenderer.js
|
||||||
//
|
//
|
||||||
// MPP desk phone diagnostics follow-up for /phonestatus (via relay).
|
// MPP desk phone diagnostics for /phonediag (via relay).
|
||||||
|
|
||||||
import { formatDisplayTime } from '../../utils/time.js';
|
import { formatDisplayTime } from '../../utils/time.js';
|
||||||
|
import {
|
||||||
|
shortenFirmware,
|
||||||
|
formatHeaderStatusLine,
|
||||||
|
headerIconForLine,
|
||||||
|
isLineRegistered,
|
||||||
|
formatRebootEntry,
|
||||||
|
formatProvisioningResync,
|
||||||
|
formatFirmwareUpgrade,
|
||||||
|
collectIssues,
|
||||||
|
} from './mppPhoneFormatters.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {Array} results probeAll() output
|
* @param {Array} results probeAll() output
|
||||||
|
|
@ -40,165 +50,148 @@ function renderOnePhone(r, { verbose }) {
|
||||||
const ip = r.phone?.ip || r.parsed?.network?.ipv4 || '?';
|
const ip = r.phone?.ip || r.parsed?.network?.ipv4 || '?';
|
||||||
|
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
return `⚠️ **${label}** (${ip}) — probe failed: ${r.error?.message || 'unknown error'}` +
|
return `⚠️ **${label}** · \`${ip}\` — probe failed: ${r.error?.message || 'unknown error'}` +
|
||||||
(r.error?.hint ? `\n _${r.error.hint}_` : '');
|
(r.error?.hint ? `\n_${r.error.hint}_` : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const mpp = r.mpp || {};
|
const mpp = r.mpp || {};
|
||||||
const parsed = mpp.parsed || r.parsed || {};
|
const parsed = mpp.parsed || r.parsed || {};
|
||||||
const verdict = mpp.verdict || r.verdict || {};
|
|
||||||
const device = parsed.device || {};
|
const device = parsed.device || {};
|
||||||
const network = parsed.network || {};
|
|
||||||
const phoneStatus = parsed.phoneStatus || {};
|
const phoneStatus = parsed.phoneStatus || {};
|
||||||
const line1 = parsed.lines?.find((l) => l.index === 1) || parsed.lines?.[0];
|
const line1 = parsed.lines?.find((l) => l.index === 1) || parsed.lines?.[0];
|
||||||
const neighbor = mpp.networkNeighbor || {};
|
const neighbor = mpp.networkNeighbor || {};
|
||||||
const download = mpp.download || {};
|
const download = mpp.download || {};
|
||||||
const system = mpp.system || {};
|
const issues = collectIssues(mpp, parsed);
|
||||||
|
|
||||||
const icon = verdict.healthy ? '✅' : '⚠️';
|
let out = renderPhoneHeader({ label, ip, device, phoneStatus, neighbor, line1 });
|
||||||
const fwShort = shortenFirmware(device.firmware);
|
out += renderRegistrationSection(line1, phoneStatus);
|
||||||
let out = `${icon} **${label}** (${ip})\n`;
|
out += renderSwitchSection(neighbor);
|
||||||
out += ` ${device.product || '?'} · ${device.mac || '?'} · ${fwShort}\n`;
|
out += renderProvisioningSection(parsed, download);
|
||||||
|
out += renderIssuesSection(issues);
|
||||||
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) {
|
if (verbose) {
|
||||||
out += renderVerboseTier(r, parsed, neighbor, mpp.probes);
|
out += renderVerboseTier(r, parsed, neighbor, mpp.probes, line1);
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderVerboseTier(r, parsed, neighbor, probes) {
|
function renderPhoneHeader({ label, ip, device, phoneStatus, neighbor, line1 }) {
|
||||||
|
const icon = headerIconForLine(line1);
|
||||||
|
const fwShort = shortenFirmware(device.firmware);
|
||||||
|
let out = `${icon} **${label}** · \`${ip}\`\n`;
|
||||||
|
out += `_${device.product || '?'} · ${device.mac || '?'} · ${fwShort}_\n`;
|
||||||
|
const statusLine = formatHeaderStatusLine(phoneStatus, neighbor);
|
||||||
|
if (statusLine) out += `_${statusLine}_\n`;
|
||||||
|
return `${out}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRegistrationSection(line1, phoneStatus) {
|
||||||
|
const bullets = [];
|
||||||
|
if (line1?.registrationState) {
|
||||||
|
let reg = `Ext ${line1.index}: ${line1.registrationState}`;
|
||||||
|
if (line1.lastRegistrationIp) reg += ` → ${line1.lastRegistrationIp}`;
|
||||||
|
if (line1.lastRegistrationAt) reg += ` (last ${line1.lastRegistrationAt})`;
|
||||||
|
bullets.push(reg);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line1 && !isLineRegistered(line1) && phoneStatus.ledLine1Color) {
|
||||||
|
const led = `${phoneStatus.ledLine1Color}${phoneStatus.ledLine1Cadence ? ` ${phoneStatus.ledLine1Cadence}` : ''}`;
|
||||||
|
bullets.push(`Line 1 LED: ${led}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (line1?.messageWaiting && line1.messageWaiting !== 'No') {
|
||||||
|
bullets.push(`Message waiting: ${line1.messageWaiting}`);
|
||||||
|
}
|
||||||
|
if (line1?.hotelingState && line1.hotelingState !== 'Disabled') {
|
||||||
|
bullets.push(`Hoteling: ${line1.hotelingState}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bullets.length === 0) return '';
|
||||||
|
return `**Registration**\n${bullets.map((b) => `• ${b}`).join('\n')}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSwitchSection(neighbor) {
|
||||||
|
if (!neighbor?.switchDevice) return '';
|
||||||
|
const swIp = neighbor.switchIp ? ` · \`${neighbor.switchIp}\`` : '';
|
||||||
|
const port = neighbor.switchPort || '';
|
||||||
|
return `**Switch**\n• ${neighbor.switchDevice} · ${port}${swIp}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProvisioningSection(parsed, download) {
|
||||||
|
const bullets = [];
|
||||||
|
if (parsed.rebootHistory?.length) {
|
||||||
|
const reboot = formatRebootEntry(parsed.rebootHistory[0]);
|
||||||
|
if (reboot) bullets.push(`Last reboot: ${reboot}`);
|
||||||
|
}
|
||||||
|
const resync = formatProvisioningResync(download.latestProvisioning);
|
||||||
|
if (resync) bullets.push(resync);
|
||||||
|
const fw = formatFirmwareUpgrade(download.latestFirmwareUpgrade);
|
||||||
|
if (fw) bullets.push(fw);
|
||||||
|
|
||||||
|
if (bullets.length === 0) return '';
|
||||||
|
return `**Provisioning**\n${bullets.map((b) => `• ${b}`).join('\n')}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIssuesSection(issues) {
|
||||||
|
if (!Array.isArray(issues) || issues.length === 0) return '';
|
||||||
|
return `**Issues**\n${issues.map((i) => `• ${i}`).join('\n')}\n\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVerboseTier(r, parsed, neighbor, probes, line1) {
|
||||||
let out = '';
|
let out = '';
|
||||||
|
|
||||||
const otherLines = (parsed.lines || []).filter((l) => l.index !== 1);
|
const otherLines = (parsed.lines || []).filter((l) => l.index !== 1);
|
||||||
|
if (otherLines.length > 0) {
|
||||||
|
out += '**Extensions**\n';
|
||||||
for (const line of otherLines) {
|
for (const line of otherLines) {
|
||||||
out += ` Ext ${line.index}: ${line.registrationState || '?'}\n`;
|
out += `• Ext ${line.index}: ${line.registrationState || '?'}\n`;
|
||||||
|
}
|
||||||
|
out += '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (parsed.sip?.messagesSent || parsed.sip?.messagesRecv) {
|
||||||
|
const ps = parsed.phoneStatus || {};
|
||||||
|
out += `**SIP counters**\n• ${parsed.sip.messagesSent || 0} sent / ${parsed.sip.messagesRecv || 0} recv`;
|
||||||
|
if (ps.sipBytesSent) out += ` (${ps.sipBytesSent}B / ${ps.sipBytesRecv || 0}B)`;
|
||||||
|
out += '\n\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLineRegistered(line1) && line1 && parsed.phoneStatus?.ledLine1Color) {
|
||||||
|
const ps = parsed.phoneStatus;
|
||||||
|
const led = `${ps.ledLine1Color}${ps.ledLine1Cadence ? ` ${ps.ledLine1Cadence}` : ''}`;
|
||||||
|
out += `**LED**\n• Line 1: ${led}\n\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ps = parsed.phoneStatus || {};
|
const ps = parsed.phoneStatus || {};
|
||||||
if (ps.ipv6Status || ps.ipv6Address) {
|
if (ps.ipv6Status || ps.ipv6Address) {
|
||||||
out += ` IPv6: ${ps.ipv6Status || '?'} ${ps.ipv6Address || ''}\n`.trimEnd() + '\n';
|
out += `**IPv6**\n• ${ps.ipv6Status || '?'} ${ps.ipv6Address || ''}\n\n`;
|
||||||
}
|
}
|
||||||
if (ps.tr069Feature) out += ` TR-069: ${ps.tr069Feature}\n`;
|
if (ps.tr069Feature) out += `**TR-069**\n• ${ps.tr069Feature}\n\n`;
|
||||||
if (ps.multicastRx != null || ps.multicastTx != null) {
|
if (ps.multicastRx != null || ps.multicastTx != null) {
|
||||||
out += ` Paging multicast: rx ${ps.multicastRx || 0} / tx ${ps.multicastTx || 0}\n`;
|
out += `**Paging multicast**\n• rx ${ps.multicastRx || 0} / tx ${ps.multicastTx || 0}\n\n`;
|
||||||
}
|
}
|
||||||
if (ps.streamingRx != null) out += ` XML streaming rx: ${ps.streamingRx}\n`;
|
if (ps.streamingRx != null) out += `**XML streaming**\n• rx ${ps.streamingRx}\n\n`;
|
||||||
|
|
||||||
const counters = neighbor.errorCounters || {};
|
const counters = neighbor.errorCounters || {};
|
||||||
const counterKeys = Object.keys(counters);
|
const counterKeys = Object.keys(counters);
|
||||||
if (counterKeys.length > 0) {
|
if (counterKeys.length > 0) {
|
||||||
out += ' Ethernet errors:\n';
|
out += '**Ethernet errors**\n';
|
||||||
for (const k of counterKeys.slice(0, 8)) {
|
for (const k of counterKeys.slice(0, 8)) {
|
||||||
out += ` ${k}: ${counters[k]}\n`;
|
out += `• ${k}: ${counters[k]}\n`;
|
||||||
}
|
}
|
||||||
|
out += '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
const probeList = Array.isArray(probes) ? probes : [];
|
const probeList = Array.isArray(probes) ? probes : [];
|
||||||
if (probeList.length > 0) {
|
if (probeList.length > 0) {
|
||||||
out += ' Probe paths:\n';
|
out += '**Probe paths**\n```\nSTATUS BYTES PATH\n';
|
||||||
out += ' ```\n';
|
|
||||||
out += ' STATUS BYTES PATH\n';
|
|
||||||
for (const p of probeList) {
|
for (const p of probeList) {
|
||||||
const status = p.status == null ? 'ERR' : String(p.status);
|
const status = p.status == null ? 'ERR' : String(p.status);
|
||||||
out += ` ${status.padEnd(6)} ${String(p.sizeBytes || 0).padEnd(6)} ${p.path}\n`;
|
out += `${status.padEnd(6)} ${String(p.sizeBytes || 0).padEnd(6)} ${p.path}\n`;
|
||||||
}
|
}
|
||||||
out += ' ```\n';
|
out += '```\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
133
services/renderers/mppPhoneFormatters.js
Normal file
133
services/renderers/mppPhoneFormatters.js
Normal file
|
|
@ -0,0 +1,133 @@
|
||||||
|
// services/renderers/mppPhoneFormatters.js
|
||||||
|
//
|
||||||
|
// Display helpers for MPP phone diagnostics follow-up messages.
|
||||||
|
|
||||||
|
import { formatMicCertForDisplay } from '../../integrations/cisco-mpp-phone/downloadStatusJson.js';
|
||||||
|
|
||||||
|
export 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {string|null|undefined} elapsedTime */
|
||||||
|
export function formatUptime(elapsedTime) {
|
||||||
|
if (!elapsedTime || typeof elapsedTime !== 'string') return null;
|
||||||
|
const trimmed = elapsedTime.trim();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const dayHour = trimmed.match(/(\d+)\s+days?\s+and\s+(\d+):(\d+):(\d+)/i);
|
||||||
|
if (dayHour) {
|
||||||
|
const days = Number(dayHour[1]);
|
||||||
|
const hours = Number(dayHour[2]);
|
||||||
|
if (days > 0) return `${days}d ${hours}h`;
|
||||||
|
if (hours > 0) return `${hours}h`;
|
||||||
|
return `${Number(dayHour[3])}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hms = trimmed.match(/^(\d+):(\d+):(\d+)$/);
|
||||||
|
if (hms) {
|
||||||
|
const hours = Number(hms[1]);
|
||||||
|
const mins = Number(hms[2]);
|
||||||
|
if (hours > 0) return `${hours}h ${mins}m`;
|
||||||
|
return `${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return trimmed.length > 24 ? `${trimmed.slice(0, 24)}…` : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {object} phoneStatus @param {object} neighbor */
|
||||||
|
export function formatLinkSpeed(phoneStatus = {}, neighbor = {}) {
|
||||||
|
const fromStatus = phoneStatus.linkSpeed || phoneStatus.linkConfig || null;
|
||||||
|
if (fromStatus && fromStatus !== 'Disabled') return String(fromStatus).trim();
|
||||||
|
const fromLldp = neighbor.portSpeed ? String(neighbor.portSpeed).trim() : null;
|
||||||
|
if (fromLldp) {
|
||||||
|
const compact = fromLldp.replace(/\s+/g, ' ');
|
||||||
|
if (/^\d+F$/i.test(compact)) return compact.replace(/F$/i, 'M Full');
|
||||||
|
return compact;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatHeaderStatusLine(phoneStatus, neighbor) {
|
||||||
|
const uptime = formatUptime(phoneStatus?.elapsedTime);
|
||||||
|
const link = formatLinkSpeed(phoneStatus, neighbor);
|
||||||
|
const parts = [];
|
||||||
|
if (uptime) parts.push(`Uptime ${uptime}`);
|
||||||
|
if (link) parts.push(link);
|
||||||
|
return parts.length ? parts.join(' · ') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLineRegistered(line) {
|
||||||
|
const state = String(line?.registrationState || '').toLowerCase();
|
||||||
|
if (!state) return false;
|
||||||
|
if (state.includes('not registered') || state.includes('fail')) return false;
|
||||||
|
return state.includes('registered');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function headerIconForLine(line) {
|
||||||
|
if (!line?.registrationState) return '⚠️';
|
||||||
|
return isLineRegistered(line) ? '✅' : '❌';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRebootEntry(raw) {
|
||||||
|
if (!raw) return null;
|
||||||
|
const m = String(raw).match(/^(\w+)\(([^)]+)\)/);
|
||||||
|
if (m) return `${m[1]} @ ${m[2]}`;
|
||||||
|
return String(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hostFromUrl(url) {
|
||||||
|
if (!url || typeof url !== 'string') return '?';
|
||||||
|
try {
|
||||||
|
const normalized = url.startsWith('http') ? url : `https://${url}`;
|
||||||
|
return new URL(normalized).hostname;
|
||||||
|
} catch {
|
||||||
|
return url.length > 30 ? `${url.slice(0, 30)}…` : url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatProvisioningResync(entry) {
|
||||||
|
if (!entry) return null;
|
||||||
|
const host = entry.url ? hostFromUrl(entry.url) : '?';
|
||||||
|
const result = entry.succeeded ? 'OK' : (entry.failed ? 'FAILED' : (entry.result || '?'));
|
||||||
|
return `Resync: ${host} · ${result}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatFirmwareUpgrade(entry) {
|
||||||
|
if (!entry) return null;
|
||||||
|
if (entry.succeeded) return 'Firmware upgrade: OK';
|
||||||
|
if (entry.failed) return `Firmware upgrade: ${entry.result || 'FAILED'}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} mpp buildMppProbeView output
|
||||||
|
* @param {object} parsed
|
||||||
|
* @returns {string[]}
|
||||||
|
*/
|
||||||
|
export function collectIssues(mpp, parsed) {
|
||||||
|
const issues = [];
|
||||||
|
const download = mpp?.download;
|
||||||
|
|
||||||
|
const mic = formatMicCertForDisplay(download?.micCert);
|
||||||
|
if (mic) issues.push(mic);
|
||||||
|
|
||||||
|
if (download?.latestProvisioning?.failed) {
|
||||||
|
const host = download.latestProvisioning.url
|
||||||
|
? hostFromUrl(download.latestProvisioning.url)
|
||||||
|
: '?';
|
||||||
|
issues.push(`Provisioning resync failed — ${host}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (download?.latestFirmwareUpgrade?.failed) {
|
||||||
|
issues.push(`Firmware upgrade failed — ${download.latestFirmwareUpgrade.result || 'unknown'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const line1 = parsed?.lines?.find((l) => l.index === 1) || parsed?.lines?.[0];
|
||||||
|
if (line1?.registrationState && !isLineRegistered(line1)) {
|
||||||
|
issues.push(`Ext ${line1.index}: ${line1.registrationState}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues;
|
||||||
|
}
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
// src/services/renderers/phoneStatusRenderer.js
|
// src/services/renderers/phoneStatusRenderer.js
|
||||||
//
|
//
|
||||||
// Extracted from commands/phoneStatus.js so the same output can drive
|
// Extracted from commands/voiceStatus.js so the same output can drive
|
||||||
// BOTH the chat reply (`bot.say('markdown', md)`) AND the Jira poller
|
// BOTH the chat reply (`bot.say('markdown', md)`) AND the Jira poller
|
||||||
// comment (via utils/markdownToAdf). Byte-for-byte identical to what
|
// comment (via utils/markdownToAdf).
|
||||||
// the chat command used to emit for a given input.
|
|
||||||
//
|
//
|
||||||
// Options
|
// Options
|
||||||
// storeNum (required) header string uses it
|
// storeNum (required) header string uses it
|
||||||
|
|
@ -29,20 +28,6 @@ import { simpleTimeAgo, formatBytes, formatDisplayTime } from '../../utils/time.
|
||||||
* @param {string} opts.storeNum 2-6 digit store id (header text)
|
* @param {string} opts.storeNum 2-6 digit store id (header text)
|
||||||
* @param {boolean} [opts.detailed=false]
|
* @param {boolean} [opts.detailed=false]
|
||||||
* @param {boolean} [opts.footer=true]
|
* @param {boolean} [opts.footer=true]
|
||||||
* @param {number} [opts.dectFollowUpBaseCount=0]
|
|
||||||
* When > 0, emits an "⏳ DECT base data loading for N base(s)…"
|
|
||||||
* line inside the DECT Basestations section. Signals to the reader
|
|
||||||
* that a follow-up message with base-station diagnostics is on the
|
|
||||||
* way. Chat handler passes this after the base count comes back
|
|
||||||
* from discoverDectBases(); poller and HTTP callers pass 0.
|
|
||||||
* @param {number} [opts.mppFollowUpPhoneCount=0]
|
|
||||||
* When > 0, emits MPP desk-phone relay diagnostics loading hint.
|
|
||||||
* @param {boolean} [opts.wanFollowUpEnabled=false]
|
|
||||||
* When true, emits a "⏳ WAN metrics loading…" line just above the
|
|
||||||
* footer. Same rationale as `dectFollowUpBaseCount` — chat handler
|
|
||||||
* sets this only when the Prisma SD-WAN site resolves and a
|
|
||||||
* follow-up is genuinely in-flight; HTTP / Jira surfaces pass
|
|
||||||
* false and never see the line.
|
|
||||||
* @returns {string} markdown, whitespace-trimmed and ready to send.
|
* @returns {string} markdown, whitespace-trimmed and ready to send.
|
||||||
*/
|
*/
|
||||||
export function renderPhoneStatusMarkdown(data, opts = {}) {
|
export function renderPhoneStatusMarkdown(data, opts = {}) {
|
||||||
|
|
@ -50,12 +35,9 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
|
||||||
storeNum,
|
storeNum,
|
||||||
detailed = false,
|
detailed = false,
|
||||||
footer = true,
|
footer = true,
|
||||||
dectFollowUpBaseCount = 0,
|
|
||||||
mppFollowUpPhoneCount = 0,
|
|
||||||
wanFollowUpEnabled = false,
|
|
||||||
} = opts;
|
} = opts;
|
||||||
|
|
||||||
let reply = `**Phone Status - Store ${storeNum}**\n\n`;
|
let reply = `**Voice Status - Store ${storeNum}**\n\n`;
|
||||||
|
|
||||||
const phones = data.phones?.data || [];
|
const phones = data.phones?.data || [];
|
||||||
const dectBasestations = data.dectBasestations || [];
|
const dectBasestations = data.dectBasestations || [];
|
||||||
|
|
@ -141,11 +123,6 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
|
||||||
}
|
}
|
||||||
reply += '\n';
|
reply += '\n';
|
||||||
});
|
});
|
||||||
|
|
||||||
if (mppFollowUpPhoneCount > 0) {
|
|
||||||
const n = mppFollowUpPhoneCount;
|
|
||||||
reply += `_⏳ MPP phone diagnostics loading for ${n} desk phone${n === 1 ? '' : 's'} via relay — a follow-up message will arrive shortly._\n\n`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DECT Basestations
|
// DECT Basestations
|
||||||
|
|
@ -216,29 +193,12 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
|
||||||
});
|
});
|
||||||
reply += '\n';
|
reply += '\n';
|
||||||
}
|
}
|
||||||
|
|
||||||
// DECT relay follow-up notice. Only shown when the caller has
|
|
||||||
// told us a follow-up is actually in-flight (chat handler, after
|
|
||||||
// discoverDectBases returned a non-empty list). Silent for HTTP /
|
|
||||||
// Jira surfaces where a follow-up doesn't happen.
|
|
||||||
if (dectFollowUpBaseCount > 0) {
|
|
||||||
const n = dectFollowUpBaseCount;
|
|
||||||
reply += `_⏳ Base-station diagnostics loading for ${n} base${n === 1 ? '' : 's'} — a follow-up message will arrive shortly._\n\n`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (detailed) {
|
if (detailed) {
|
||||||
reply += `_Detailed mode — additional fields above (use without ?detailed=true for compact view)_\n`;
|
reply += `_Detailed mode — additional fields above (use without ?detailed=true for compact view)_\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// WAN follow-up hint — same "silent for HTTP / Jira" rule as the
|
|
||||||
// DECT hint above. Sits at the bottom because WAN metrics are the
|
|
||||||
// last section chronologically (arrives after the DECT follow-up
|
|
||||||
// in most stores).
|
|
||||||
if (wanFollowUpEnabled) {
|
|
||||||
reply += `\n_⏳ WAN metrics loading from Prisma SD-WAN — a follow-up message will arrive shortly._\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (footer) {
|
if (footer) {
|
||||||
reply += `\n*Last checked: ${formatDisplayTime()}*`;
|
reply += `\n*Last checked: ${formatDisplayTime()}*`;
|
||||||
}
|
}
|
||||||
|
|
@ -246,91 +206,3 @@ export function renderPhoneStatusMarkdown(data, opts = {}) {
|
||||||
return reply.trim();
|
return reply.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── DECT base-station diagnostics (follow-up message) ──────────────
|
|
||||||
//
|
|
||||||
// Separate exported renderer for the follow-up message that arrives
|
|
||||||
// ~10-30s after the main /phonestatus output. Input is the array
|
|
||||||
// returned by services/dectCollectorService.collectAll(): per-base
|
|
||||||
// { ok, data (parsed status), verdict, error } records.
|
|
||||||
//
|
|
||||||
// Chat surface stays compact — most operators only need to see the
|
|
||||||
// exceptional stuff (warnings, recent power-loss reboots). Firmware
|
|
||||||
// / emergency numbers / detailed reboot log stay behind the future
|
|
||||||
// /dectstatus command where the full CLI-style dump makes more sense.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {Array} results collectAll() output
|
|
||||||
* @param {object} opts
|
|
||||||
* @param {string} opts.storeNum
|
|
||||||
* @param {boolean} [opts.footer=true]
|
|
||||||
* @returns {string} markdown, whitespace-trimmed. Empty string when
|
|
||||||
* the input list is empty (caller shouldn't send a message
|
|
||||||
* in that case).
|
|
||||||
*/
|
|
||||||
export function renderDectDiagnosticsMarkdown(results, opts = {}) {
|
|
||||||
const { storeNum, footer = true } = opts;
|
|
||||||
const list = Array.isArray(results) ? results : [];
|
|
||||||
if (list.length === 0) return '';
|
|
||||||
|
|
||||||
let out = `**DECT Base Station Diagnostics — Store ${storeNum}**\n\n`;
|
|
||||||
|
|
||||||
for (const r of list) {
|
|
||||||
out += renderOneBase(r);
|
|
||||||
out += '\n';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (footer) {
|
|
||||||
out += `\n*Base diagnostics pulled at ${formatDisplayTime()} via the DECT relay. Use \`/dectstatus ${storeNum}\` for full details and reboot/factory-reset controls.*`;
|
|
||||||
}
|
|
||||||
return out.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderOneBase(r) {
|
|
||||||
const label = r.base?.name || `Basestation ${r.base?.mac || '?'}`;
|
|
||||||
const ip = r.base?.ip || '?';
|
|
||||||
if (!r.ok) {
|
|
||||||
return `⚠️ **${label}** (${ip}) — collect failed: ${r.error?.message || 'unknown error'}` +
|
|
||||||
(r.error?.hint ? `\n _${r.error.hint}_\n` : '\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = r.data || {};
|
|
||||||
const verdict = r.verdict || {};
|
|
||||||
const uptimeText = data.time?.operatingTime || '?';
|
|
||||||
const fw = data.firmware?.version || '?';
|
|
||||||
const conflict = data.conflictInfo && data.conflictInfo !== 'No Conflict'
|
|
||||||
? ` • RF conflict: ${data.conflictInfo}` : '';
|
|
||||||
const role = data.multiCell?.role ? ` • role: ${data.multiCell.role}` : '';
|
|
||||||
|
|
||||||
// Header line uses a checkmark or warning depending on verdict.
|
|
||||||
const icon = verdict.healthy ? '✅' : '⚠️';
|
|
||||||
let out = `${icon} **${label}** (${ip}) — uptime ${uptimeText} • fw ${fw}${role}${conflict}\n`;
|
|
||||||
|
|
||||||
// Most-recent Power Loss reboot (if any in the last-6 log) is the
|
|
||||||
// highest-signal thing we can surface here. Anything else falls
|
|
||||||
// under "warnings" below.
|
|
||||||
const powerLoss = (data.rebootLog || []).find((entry) => entry.reasonCode === 80);
|
|
||||||
if (powerLoss) {
|
|
||||||
out += ` ⚡ Recent power loss: ${powerLoss.at} (reboot #${powerLoss.sequence})\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Warnings from summarizeBaseHealth() are already user-facing
|
|
||||||
// strings; render as a bulleted list under the header.
|
|
||||||
if (Array.isArray(verdict.warnings) && verdict.warnings.length > 0) {
|
|
||||||
for (const w of verdict.warnings) {
|
|
||||||
// Skip the power-loss warning if we already surfaced the
|
|
||||||
// structured line above — avoids duplication.
|
|
||||||
if (powerLoss && /power.?loss/i.test(w)) continue;
|
|
||||||
out += ` ⚠️ ${w}\n`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// RTP: only show if there's an active session — usually the
|
|
||||||
// diagnostic reader cares whether a call is up right now, not
|
|
||||||
// that this base has served 2 total calls since boot.
|
|
||||||
if ((data.rtp?.current || 0) > 0) {
|
|
||||||
out += ` 📞 ${data.rtp.current} active RTP session(s)\n`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// src/services/renderers/wanDiagnosticsRenderer.js
|
// src/services/renderers/wanDiagnosticsRenderer.js
|
||||||
//
|
//
|
||||||
// Pure markdown renderer for the /phonestatus WAN follow-up message.
|
// Pure markdown renderer for the /wanstatus command.
|
||||||
// Takes a `collectSdwanForStore(storeNum)` result and produces the
|
// Takes a `collectSdwanForStore(storeNum)` result and produces the
|
||||||
// section that shows healthscore + per-path LQM + active alarms.
|
// section that shows healthscore + per-path LQM + active alarms.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
105
services/voiceDiag/checks/dectBaseRelay.js
Normal file
105
services/voiceDiag/checks/dectBaseRelay.js
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// services/voiceDiag/checks/dectBaseRelay.js
|
||||||
|
//
|
||||||
|
// Summarises DECT base relay collect results for /voicediag.
|
||||||
|
|
||||||
|
export const DECT_BASE_RELAY_STANDARDS = Object.freeze({
|
||||||
|
healthy: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const dectBaseRelayCheck = {
|
||||||
|
id: 'dectBaseRelay',
|
||||||
|
label: 'DECT Base (relay)',
|
||||||
|
requires: ['phoneStatus'],
|
||||||
|
scope: null,
|
||||||
|
standards: DECT_BASE_RELAY_STANDARDS,
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
if (!process.env.DECT_RELAY_AGENT_TOKEN) {
|
||||||
|
return {
|
||||||
|
status: 'skipped',
|
||||||
|
message: 'Relay not configured (`DECT_RELAY_AGENT_TOKEN` unset) — run `/dectdiag` when relay is available.',
|
||||||
|
details: null,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = ctx.dectRelayResults;
|
||||||
|
if (!Array.isArray(results)) {
|
||||||
|
return {
|
||||||
|
status: 'skipped',
|
||||||
|
message: 'DECT relay collect was not run (no bases discovered on 10.x).',
|
||||||
|
details: null,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return {
|
||||||
|
status: 'skipped',
|
||||||
|
message: 'No DECT bases discovered on 10.x for relay collect.',
|
||||||
|
details: null,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const bases = [];
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
const label = r.base?.name || r.base?.mac || '?';
|
||||||
|
const ip = r.base?.ip || '?';
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
errorCount += 1;
|
||||||
|
bases.push({
|
||||||
|
label,
|
||||||
|
ip,
|
||||||
|
ok: false,
|
||||||
|
error: r.error?.message || 'collect failed',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const verdict = r.verdict || {};
|
||||||
|
const data = r.data || {};
|
||||||
|
const warnings = Array.isArray(verdict.warnings) ? verdict.warnings : [];
|
||||||
|
const powerLoss = (data.rebootLog || []).find((entry) => entry.reasonCode === 80);
|
||||||
|
const unhealthy = !verdict.healthy;
|
||||||
|
|
||||||
|
if (unhealthy) errorCount += 1;
|
||||||
|
|
||||||
|
bases.push({
|
||||||
|
label,
|
||||||
|
ip,
|
||||||
|
ok: true,
|
||||||
|
healthy: verdict.healthy !== false,
|
||||||
|
uptime: data.time?.operatingTime || null,
|
||||||
|
warnings,
|
||||||
|
powerLoss: powerLoss ? { at: powerLoss.at, sequence: powerLoss.sequence } : null,
|
||||||
|
activeRtp: data.rtp?.current || 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
`${results.length} DECT base(s) collected via relay. ` +
|
||||||
|
`${errorCount === 0 ? 'All bases healthy.' : `${errorCount} base(s) need attention.`}`;
|
||||||
|
|
||||||
|
const details = { bases, collected: results.length, attentionCount: errorCount };
|
||||||
|
|
||||||
|
if (errorCount > 0) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message: summary + ' Run `/dectdiag` for full dump and reboot controls.',
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: summary,
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -55,6 +55,8 @@ import { wanAppRtpLossCheck } from './wan/wanAppRtpLoss.js';
|
||||||
import { wanAppRtpJitterCheck } from './wan/wanAppRtpJitter.js';
|
import { wanAppRtpJitterCheck } from './wan/wanAppRtpJitter.js';
|
||||||
import { wanAlarmsCheck } from './wan/wanAlarms.js';
|
import { wanAlarmsCheck } from './wan/wanAlarms.js';
|
||||||
import { wanTunnelsCheck } from './wan/wanTunnels.js';
|
import { wanTunnelsCheck } from './wan/wanTunnels.js';
|
||||||
|
import { mppDeskPhoneRelayCheck } from './mppDeskPhoneRelay.js';
|
||||||
|
import { dectBaseRelayCheck } from './dectBaseRelay.js';
|
||||||
|
|
||||||
// Order: user-facing feature signals first (things an operator can
|
// Order: user-facing feature signals first (things an operator can
|
||||||
// see from the phone UI), then LAN-side port hygiene, then the WAN
|
// see from the phone UI), then LAN-side port hygiene, then the WAN
|
||||||
|
|
@ -92,6 +94,8 @@ export const CHECKS = [
|
||||||
wanAppRtpLossCheck,
|
wanAppRtpLossCheck,
|
||||||
wanAppRtpJitterCheck,
|
wanAppRtpJitterCheck,
|
||||||
wanAlarmsCheck,
|
wanAlarmsCheck,
|
||||||
|
mppDeskPhoneRelayCheck,
|
||||||
|
dectBaseRelayCheck,
|
||||||
phoneOnlineCheck,
|
phoneOnlineCheck,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
105
services/voiceDiag/checks/mppDeskPhoneRelay.js
Normal file
105
services/voiceDiag/checks/mppDeskPhoneRelay.js
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
// services/voiceDiag/checks/mppDeskPhoneRelay.js
|
||||||
|
//
|
||||||
|
// Summarises MPP desk-phone relay probe results for /voicediag.
|
||||||
|
|
||||||
|
import { collectIssues } from '../../renderers/mppPhoneFormatters.js';
|
||||||
|
import { isLineRegistered } from '../../renderers/mppPhoneFormatters.js';
|
||||||
|
|
||||||
|
export const MPP_DESK_PHONE_RELAY_STANDARDS = Object.freeze({
|
||||||
|
registered: true,
|
||||||
|
noIssues: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const mppDeskPhoneRelayCheck = {
|
||||||
|
id: 'mppDeskPhoneRelay',
|
||||||
|
label: 'MPP Desk Phone (relay)',
|
||||||
|
requires: ['phoneStatus'],
|
||||||
|
scope: null,
|
||||||
|
standards: MPP_DESK_PHONE_RELAY_STANDARDS,
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
if (!process.env.DECT_RELAY_AGENT_TOKEN) {
|
||||||
|
return {
|
||||||
|
status: 'skipped',
|
||||||
|
message: 'Relay not configured (`DECT_RELAY_AGENT_TOKEN` unset) — run `/phonediag` when relay is available.',
|
||||||
|
details: null,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = ctx.mppRelayResults;
|
||||||
|
if (!Array.isArray(results)) {
|
||||||
|
return {
|
||||||
|
status: 'skipped',
|
||||||
|
message: 'MPP relay probe was not run (no desk phones discovered on 10.x).',
|
||||||
|
details: null,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (results.length === 0) {
|
||||||
|
return {
|
||||||
|
status: 'skipped',
|
||||||
|
message: 'No MPP desk phones discovered on 10.x for relay probe.',
|
||||||
|
details: null,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const phones = [];
|
||||||
|
let errorCount = 0;
|
||||||
|
|
||||||
|
for (const r of results) {
|
||||||
|
const label = r.phone?.name || r.phone?.product || r.phone?.mac || '?';
|
||||||
|
const ip = r.phone?.ip || '?';
|
||||||
|
|
||||||
|
if (!r.ok) {
|
||||||
|
errorCount += 1;
|
||||||
|
phones.push({
|
||||||
|
label,
|
||||||
|
ip,
|
||||||
|
ok: false,
|
||||||
|
error: r.error?.message || 'probe failed',
|
||||||
|
});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = r.mpp?.parsed || r.parsed || {};
|
||||||
|
const line1 = parsed.lines?.find((l) => l.index === 1) || parsed.lines?.[0];
|
||||||
|
const issues = collectIssues(r.mpp || {}, parsed);
|
||||||
|
const registered = line1 ? isLineRegistered(line1) : null;
|
||||||
|
|
||||||
|
if (!registered || issues.length > 0) errorCount += 1;
|
||||||
|
|
||||||
|
phones.push({
|
||||||
|
label,
|
||||||
|
ip,
|
||||||
|
ok: true,
|
||||||
|
registered: line1?.registrationState || null,
|
||||||
|
issues,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary =
|
||||||
|
`${results.length} MPP phone(s) probed via relay. ` +
|
||||||
|
`${errorCount === 0 ? 'All registered with no issues.' : `${errorCount} phone(s) need attention.`}`;
|
||||||
|
|
||||||
|
const details = { phones, probed: results.length, attentionCount: errorCount };
|
||||||
|
|
||||||
|
if (errorCount > 0) {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
message: summary + ' Run `/phonediag` for full section layout.',
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message: summary,
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -46,7 +46,7 @@ export function maybeSkippedByKillSwitch(check) {
|
||||||
// Defaults match the ITU-T G.114 / RFC 3550 references for voice
|
// Defaults match the ITU-T G.114 / RFC 3550 references for voice
|
||||||
// quality. Any of these can be overridden per-tenant via env; the
|
// quality. Any of these can be overridden per-tenant via env; the
|
||||||
// renderer + the checks read from the same accessors so the icon
|
// renderer + the checks read from the same accessors so the icon
|
||||||
// in the phonestatus follow-up always agrees with the verdict in
|
// in the /wanstatus renderer always agrees with the verdict in
|
||||||
// voicediag.
|
// voicediag.
|
||||||
|
|
||||||
function num(envKey, fallback) {
|
function num(envKey, fallback) {
|
||||||
|
|
|
||||||
|
|
@ -120,6 +120,39 @@ export async function buildContext(storeNum, opts = {}) {
|
||||||
const phoneStatus = phoneStatusRes.status === 'fulfilled' ? phoneStatusRes.value : null;
|
const phoneStatus = phoneStatusRes.status === 'fulfilled' ? phoneStatusRes.value : null;
|
||||||
const sdwanData = sdwanRes.status === 'fulfilled' ? sdwanRes.value : null;
|
const sdwanData = sdwanRes.status === 'fulfilled' ? sdwanRes.value : null;
|
||||||
|
|
||||||
|
let dectRelayResults = null;
|
||||||
|
let mppRelayResults = null;
|
||||||
|
if (phoneStatus && process.env.DECT_RELAY_AGENT_TOKEN) {
|
||||||
|
try {
|
||||||
|
const { discoverDectBases } = await import('../dectDiscovery.js');
|
||||||
|
const { discoverDeskPhones } = await import('../phoneDiscovery.js');
|
||||||
|
const { collectAll } = await import('../dectCollectorService.js');
|
||||||
|
const { probeAll } = await import('../phoneCollectorService.js');
|
||||||
|
|
||||||
|
const { bases } = discoverDectBases(phoneStatus);
|
||||||
|
const { phones } = discoverDeskPhones(phoneStatus);
|
||||||
|
|
||||||
|
const [dectRes, mppRes] = await Promise.allSettled([
|
||||||
|
bases.length > 0 ? collectAll(bases) : Promise.resolve([]),
|
||||||
|
phones.length > 0 ? probeAll(phones) : Promise.resolve([]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
dectRelayResults = dectRes.status === 'fulfilled' ? dectRes.value : [];
|
||||||
|
mppRelayResults = mppRes.status === 'fulfilled' ? mppRes.value : [];
|
||||||
|
|
||||||
|
if (dectRes.status === 'rejected') {
|
||||||
|
logger('voicediag', `DECT relay collect soft-failed: ${dectRes.reason?.message}`, 'warn');
|
||||||
|
}
|
||||||
|
if (mppRes.status === 'rejected') {
|
||||||
|
logger('voicediag', `MPP relay probe soft-failed: ${mppRes.reason?.message}`, 'warn');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger('voicediag', `Relay probe setup failed: ${err.message}`, 'warn');
|
||||||
|
dectRelayResults = [];
|
||||||
|
mppRelayResults = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const personLabel = person?.displayName || email;
|
const personLabel = person?.displayName || email;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -143,6 +176,8 @@ export async function buildContext(storeNum, opts = {}) {
|
||||||
// (or when the Prisma integration hasn't been configured).
|
// (or when the Prisma integration hasn't been configured).
|
||||||
sdwanData,
|
sdwanData,
|
||||||
sdwanSite: sdwanData?.site || null,
|
sdwanSite: sdwanData?.site || null,
|
||||||
|
dectRelayResults,
|
||||||
|
mppRelayResults,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,6 @@ test('parseDownloadStatusJson: extracts provisioning and firmware history', () =
|
||||||
test('downloadStatusWarnings: flags MIC cert failure', () => {
|
test('downloadStatusWarnings: flags MIC cert failure', () => {
|
||||||
const parsed = parseDownloadStatusJson(FIXTURE);
|
const parsed = parseDownloadStatusJson(FIXTURE);
|
||||||
const warnings = downloadStatusWarnings(parsed);
|
const warnings = downloadStatusWarnings(parsed);
|
||||||
assert.ok(warnings.some((w) => /MIC cert/i.test(w)));
|
assert.ok(warnings.includes('mic_cert_failed'));
|
||||||
assert.equal(parsed.micCert.failed, true);
|
assert.equal(parsed.micCert.failed, true);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ test('extractStoreFromSummary parses Store NNNN', () => {
|
||||||
assert.equal(extractStoreFromSummary('no store here'), null);
|
assert.equal(extractStoreFromSummary('no store here'), null);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('comm call quality runs phonestatus and callreport', () => {
|
test('comm call quality runs voicestatus and callreport', () => {
|
||||||
const plan = resolveEnrichmentPlan(
|
const plan = resolveEnrichmentPlan(
|
||||||
{
|
{
|
||||||
summary: 'Store 2477 - garbled voice on inbound calls',
|
summary: 'Store 2477 - garbled voice on inbound calls',
|
||||||
|
|
@ -28,7 +28,7 @@ test('comm call quality runs phonestatus and callreport', () => {
|
||||||
{ kind: 'phone', storeNum: '2477', reason: 'desk phone issue' },
|
{ kind: 'phone', storeNum: '2477', reason: 'desk phone issue' },
|
||||||
);
|
);
|
||||||
assert.equal(plan.skip, false);
|
assert.equal(plan.skip, false);
|
||||||
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus, CHECK_IDS.callreport]);
|
assert.deepEqual(plan.checks, [CHECK_IDS.voicestatus, CHECK_IDS.callreport]);
|
||||||
assert.ok(plan.matchedRules.includes('comm-call-quality'));
|
assert.ok(plan.matchedRules.includes('comm-call-quality'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -45,7 +45,7 @@ test('comm spam runs callreport only', () => {
|
||||||
assert.ok(plan.matchedRules.includes('comm-spam'));
|
assert.ok(plan.matchedRules.includes('comm-spam'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('comm spam with both symptoms runs phonestatus and callreport', () => {
|
test('comm spam with both symptoms runs voicestatus and callreport', () => {
|
||||||
const plan = resolveEnrichmentPlan(
|
const plan = resolveEnrichmentPlan(
|
||||||
{
|
{
|
||||||
summary: 'Store 100 - spam and garbled audio',
|
summary: 'Store 100 - spam and garbled audio',
|
||||||
|
|
@ -53,7 +53,7 @@ test('comm spam with both symptoms runs phonestatus and callreport', () => {
|
||||||
},
|
},
|
||||||
{ kind: 'phone', storeNum: '100', reason: 'x' },
|
{ kind: 'phone', storeNum: '100', reason: 'x' },
|
||||||
);
|
);
|
||||||
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus, CHECK_IDS.callreport]);
|
assert.deepEqual(plan.checks, [CHECK_IDS.voicestatus, CHECK_IDS.callreport]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('mobility spam does not trigger comm spam rule', () => {
|
test('mobility spam does not trigger comm spam rule', () => {
|
||||||
|
|
@ -64,11 +64,11 @@ test('mobility spam does not trigger comm spam rule', () => {
|
||||||
},
|
},
|
||||||
{ kind: 'phone', storeNum: '500', reason: 'phone' },
|
{ kind: 'phone', storeNum: '500', reason: 'phone' },
|
||||||
);
|
);
|
||||||
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus]);
|
assert.deepEqual(plan.checks, [CHECK_IDS.voicestatus]);
|
||||||
assert.deepEqual(plan.matchedRules, ['default-phone']);
|
assert.deepEqual(plan.matchedRules, ['default-phone']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('default phone kind runs phonestatus only', () => {
|
test('default phone kind runs voicestatus only', () => {
|
||||||
const plan = resolveEnrichmentPlan(
|
const plan = resolveEnrichmentPlan(
|
||||||
{
|
{
|
||||||
summary: 'Store 300 - phone not registering',
|
summary: 'Store 300 - phone not registering',
|
||||||
|
|
@ -76,7 +76,7 @@ test('default phone kind runs phonestatus only', () => {
|
||||||
},
|
},
|
||||||
{ kind: 'phone', storeNum: '300', reason: 'registration' },
|
{ kind: 'phone', storeNum: '300', reason: 'registration' },
|
||||||
);
|
);
|
||||||
assert.deepEqual(plan.checks, [CHECK_IDS.phonestatus]);
|
assert.deepEqual(plan.checks, [CHECK_IDS.voicestatus]);
|
||||||
assert.deepEqual(plan.matchedRules, ['default-phone']);
|
assert.deepEqual(plan.matchedRules, ['default-phone']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ test('formatEnrichmentBody merges sections with headings', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
test('CHECK_IDS covers expected checks', () => {
|
test('CHECK_IDS covers expected checks', () => {
|
||||||
assert.equal(CHECK_IDS.phonestatus, 'phonestatus');
|
assert.equal(CHECK_IDS.voicestatus, 'voicestatus');
|
||||||
assert.equal(CHECK_IDS.callreport, 'callreport');
|
assert.equal(CHECK_IDS.callreport, 'callreport');
|
||||||
assert.equal(CHECK_IDS.avstatus, 'avstatus');
|
assert.equal(CHECK_IDS.avstatus, 'avstatus');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,14 @@ import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
import { buildMppProbeView } from '../integrations/cisco-mpp-phone/aggregateProbe.js';
|
import { buildMppProbeView } from '../integrations/cisco-mpp-phone/aggregateProbe.js';
|
||||||
|
import { formatMicCertForDisplay } from '../integrations/cisco-mpp-phone/downloadStatusJson.js';
|
||||||
|
import { parseDownloadStatusJson, downloadStatusWarnings } from '../integrations/cisco-mpp-phone/downloadStatusJson.js';
|
||||||
|
import {
|
||||||
|
formatUptime,
|
||||||
|
formatHeaderStatusLine,
|
||||||
|
headerIconForLine,
|
||||||
|
isLineRegistered,
|
||||||
|
} from '../services/renderers/mppPhoneFormatters.js';
|
||||||
import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.js';
|
import { renderMppPhoneDiagnosticsMarkdown } from '../services/renderers/mppPhoneDiagnosticsRenderer.js';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -34,22 +42,63 @@ function okResult() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
test('renderMppPhoneDiagnosticsMarkdown: tier 1+2 default output', () => {
|
test('formatMicCertForDisplay: short message without URL', () => {
|
||||||
|
const parsed = parseDownloadStatusJson(DOWNLOAD);
|
||||||
|
const line = formatMicCertForDisplay(parsed.micCert);
|
||||||
|
assert.match(line, /MIC cert download failed — .*File not found/i);
|
||||||
|
assert.doesNotMatch(line, /sudirenewal\.cisco\.com/);
|
||||||
|
assert.match(line, /07\/28\/2026/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatUptime: compresses day-hour strings', () => {
|
||||||
|
assert.equal(formatUptime('22 days and 07:20:35'), '22d 7h');
|
||||||
|
assert.equal(formatUptime('2 days and 12:01:51'), '2d 12h');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('headerIconForLine: registered vs not', () => {
|
||||||
|
assert.equal(headerIconForLine({ registrationState: 'Registered' }), '✅');
|
||||||
|
assert.equal(headerIconForLine({ registrationState: 'Not Registered' }), '❌');
|
||||||
|
assert.equal(isLineRegistered({ registrationState: 'Registered' }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderMppPhoneDiagnosticsMarkdown: section layout default tier', () => {
|
||||||
const md = renderMppPhoneDiagnosticsMarkdown([okResult()], { storeNum: '782', footer: false });
|
const md = renderMppPhoneDiagnosticsMarkdown([okResult()], { storeNum: '782', footer: false });
|
||||||
assert.match(md, /\*\*MPP Desk Phone Diagnostics — Store 782\*\*/);
|
assert.match(md, /\*\*MPP Desk Phone Diagnostics — Store 782\*\*/);
|
||||||
|
assert.match(md, /\*\*Registration\*\*/);
|
||||||
|
assert.match(md, /\*\*Switch\*\*/);
|
||||||
|
assert.match(md, /\*\*Provisioning\*\*/);
|
||||||
|
assert.match(md, /\*\*Issues\*\*/);
|
||||||
|
assert.doesNotMatch(md, /\*\*Network\*\*/);
|
||||||
|
assert.match(md, /_Uptime 22d 7h · 100M Full_/);
|
||||||
|
assert.match(md, /✅ \*\*Store 00782 CP-7841\*\*/);
|
||||||
assert.match(md, /Registered/);
|
assert.match(md, /Registered/);
|
||||||
assert.match(md, /SW00782R/);
|
assert.match(md, /SW00782R/);
|
||||||
assert.match(md, /Port 45/);
|
assert.match(md, /Port 45/);
|
||||||
assert.match(md, /cisco\.sipflash\.com/);
|
assert.match(md, /Resync: cisco\.sipflash\.com · OK/);
|
||||||
assert.match(md, /MIC cert/i);
|
assert.match(md, /MIC cert download failed — .*File not found/i);
|
||||||
assert.doesNotMatch(md, /Probe paths:/);
|
assert.doesNotMatch(md, /sudirenewal\.cisco\.com/);
|
||||||
|
assert.doesNotMatch(md, /802\.1X/i);
|
||||||
|
assert.doesNotMatch(md, /Web server/i);
|
||||||
|
assert.doesNotMatch(md, /VLAN/i);
|
||||||
|
assert.doesNotMatch(md, /proxy/i);
|
||||||
|
assert.doesNotMatch(md, /Probe paths/);
|
||||||
|
assert.doesNotMatch(md, /SIP counters/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renderMppPhoneDiagnosticsMarkdown: verbose includes probe table', () => {
|
test('renderMppPhoneDiagnosticsMarkdown: verbose includes probe table and SIP', () => {
|
||||||
const md = renderMppPhoneDiagnosticsMarkdown([okResult()], { storeNum: '782', verbose: true, footer: false });
|
const md = renderMppPhoneDiagnosticsMarkdown([okResult()], { storeNum: '782', verbose: true, footer: false });
|
||||||
assert.match(md, /Probe paths:/);
|
assert.match(md, /\*\*Probe paths\*\*/);
|
||||||
assert.match(md, /\/Status\.json/);
|
assert.match(md, /\/Status\.json/);
|
||||||
|
assert.match(md, /\*\*Extensions\*\*/);
|
||||||
assert.match(md, /Ext 2:/);
|
assert.match(md, /Ext 2:/);
|
||||||
|
assert.match(md, /SIP counters/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderMppPhoneDiagnosticsMarkdown: unregistered shows X header', () => {
|
||||||
|
const r = okResult();
|
||||||
|
r.mpp.parsed.lines[0].registrationState = 'Not Registered';
|
||||||
|
const md = renderMppPhoneDiagnosticsMarkdown([r], { storeNum: '782', footer: false });
|
||||||
|
assert.match(md, /❌ \*\*Store 00782 CP-7841\*\*/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renderMppPhoneDiagnosticsMarkdown: relay failure line', () => {
|
test('renderMppPhoneDiagnosticsMarkdown: relay failure line', () => {
|
||||||
|
|
@ -69,5 +118,13 @@ test('buildMppProbeView: merges MIC warning into verdict', () => {
|
||||||
nsJson: NS,
|
nsJson: NS,
|
||||||
});
|
});
|
||||||
assert.equal(mpp.verdict.healthy, false);
|
assert.equal(mpp.verdict.healthy, false);
|
||||||
assert.ok(mpp.verdict.warnings.some((w) => /MIC cert/i.test(w)));
|
assert.ok(mpp.verdict.warnings.includes('mic_cert_failed'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('formatHeaderStatusLine: uptime and link from phone status', () => {
|
||||||
|
const line = formatHeaderStatusLine(
|
||||||
|
{ elapsedTime: '22 days and 07:20:35', linkSpeed: '100M Full' },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
assert.equal(line, 'Uptime 22d 7h · 100M Full');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,6 @@ import assert from 'node:assert/strict';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
renderPhoneStatusMarkdown,
|
renderPhoneStatusMarkdown,
|
||||||
renderDectDiagnosticsMarkdown,
|
|
||||||
} from '../services/renderers/phoneStatusRenderer.js';
|
} from '../services/renderers/phoneStatusRenderer.js';
|
||||||
import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js';
|
import { renderAvStatusMarkdown } from '../services/renderers/avStatusRenderer.js';
|
||||||
import { renderDectStatusMarkdown } from '../services/renderers/dectStatusRenderer.js';
|
import { renderDectStatusMarkdown } from '../services/renderers/dectStatusRenderer.js';
|
||||||
|
|
@ -24,7 +23,7 @@ const threeHrAgo = () => new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString()
|
||||||
|
|
||||||
test('phone renderer: header and empty-store message', () => {
|
test('phone renderer: header and empty-store message', () => {
|
||||||
const md = renderPhoneStatusMarkdown({}, { storeNum: '782' });
|
const md = renderPhoneStatusMarkdown({}, { storeNum: '782' });
|
||||||
assert.match(md, /^\*\*Phone Status - Store 782\*\*/);
|
assert.match(md, /^\*\*Voice Status - Store 782\*\*/);
|
||||||
assert.match(md, /No phones or DECT basestations found/);
|
assert.match(md, /No phones or DECT basestations found/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -255,173 +254,7 @@ test('av renderer: Atlas AMP with vitals renders temps + fan + amps', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
// DECT follow-up "loading" hint (main /phonestatus output)
|
// Full /dectdiag dump (renderDectStatusMarkdown)
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
test('phone renderer: dectFollowUpBaseCount > 0 emits a loading hint inside the DECT section', () => {
|
|
||||||
const data = {
|
|
||||||
dectBasestations: [
|
|
||||||
{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } },
|
|
||||||
{ mac: 'aa:bb:cc:dd:ee:02', meraki: { status: 'Online' } },
|
|
||||||
],
|
|
||||||
dectHandsets: [],
|
|
||||||
};
|
|
||||||
const md = renderPhoneStatusMarkdown(data, {
|
|
||||||
storeNum: '782', footer: false, dectFollowUpBaseCount: 2,
|
|
||||||
});
|
|
||||||
assert.match(md, /Base-station diagnostics loading for 2 bases/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('phone renderer: dectFollowUpBaseCount === 1 uses singular "base"', () => {
|
|
||||||
const data = {
|
|
||||||
dectBasestations: [{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } }],
|
|
||||||
dectHandsets: [],
|
|
||||||
};
|
|
||||||
const md = renderPhoneStatusMarkdown(data, {
|
|
||||||
storeNum: '782', footer: false, dectFollowUpBaseCount: 1,
|
|
||||||
});
|
|
||||||
assert.match(md, /loading for 1 base —/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('phone renderer: dectFollowUpBaseCount === 0 emits no loading hint (default state)', () => {
|
|
||||||
const data = {
|
|
||||||
dectBasestations: [{ mac: 'aa:bb:cc:dd:ee:01', meraki: { status: 'Online' } }],
|
|
||||||
dectHandsets: [],
|
|
||||||
};
|
|
||||||
const md = renderPhoneStatusMarkdown(data, { storeNum: '782', footer: false });
|
|
||||||
assert.doesNotMatch(md, /diagnostics loading/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('phone renderer: mppFollowUpPhoneCount > 0 emits MPP loading hint', () => {
|
|
||||||
const data = {
|
|
||||||
phones: { data: [{ name: 'Store CP-7841', status: 'connected', lastSeen: new Date().toISOString() }] },
|
|
||||||
dectBasestations: [],
|
|
||||||
dectHandsets: [],
|
|
||||||
};
|
|
||||||
const md = renderPhoneStatusMarkdown(data, {
|
|
||||||
storeNum: '782', footer: false, mppFollowUpPhoneCount: 1,
|
|
||||||
});
|
|
||||||
assert.match(md, /MPP phone diagnostics loading for 1 desk phone/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// DECT follow-up message (renderDectDiagnosticsMarkdown)
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
const okResult = (overrides = {}) => ({
|
|
||||||
base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' },
|
|
||||||
ok: true,
|
|
||||||
data: {
|
|
||||||
time: { operatingTime: '02:15:00 (H:M:S)' },
|
|
||||||
firmware: { version: '05-01-03-0101-09' },
|
|
||||||
multiCell: { role: 'primary' },
|
|
||||||
conflictInfo: 'No Conflict',
|
|
||||||
rebootLog: [],
|
|
||||||
rtp: { current: 0 },
|
|
||||||
},
|
|
||||||
verdict: { healthy: true, warnings: [], info: [] },
|
|
||||||
elapsedMs: 812,
|
|
||||||
...overrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dect diagnostics renderer: empty input returns empty string (caller should not send)', () => {
|
|
||||||
assert.equal(renderDectDiagnosticsMarkdown([], { storeNum: '782' }), '');
|
|
||||||
assert.equal(renderDectDiagnosticsMarkdown(null, { storeNum: '782' }), '');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dect diagnostics renderer: healthy base renders check + uptime + firmware', () => {
|
|
||||||
const md = renderDectDiagnosticsMarkdown([okResult()], { storeNum: '782', footer: false });
|
|
||||||
assert.match(md, /\*\*DECT Base Station Diagnostics — Store 782\*\*/);
|
|
||||||
assert.match(md, /✅ \*\*Basestation A\*\* \(10\.4\.11\.87\)/);
|
|
||||||
assert.match(md, /uptime 02:15:00/);
|
|
||||||
assert.match(md, /fw 05-01-03-0101-09/);
|
|
||||||
assert.match(md, /role: primary/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dect diagnostics renderer: warnings from verdict are surfaced under the header', () => {
|
|
||||||
const md = renderDectDiagnosticsMarkdown([
|
|
||||||
okResult({
|
|
||||||
verdict: {
|
|
||||||
healthy: false,
|
|
||||||
warnings: ['Rx errors: 42 since last boot'],
|
|
||||||
info: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
], { storeNum: '782', footer: false });
|
|
||||||
assert.match(md, /⚠️ \*\*Basestation A\*\*/);
|
|
||||||
assert.match(md, /⚠️ Rx errors: 42 since last boot/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dect diagnostics renderer: recent Power Loss reboot gets its own bolt line + suppresses duplicate warning', () => {
|
|
||||||
const md = renderDectDiagnosticsMarkdown([
|
|
||||||
okResult({
|
|
||||||
data: {
|
|
||||||
time: { operatingTime: '02:15:00' },
|
|
||||||
firmware: { version: '05-01-03-0101-09' },
|
|
||||||
multiCell: { role: 'primary' },
|
|
||||||
conflictInfo: 'No Conflict',
|
|
||||||
rebootLog: [
|
|
||||||
{ sequence: 164, at: '2026-07-02T12:54:12', reasonName: 'Power Loss', reasonCode: 80 },
|
|
||||||
],
|
|
||||||
rtp: { current: 0 },
|
|
||||||
},
|
|
||||||
verdict: {
|
|
||||||
healthy: false,
|
|
||||||
warnings: ['1 recent power-loss reboot(s); most recent at 2026-07-02T12:54:12'],
|
|
||||||
info: [],
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
], { storeNum: '782', footer: false });
|
|
||||||
|
|
||||||
// The structured line survives …
|
|
||||||
assert.match(md, /⚡ Recent power loss: 2026-07-02T12:54:12 \(reboot #164\)/);
|
|
||||||
// … but the summary warning about power-loss is filtered out to
|
|
||||||
// avoid duplication under the same header.
|
|
||||||
assert.doesNotMatch(md, /⚠️ 1 recent power-loss/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dect diagnostics renderer: active RTP session gets a call icon', () => {
|
|
||||||
const md = renderDectDiagnosticsMarkdown([
|
|
||||||
okResult({
|
|
||||||
data: {
|
|
||||||
time: { operatingTime: '02:15:00' },
|
|
||||||
firmware: { version: '05-01-03-0101-09' },
|
|
||||||
multiCell: { role: 'primary' },
|
|
||||||
conflictInfo: 'No Conflict',
|
|
||||||
rebootLog: [],
|
|
||||||
rtp: { current: 2 },
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
], { storeNum: '782', footer: false });
|
|
||||||
assert.match(md, /📞 2 active RTP session/);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dect diagnostics renderer: base with error renders remediation hint', () => {
|
|
||||||
const md = renderDectDiagnosticsMarkdown([
|
|
||||||
{
|
|
||||||
base: { mac: '6c:ab:05:f6:28:19', ip: '10.4.11.87', name: 'Basestation A' },
|
|
||||||
ok: false,
|
|
||||||
data: null,
|
|
||||||
verdict: null,
|
|
||||||
elapsedMs: 15003,
|
|
||||||
error: {
|
|
||||||
code: 'RELAY_RPC_TIMEOUT',
|
|
||||||
message: 'timed out after 15000ms',
|
|
||||||
hint: 'Relay accepted the request but the base did not respond in time.',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
], { storeNum: '782', footer: false });
|
|
||||||
assert.match(md, /⚠️ \*\*Basestation A\*\* \(10\.4\.11\.87\) — collect failed: timed out after 15000ms/);
|
|
||||||
assert.match(md, /Relay accepted the request but the base did not respond in time\./);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('dect diagnostics renderer: footer references /dectstatus command by store', () => {
|
|
||||||
const md = renderDectDiagnosticsMarkdown([okResult()], { storeNum: '782' });
|
|
||||||
assert.match(md, /Use `\/dectstatus 782`/);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
|
||||||
// Full /dectstatus dump (renderDectStatusMarkdown)
|
|
||||||
// ─────────────────────────────────────────────────────────────
|
// ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const fullOkResult = () => ({
|
const fullOkResult = () => ({
|
||||||
|
|
|
||||||
68
tests/voiceDiag.relay.test.js
Normal file
68
tests/voiceDiag.relay.test.js
Normal file
|
|
@ -0,0 +1,68 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { mppDeskPhoneRelayCheck } from '../services/voiceDiag/checks/mppDeskPhoneRelay.js';
|
||||||
|
import { dectBaseRelayCheck } from '../services/voiceDiag/checks/dectBaseRelay.js';
|
||||||
|
|
||||||
|
const origToken = process.env.DECT_RELAY_AGENT_TOKEN;
|
||||||
|
|
||||||
|
test('mppDeskPhoneRelayCheck: skipped when relay token unset', async () => {
|
||||||
|
delete process.env.DECT_RELAY_AGENT_TOKEN;
|
||||||
|
const result = await mppDeskPhoneRelayCheck.run({ phoneStatus: {} });
|
||||||
|
assert.equal(result.status, 'skipped');
|
||||||
|
assert.match(result.message, /Relay not configured/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('mppDeskPhoneRelayCheck: error when phone has registration issues', async () => {
|
||||||
|
process.env.DECT_RELAY_AGENT_TOKEN = 'test-token';
|
||||||
|
const result = await mppDeskPhoneRelayCheck.run({
|
||||||
|
phoneStatus: {},
|
||||||
|
mppRelayResults: [{
|
||||||
|
ok: true,
|
||||||
|
phone: { name: 'Store CP-7841', ip: '10.1.1.1' },
|
||||||
|
mpp: {
|
||||||
|
parsed: {
|
||||||
|
lines: [{ index: 1, registrationState: 'Not Registered' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 'error');
|
||||||
|
assert.match(result.message, /need attention/i);
|
||||||
|
if (origToken === undefined) delete process.env.DECT_RELAY_AGENT_TOKEN;
|
||||||
|
else process.env.DECT_RELAY_AGENT_TOKEN = origToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dectBaseRelayCheck: ok when all bases healthy', async () => {
|
||||||
|
process.env.DECT_RELAY_AGENT_TOKEN = 'test-token';
|
||||||
|
const result = await dectBaseRelayCheck.run({
|
||||||
|
phoneStatus: {},
|
||||||
|
dectRelayResults: [{
|
||||||
|
ok: true,
|
||||||
|
base: { name: 'Base A', ip: '10.2.2.2' },
|
||||||
|
data: { time: { operatingTime: '1d' }, rebootLog: [], rtp: { current: 0 } },
|
||||||
|
verdict: { healthy: true, warnings: [] },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 'ok');
|
||||||
|
assert.match(result.message, /All bases healthy/i);
|
||||||
|
if (origToken === undefined) delete process.env.DECT_RELAY_AGENT_TOKEN;
|
||||||
|
else process.env.DECT_RELAY_AGENT_TOKEN = origToken;
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dectBaseRelayCheck: error when base unhealthy', async () => {
|
||||||
|
process.env.DECT_RELAY_AGENT_TOKEN = 'test-token';
|
||||||
|
const result = await dectBaseRelayCheck.run({
|
||||||
|
phoneStatus: {},
|
||||||
|
dectRelayResults: [{
|
||||||
|
ok: true,
|
||||||
|
base: { name: 'Base A', ip: '10.2.2.2' },
|
||||||
|
data: { time: { operatingTime: '1d' }, rebootLog: [], rtp: { current: 0 } },
|
||||||
|
verdict: { healthy: false, warnings: ['Rx errors'] },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 'error');
|
||||||
|
assert.match(result.message, /need attention/i);
|
||||||
|
if (origToken === undefined) delete process.env.DECT_RELAY_AGENT_TOKEN;
|
||||||
|
else process.env.DECT_RELAY_AGENT_TOKEN = origToken;
|
||||||
|
});
|
||||||
11
tests/wanStatus.test.js
Normal file
11
tests/wanStatus.test.js
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import { parseWindowMinutes } from '../utils/windowArgs.js';
|
||||||
|
|
||||||
|
test('parseWindowMinutes: shared util resolves durations', () => {
|
||||||
|
assert.equal(parseWindowMinutes('24h'), 1440);
|
||||||
|
assert.equal(parseWindowMinutes('7d'), 10080);
|
||||||
|
assert.equal(parseWindowMinutes(null), undefined);
|
||||||
|
assert.equal(parseWindowMinutes('bogus'), null);
|
||||||
|
});
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
// Narrow, hand-rolled markdown → Atlassian Document Format converter.
|
// Narrow, hand-rolled markdown → Atlassian Document Format converter.
|
||||||
//
|
//
|
||||||
// Grammar supported (deliberately minimal, matches what our chat
|
// Grammar supported (deliberately minimal, matches what our chat
|
||||||
// renderers actually emit — see commands/phoneStatus.js and
|
// renderers actually emit — see commands/voiceStatus.js and
|
||||||
// commands/avStatus.js):
|
// commands/avStatus.js):
|
||||||
//
|
//
|
||||||
// **text** → { type:'text', text, marks:[{type:'strong'}] }
|
// **text** → { type:'text', text, marks:[{type:'strong'}] }
|
||||||
|
|
|
||||||
37
utils/windowArgs.js
Normal file
37
utils/windowArgs.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// utils/windowArgs.js
|
||||||
|
//
|
||||||
|
// Shared `--window` parsing for /wanstatus and /voicediag.
|
||||||
|
|
||||||
|
export function pickWindowArg(args) {
|
||||||
|
return pickFlagValue(args, '--window');
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickFlagValue(args, flag) {
|
||||||
|
const prefix = `${flag.toLowerCase()}=`;
|
||||||
|
for (let i = 0; i < args.length; i += 1) {
|
||||||
|
const a = String(args[i] || '').toLowerCase();
|
||||||
|
if (a.startsWith(prefix)) return String(args[i]).slice(prefix.length);
|
||||||
|
if (a === flag.toLowerCase() && i + 1 < args.length) return args[i + 1];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a window shorthand (`15m`, `1h`, `24h`, `1d`, `1440`) into
|
||||||
|
* minutes. Returns null on unrecognised input so the caller can
|
||||||
|
* decide whether to surface a friendly error or fall back to the
|
||||||
|
* env default.
|
||||||
|
*/
|
||||||
|
export function parseWindowMinutes(raw) {
|
||||||
|
if (raw === null || raw === undefined || raw === '') return undefined;
|
||||||
|
const s = String(raw).trim().toLowerCase();
|
||||||
|
if (/^\d+$/.test(s)) return Math.max(1, parseInt(s, 10));
|
||||||
|
const m = s.match(/^(\d+)\s*(m|min|mins|h|hr|hrs|hour|hours|d|day|days)$/);
|
||||||
|
if (!m) return null;
|
||||||
|
const n = parseInt(m[1], 10);
|
||||||
|
const unit = m[2];
|
||||||
|
if (['m', 'min', 'mins'].includes(unit)) return n;
|
||||||
|
if (['h', 'hr', 'hrs', 'hour', 'hours'].includes(unit)) return n * 60;
|
||||||
|
if (['d', 'day', 'days'].includes(unit)) return n * 60 * 24;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue