Introduces a full Palo Alto Prisma SD-WAN integration (dual-mode SASE OAuth 2.0 / legacy CloudGenix auth, pagination, 429 backoff, session priming) that surfaces per-path latency/jitter/loss/MOS, site healthscore, link state, and alarm data for a store. Wired into the /phonestatus WAN follow-up and eight new /voicediag WAN checks graded against ITU-T G.114 / RFC 3550 defaults (env-overridable via WAN_STANDARD_*). Also adds a shape-aware detail renderer for /voicediag (per-link tables with verdict icons instead of a stringified JSON dump) and a --window flag (15m / 1h / 6h / 24h / 1d, env default via WAN_STANDARD_WINDOW_MINUTES) so operators can widen the look-back without redeploying. scripts/prismaProbe.js is bundled as a CLI for schema iteration against a live tenant. Co-authored-by: Cursor <cursoragent@cursor.com>
355 lines
13 KiB
JavaScript
355 lines
13 KiB
JavaScript
// src/services/voiceDiag/voiceDiagService.js
|
|
//
|
|
// Orchestrator for the /voicediag command. Two responsibilities:
|
|
//
|
|
// 1. buildContext(storeNum) — resolves the store's canonical Webex
|
|
// user (currently 1:1 with a store number via the ae<pad5>@ae.com
|
|
// convention) and pre-fetches shared data that most checks want
|
|
// (device list, telephony profile, phone status). Doing this
|
|
// once here means individual check modules don't each re-do the
|
|
// lookup.
|
|
//
|
|
// 2. runVoiceDiag(storeNum, opts) — iterates the check registry,
|
|
// filters by `requires` capabilities present in the context and
|
|
// an optional `only` allow-list, executes each check in parallel
|
|
// via Promise.allSettled so one 500 doesn't take the run down,
|
|
// and normalises any thrown error into a `status: 'skipped'` or
|
|
// `status: 'error'` CheckResult. The returned array is in
|
|
// registry order — the renderer relies on that for stable output.
|
|
//
|
|
// Design principles:
|
|
// - Checks never see raw axios errors — the runner turns 401/403/404
|
|
// into `skipped: scope missing` and everything else into
|
|
// `error: <message>`. Individual checks decide their own OK/WARN
|
|
// when their fetch succeeds.
|
|
// - Context is intentionally minimal. If a future check needs an
|
|
// extra pre-fetch, add it here rather than duplicating the fetch
|
|
// per check.
|
|
// - Never throws — every failure surfaces as a CheckResult so the
|
|
// command handler can render *something* to the user.
|
|
|
|
import { logger } from '../../utils/logger.js';
|
|
import { CHECKS, getCheckById } from './checks/index.js';
|
|
|
|
// The WebexClient singleton + phoneService helpers are imported LAZILY
|
|
// inside buildContext(). Eager top-level imports would run the
|
|
// WebexClient constructor at module load time, which requires
|
|
// WEBEX_CLIENT_ID / WEBEX_CLIENT_SECRET in the environment. That's
|
|
// fine at runtime but breaks unit tests that only exercise
|
|
// runVoiceDiag / buildRemediationRegistry against a stub ctx (see
|
|
// tests/voiceDiag.checks.test.js).
|
|
|
|
/**
|
|
* Build the shared execution context for a /voicediag run. Pre-fetches
|
|
* the person + telephony profile + phone-status snapshot up-front so
|
|
* each check has cheap random access to them. Missing pieces are set
|
|
* to null so `requires` gating in runVoiceDiag can short-circuit.
|
|
*
|
|
* @param {string} storeNum 2-4 digit store id
|
|
* @returns {Promise<{
|
|
* storeNum: string,
|
|
* email: string,
|
|
* personId: string | null,
|
|
* person: object | null,
|
|
* telephonyProfile: object,
|
|
* phoneStatus: object | null,
|
|
* personLabel: string,
|
|
* }>}
|
|
*/
|
|
export async function buildContext(storeNum, opts = {}) {
|
|
const email = `ae${String(storeNum).padStart(5, '0')}@ae.com`;
|
|
const windowMinutes = opts.windowMinutes;
|
|
logger(
|
|
'voicediag',
|
|
`Building context for store ${storeNum} → ${email}` +
|
|
(windowMinutes ? ` (WAN window: ${windowMinutes}m)` : ''),
|
|
'debug',
|
|
);
|
|
|
|
const { default: webex } = await import('../../integrations/webex/WebexClient.js');
|
|
const {
|
|
getPersonIdByEmail,
|
|
getPersonDetails,
|
|
getTelephonyProfile,
|
|
collectPhoneStatus,
|
|
} = await import('../phoneService.js');
|
|
// Prisma SD-WAN enrichment is lazy for the same reason WebexClient
|
|
// is: it reads process.env at import time (auth mode selection)
|
|
// and unit tests should be able to run without provisioning any
|
|
// Prisma creds. Fetch failures are already absorbed inside the
|
|
// composer, so the wrapping try/catch here is a belt-and-braces
|
|
// guard against unexpected import-time surprises only.
|
|
let collectSdwanForStore;
|
|
try {
|
|
({ collectSdwanForStore } = await import('../enrichment/sdwanEnrichment.js'));
|
|
} catch (err) {
|
|
logger('voicediag', `Prisma enrichment unavailable: ${err.message}`, 'warn');
|
|
collectSdwanForStore = null;
|
|
}
|
|
|
|
const personId = await getPersonIdByEmail(email);
|
|
|
|
// If we have no person, skip the expensive phoneStatus fetch — the
|
|
// renderer will show every check as `skipped: no user`. This keeps
|
|
// the "unknown store" case cheap. We still fetch SD-WAN data — a
|
|
// store with no Webex user can absolutely still have a WAN we care
|
|
// about (dark store investigation, e.g. the /findEmptyLocations
|
|
// follow-up).
|
|
const [personRes, telProfRes, phoneStatusRes, sdwanRes] = await Promise.allSettled([
|
|
personId ? getPersonDetails(personId) : Promise.resolve(null),
|
|
personId ? getTelephonyProfile(personId) : Promise.resolve({}),
|
|
personId
|
|
? collectPhoneStatus(storeNum).catch((err) => {
|
|
// collectPhoneStatus can throw noisily on network hiccups.
|
|
// We tolerate this: the phoneOnline check will read
|
|
// ctx.phoneStatus and show 'skipped' if it's null.
|
|
logger('voicediag', `collectPhoneStatus soft-failed: ${err.message}`, 'warn');
|
|
return null;
|
|
})
|
|
: Promise.resolve(null),
|
|
collectSdwanForStore
|
|
? collectSdwanForStore(storeNum, { windowMinutes }).catch((err) => {
|
|
logger('voicediag', `collectSdwanForStore soft-failed: ${err.message}`, 'warn');
|
|
return null;
|
|
})
|
|
: Promise.resolve(null),
|
|
]);
|
|
|
|
const person = personRes.status === 'fulfilled' ? personRes.value : null;
|
|
const telephonyProfile = telProfRes.status === 'fulfilled' ? (telProfRes.value || {}) : {};
|
|
const phoneStatus = phoneStatusRes.status === 'fulfilled' ? phoneStatusRes.value : null;
|
|
const sdwanData = sdwanRes.status === 'fulfilled' ? sdwanRes.value : null;
|
|
|
|
const personLabel = person?.displayName || email;
|
|
|
|
return {
|
|
storeNum: String(storeNum),
|
|
email,
|
|
personId,
|
|
person,
|
|
telephonyProfile,
|
|
phoneStatus,
|
|
personLabel,
|
|
// Bare reference to the WebexClient singleton (lazy-loaded above)
|
|
// so checks can make their own targeted calls
|
|
// (`people/{id}/features/*`) without re-importing from every
|
|
// check module. Keeps mocking easy in tests — a test can pass a
|
|
// stub context with a fake webex; no ctx passes through the real
|
|
// client unless it came from buildContext().
|
|
webex,
|
|
// Prisma SD-WAN data. sdwanData is the full composer output;
|
|
// sdwanSite is the shortcut checks use to gate `requires:
|
|
// 'sdwanSite'`. Both null when this store isn't Prisma-managed
|
|
// (or when the Prisma integration hasn't been configured).
|
|
sdwanData,
|
|
sdwanSite: sdwanData?.site || null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Options for runVoiceDiag.
|
|
*
|
|
* @typedef {Object} RunVoiceDiagOptions
|
|
* @property {string[]} [only]
|
|
* Restrict execution to the given check ids. Unknown ids are logged
|
|
* at 'warn' and skipped silently in the results (no CheckResult for
|
|
* them). Empty array runs everything (same as omitted).
|
|
*/
|
|
|
|
/**
|
|
* Execute all registered checks against the given context. Never
|
|
* throws. Returns one CheckResult per check that actually ran; checks
|
|
* whose `requires` aren't satisfied by the context appear as
|
|
* `skipped: missing <cap>` so the renderer can still show them.
|
|
*
|
|
* @param {ReturnType<typeof buildContext> extends Promise<infer T> ? T : never} ctx
|
|
* @param {RunVoiceDiagOptions} [opts]
|
|
* @returns {Promise<CheckResult[]>}
|
|
*/
|
|
export async function runVoiceDiag(ctx, opts = {}) {
|
|
const onlyIds = Array.isArray(opts.only) && opts.only.length > 0
|
|
? new Set(opts.only.map((s) => String(s).toLowerCase().trim()))
|
|
: null;
|
|
|
|
if (onlyIds) {
|
|
for (const id of onlyIds) {
|
|
if (!getCheckById(id)) {
|
|
logger('voicediag', `--only referenced unknown check id: ${id}`, 'warn');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Both sides lowercased so `--only DND` matches check.id === 'dnd'
|
|
// and `--only callforwarding` matches check.id === 'callForwarding'.
|
|
const toRun = CHECKS.filter((c) => !onlyIds || onlyIds.has(String(c.id).toLowerCase()));
|
|
|
|
const results = await Promise.all(
|
|
toRun.map(async (check) => {
|
|
const missing = missingRequirements(check, ctx);
|
|
if (missing.length > 0) {
|
|
return skipped(
|
|
check,
|
|
`not applicable — missing ${missing.join(', ')} for this store`,
|
|
);
|
|
}
|
|
try {
|
|
const result = await check.run(ctx);
|
|
return normalizeResult(check, result);
|
|
} catch (err) {
|
|
return errorToResult(check, err);
|
|
}
|
|
}),
|
|
);
|
|
|
|
return results;
|
|
}
|
|
|
|
function missingRequirements(check, ctx) {
|
|
const reqs = Array.isArray(check.requires) ? check.requires : [];
|
|
const missing = [];
|
|
for (const req of reqs) {
|
|
if (req === 'personId' && !ctx.personId) missing.push('personId');
|
|
else if (req === 'phoneStatus' && !ctx.phoneStatus) missing.push('phoneStatus');
|
|
else if (req === 'telephonyProfile' && (!ctx.telephonyProfile || Object.keys(ctx.telephonyProfile).length === 0)) {
|
|
missing.push('telephonyProfile');
|
|
}
|
|
// WAN bucket vocabulary. `sdwanSite` gates on "is this a Prisma-
|
|
// managed store at all"; `sdwanData` gates on "did the composer
|
|
// produce anything usable" (i.e. discriminate a network-failure
|
|
// day from a non-Prisma store).
|
|
else if (req === 'sdwanSite' && !ctx.sdwanSite) missing.push('sdwanSite');
|
|
else if (req === 'sdwanData' && !ctx.sdwanData) missing.push('sdwanData');
|
|
}
|
|
return missing;
|
|
}
|
|
|
|
function normalizeResult(check, result) {
|
|
if (!result || typeof result !== 'object') {
|
|
return {
|
|
id: check.id,
|
|
label: check.label,
|
|
status: 'error',
|
|
message: `Check "${check.id}" returned no result`,
|
|
details: null,
|
|
remediation: null,
|
|
};
|
|
}
|
|
return {
|
|
id: check.id,
|
|
label: check.label,
|
|
status: result.status || 'ok',
|
|
message: result.message || '',
|
|
details: result.details ?? null,
|
|
remediation: result.remediation ?? null,
|
|
};
|
|
}
|
|
|
|
function errorToResult(check, err) {
|
|
const status = err?.response?.status;
|
|
const apiMsg =
|
|
err?.response?.data?.message ||
|
|
err?.response?.data?.errors?.[0]?.description ||
|
|
err?.message ||
|
|
String(err);
|
|
|
|
// 401/403 → the service-app token doesn't have the scope. Surface
|
|
// as `skipped: scope missing` and keep the run going against the
|
|
// other checks. The scope is uniformly `spark-admin:people_read`
|
|
// for the `/v1/people/{id}/features/*` surface, so if this fires
|
|
// on ANY check it will fire on all of them — but we still emit
|
|
// one skipped row per check so the operator can see the pattern.
|
|
if (status === 401 || status === 403) {
|
|
return {
|
|
id: check.id,
|
|
label: check.label,
|
|
status: 'skipped',
|
|
message:
|
|
`${status} on ${check.label} — the service app token is missing ` +
|
|
`\`${check.scope || 'spark-admin:people_read'}\`. ` +
|
|
`Re-authorize the app and re-bootstrap ` +
|
|
`\`tokens/webex-service-tokens.json\`.`,
|
|
details: { httpStatus: status },
|
|
remediation: null,
|
|
};
|
|
}
|
|
|
|
// 404 on `/v1/people/{id}/features/*` most commonly means the
|
|
// person isn't a Webex Calling user (no calling license, no
|
|
// location assigned, or a service line) — the same admin URL
|
|
// returns 200 for a properly-provisioned calling user. If the
|
|
// path itself moved (see Webex changelog for scheduled migrations
|
|
// to `/v1/telephony/config/people/{id}/...`), you'll see the
|
|
// "no static resource" message quoted below, which is a routing
|
|
// 404 rather than a "not applicable" 404 — the response body
|
|
// distinguishes them.
|
|
if (status === 404) {
|
|
const routingMiss = /no static resource/i.test(apiMsg);
|
|
return {
|
|
id: check.id,
|
|
label: check.label,
|
|
status: 'skipped',
|
|
message: routingMiss
|
|
? `404 (routing) on ${check.label} — the URL for this feature has moved. ` +
|
|
`Update the ENDPOINT constant in the check module. Details: ${apiMsg}`
|
|
: `404 on ${check.label} — this user has no Webex Calling license or the ` +
|
|
`feature isn't applicable to their line type.`,
|
|
details: { httpStatus: 404, routingMiss, apiMsg },
|
|
remediation: null,
|
|
};
|
|
}
|
|
|
|
logger('voicediag', `Check ${check.id} failed: ${apiMsg}`, 'warn');
|
|
|
|
return {
|
|
id: check.id,
|
|
label: check.label,
|
|
status: 'error',
|
|
message: `Error running ${check.label}: ${apiMsg}${status ? ` (HTTP ${status})` : ''}`,
|
|
details: { httpStatus: status || null },
|
|
remediation: null,
|
|
};
|
|
}
|
|
|
|
function skipped(check, message) {
|
|
return {
|
|
id: check.id,
|
|
label: check.label,
|
|
status: 'skipped',
|
|
message,
|
|
details: null,
|
|
remediation: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build a lookup { remediationId -> handler } across every check.
|
|
* Used by commands/voiceDiag.js's `applyVoiceDiagRemediation` to
|
|
* dispatch a `confirm_voicediag` attachmentAction to the right
|
|
* check's remediation function.
|
|
*
|
|
* @returns {Map<string, {check: object, handler: Function}>}
|
|
*/
|
|
export function buildRemediationRegistry() {
|
|
const map = new Map();
|
|
for (const check of CHECKS) {
|
|
const rems = check.remediations;
|
|
if (!rems || typeof rems !== 'object') continue;
|
|
for (const [actionId, handler] of Object.entries(rems)) {
|
|
if (typeof handler !== 'function') continue;
|
|
if (map.has(actionId)) {
|
|
// Duplicate remediation ids across two checks would silently
|
|
// swallow one. Fail loud so the mistake is obvious in tests.
|
|
throw new Error(
|
|
`Duplicate voicediag remediation id "${actionId}" — check "${check.id}" ` +
|
|
`collides with an already-registered handler`,
|
|
);
|
|
}
|
|
map.set(actionId, { check, handler });
|
|
}
|
|
}
|
|
return map;
|
|
}
|
|
|
|
// Re-export CHECKS so tests / list-checks handlers don't need a
|
|
// second import from ./checks/index.js.
|
|
export { CHECKS };
|