Add per-app DPI voice-quality checks + widen WAN window to 7d
Adds three new SD-WAN checks (wanAppRtpMos/Loss/Jitter) that measure
REAL voice-traffic quality on actual RTP frames via Prisma DPI, not
synthetic link probes. Graded against the WORST 5-minute window so
transient degradation the 24h link-probe averages smooth away
actually surfaces.
Voice-app selection is tenant-configurable via PRISMA_APP_ID_VOICE +
PRISMA_APP_NAME_VOICE (Webex_Calling_RTP recommended for Webex
Calling shops — the Webex-specific DPI signature excludes non-Webex
UDP noise). Legacy PRISMA_APP_ID_RTP_BASE still honored with a
one-time deprecation warning.
Widens the default WAN look-back from 24h to 7 days: per-app metrics
only get datapoints when calls actually happen, so sporadic Webex
Calling stores (3-4 calls/day) need a wider window for worst-window
statistics to be meaningful. Interval picker snaps 7d to 1hour
buckets (168 pts) to keep payloads bounded while preserving
worst-hour granularity. Hard-capped at 7d — beyond that Prisma
downsamples to 1-day buckets and the signal collapses.
Also:
- Client-side concurrency limiter (PRISMA_MAX_INFLIGHT, default 3)
to prevent 429 cascades when /voicediag fans out 10+ parallel
metric fetches
- "View in Prisma UI" deep links in both /phonestatus WAN follow-up
and /voicediag details, threading through a new
integrations/paloalto/urls.js builder
- humanizeMetricUnit maps raw API unit strings ("percentage",
"milliseconds") to display symbols ("%", "ms") to fix
"11.83percentage" leaking to the UI
- getAppAudio envelope distinguishes not-configured / fetch-failed /
no-traffic states so misleading "set env var" messages don't fire
when the real problem is a 429
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
21fa8f1436
commit
9d0dbb071e
26 changed files with 2822 additions and 71 deletions
103
.env.example
103
.env.example
|
|
@ -122,6 +122,23 @@ WEBEX_TOKENS_PATH=./config/webex-service-tokens.json
|
||||||
# PRISMA_PASSWORD=
|
# PRISMA_PASSWORD=
|
||||||
# PRISMA_LEGACY_BASE_URL=https://api.cloudgenix.com
|
# PRISMA_LEGACY_BASE_URL=https://api.cloudgenix.com
|
||||||
|
|
||||||
|
# --- Prisma client-side concurrency cap ---
|
||||||
|
# Maximum number of in-flight Prisma requests at any moment. Prisma
|
||||||
|
# throttles bursts hard — /voicediag fans out 10+ metric calls per
|
||||||
|
# request, and without this cap they'd all fire simultaneously and
|
||||||
|
# 429-cascade. Default 3 is empirically safe on the observed tenant.
|
||||||
|
# Bump if your tenant tolerates more parallelism (fewer 429s), lower
|
||||||
|
# if you still see them.
|
||||||
|
# PRISMA_MAX_INFLIGHT=3
|
||||||
|
|
||||||
|
# --- Prisma Strata Cloud Manager (SCM) UI base URL ---
|
||||||
|
# Used only for building "View in Prisma UI" deep links surfaced in
|
||||||
|
# the /phonestatus WAN follow-up + /voicediag per-app details. The
|
||||||
|
# API endpoints continue to use PRISMA_SASE_BASE_URL. Override this
|
||||||
|
# only for tenants on a partner-branded or region-specific SCM
|
||||||
|
# domain. Default matches the production SCM hostname.
|
||||||
|
# PRISMA_UI_BASE_URL=https://stratacloudmanager.paloaltonetworks.com
|
||||||
|
|
||||||
# --- WAN voice-quality thresholds (used by /voicediag WAN bucket + the
|
# --- WAN voice-quality thresholds (used by /voicediag WAN bucket + the
|
||||||
# /phonestatus WAN follow-up renderer) ---
|
# /phonestatus WAN follow-up renderer) ---
|
||||||
# Defaults follow ITU-T G.114 (latency), G.711 PLC (loss), and RFC 3550
|
# Defaults follow ITU-T G.114 (latency), G.711 PLC (loss), and RFC 3550
|
||||||
|
|
@ -138,6 +155,61 @@ WEBEX_TOKENS_PATH=./config/webex-service-tokens.json
|
||||||
# WAN_STANDARD_HEALTHSCORE_WARN=80
|
# WAN_STANDARD_HEALTHSCORE_WARN=80
|
||||||
# WAN_STANDARD_HEALTHSCORE_ERROR=60
|
# WAN_STANDARD_HEALTHSCORE_ERROR=60
|
||||||
|
|
||||||
|
# --- Per-application voice-quality thresholds ---
|
||||||
|
# These grade the WORST-window value in the 24h series (rather than
|
||||||
|
# the average) because for voice a brief 15-min degradation is a bad
|
||||||
|
# operator experience even if the daily avg looks fine. Defaults are
|
||||||
|
# less strict than the link-probe thresholds above — otherwise a
|
||||||
|
# single 5-min bad window would drown the operator in warnings.
|
||||||
|
# WAN_STANDARD_APP_MOS_WARN=4.0
|
||||||
|
# WAN_STANDARD_APP_MOS_ERROR=3.5
|
||||||
|
# WAN_STANDARD_APP_LOSS_WARN_PCT=5
|
||||||
|
# WAN_STANDARD_APP_LOSS_ERROR_PCT=15
|
||||||
|
# WAN_STANDARD_APP_JITTER_WARN_MS=30
|
||||||
|
# WAN_STANDARD_APP_JITTER_ERROR_MS=50
|
||||||
|
|
||||||
|
# --- Per-application DPI voice-metrics enablement ---
|
||||||
|
# When set, /phonestatus and /voicediag will fetch REAL voice-traffic
|
||||||
|
# quality metrics (MOS, packet loss, jitter, bandwidth) from Prisma's
|
||||||
|
# Application Path Details endpoint for the configured voice app. This
|
||||||
|
# is DPI data on actual RTP frames — the LQM link-probes above are
|
||||||
|
# synthetic probes across the WAN circuits and can look green while
|
||||||
|
# real audio is degrading (Prisma UI shows both; this env exposes the
|
||||||
|
# second one to the bot).
|
||||||
|
#
|
||||||
|
# Which app to point at is a tenant choice:
|
||||||
|
# - Webex_Calling_RTP ← recommended for Webex Calling shops. The
|
||||||
|
# Webex-specific DPI signature excludes noise
|
||||||
|
# from other UDP traffic and gives materially
|
||||||
|
# more accurate per-call quality signal.
|
||||||
|
# - rtp-base ← generic RTP DPI signature. Catches all RTP,
|
||||||
|
# including non-Webex flows; use if you have
|
||||||
|
# mixed voice apps or a tenant that doesn't
|
||||||
|
# break out per-provider signatures.
|
||||||
|
# - MS_Teams_RTP / Zoom_RTP / etc. — for those shops.
|
||||||
|
#
|
||||||
|
# To find the app id for your tenant:
|
||||||
|
# npm run prisma:probe -- appdefs webex # or `rtp`, `teams`, `zoom`
|
||||||
|
# or grab it from the Prisma UI's Application Path Details URL. Leave
|
||||||
|
# blank to disable the extra API calls entirely (checks return skipped
|
||||||
|
# with an explanation).
|
||||||
|
# PRISMA_APP_ID_VOICE=
|
||||||
|
|
||||||
|
# Optional human-friendly display label for the app configured above.
|
||||||
|
# Shown in the "Voice Traffic Quality (…)" section headers and check
|
||||||
|
# messages. Defaults to "voice" if unset. Recommend setting it to the
|
||||||
|
# exact Prisma UI app label so operators can cross-reference:
|
||||||
|
# PRISMA_APP_NAME_VOICE=Webex_Calling_RTP
|
||||||
|
|
||||||
|
# --- BACKWARDS-COMPAT: PRISMA_APP_ID_RTP_BASE (deprecated) ---
|
||||||
|
# Original name from the initial rtp-base-only release. Still honored
|
||||||
|
# if set AND PRISMA_APP_ID_VOICE is unset, but logs a one-time
|
||||||
|
# deprecation warning on first use. When falling back to this var, the
|
||||||
|
# display name defaults to "rtp-base" so existing dashboards keep
|
||||||
|
# reading the same label. Rename to PRISMA_APP_ID_VOICE at your
|
||||||
|
# convenience — the old var will be removed in a future release.
|
||||||
|
# PRISMA_APP_ID_RTP_BASE=
|
||||||
|
|
||||||
# --- WAN bucket global kill-switch (mirror of VOICE_STANDARD_ENABLED for
|
# --- WAN bucket global kill-switch (mirror of VOICE_STANDARD_ENABLED for
|
||||||
# the port bucket). Set to `false` to silence the entire WAN check
|
# the port bucket). Set to `false` to silence the entire WAN check
|
||||||
# bucket while a Prisma cleanup is in progress. The /phonestatus
|
# bucket while a Prisma cleanup is in progress. The /phonestatus
|
||||||
|
|
@ -147,12 +219,31 @@ WEBEX_TOKENS_PATH=./config/webex-service-tokens.json
|
||||||
|
|
||||||
# --- WAN look-back window (minutes) used when the operator doesn't
|
# --- WAN look-back window (minutes) used when the operator doesn't
|
||||||
# pass `--window` on /voicediag (and always used by /phonestatus).
|
# pass `--window` on /voicediag (and always used by /phonestatus).
|
||||||
# Applies to healthscore + LQM. Alarms window is
|
# Applies to healthscore + LQM + per-app voice DPI + alarms.
|
||||||
# max(60, WAN_STANDARD_WINDOW_MINUTES) since sub-hour alarm queries
|
# Alarms window is max(60, WAN_STANDARD_WINDOW_MINUTES) since
|
||||||
# are usually too noisy to be actionable. Prisma's finest LQM
|
# sub-hour alarm queries are usually too noisy to be actionable.
|
||||||
# bucket is 5m; anything <= 5 is snapped up.
|
#
|
||||||
# Common values: 15 (real-time), 60, 360 (6h), 1440 (24h — default).
|
# Default: 10080 (7 days). Widened from 24h because per-app DPI
|
||||||
# WAN_STANDARD_WINDOW_MINUTES=1440
|
# metrics (Webex_Calling_RTP etc.) only generate datapoints WHEN
|
||||||
|
# CALLS HAPPEN — a store that takes 3-4 Webex calls per day gives
|
||||||
|
# only ~30 samples in 24h, well below the point where "worst-
|
||||||
|
# window" statistics mean anything. Seven days consistently
|
||||||
|
# yields 150-300+ per-app samples across stores of any size.
|
||||||
|
#
|
||||||
|
# For live-incident triage where you want a fresh snapshot, pass
|
||||||
|
# `--window 1h` or `--window 24h` on /voicediag (or lower this
|
||||||
|
# global default here).
|
||||||
|
#
|
||||||
|
# Interval selection is automatic per window size — see
|
||||||
|
# integrations/paloalto/metrics.js:pickAppMetricInterval /
|
||||||
|
# pickInterval. Hard-capped at 10080 (7d); beyond that Prisma
|
||||||
|
# downsamples to 1-day buckets and the worst-window signal
|
||||||
|
# dissolves. If you truly need 30d, edit MAX_WINDOW_MINUTES in
|
||||||
|
# services/enrichment/sdwanEnrichment.js and confirm the interval
|
||||||
|
# pickers still yield useful granularity for the check semantics.
|
||||||
|
#
|
||||||
|
# Common values: 60 (1h — live triage), 1440 (24h), 10080 (7d — default).
|
||||||
|
# WAN_STANDARD_WINDOW_MINUTES=10080
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# /voicediag — Store voice-line standards
|
# /voicediag — Store voice-line standards
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ 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 24h WAN follow-up)',
|
phonestatus: 'DECT + IP phone status for a store (plus 7d WAN follow-up)',
|
||||||
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
|
voicediag: 'Deep voice diagnostic: Webex Calling features + SD-WAN quality with fix cards',
|
||||||
|
|
||||||
// Jira
|
// Jira
|
||||||
|
|
@ -57,7 +57,8 @@ const LONG_HELP = {
|
||||||
notes: [
|
notes: [
|
||||||
'Shows DECT basestations + IP phones with Meraki links.',
|
'Shows DECT basestations + IP phones with Meraki links.',
|
||||||
'Detailed mode adds firmware, serial, SIP details and errors.',
|
'Detailed mode adds firmware, serial, SIP details and errors.',
|
||||||
'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 24 hours (override via `WAN_STANDARD_WINDOW_MINUTES`).',
|
'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).',
|
||||||
|
'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 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`.',
|
||||||
],
|
],
|
||||||
|
|
@ -74,18 +75,20 @@ const LONG_HELP = {
|
||||||
examples: [
|
examples: [
|
||||||
'/voicediag 782',
|
'/voicediag 782',
|
||||||
'/voicediag 782 detail',
|
'/voicediag 782 detail',
|
||||||
'/voicediag 782 --window 15m',
|
'/voicediag 782 --window 24h',
|
||||||
'/voicediag 782 --window 6h --only wanLatency,wanJitter,wanLoss,wanMos',
|
'/voicediag 782 --window 15m --only wanLatency,wanJitter,wanLoss,wanMos',
|
||||||
|
'/voicediag 782 --only wanAppRtpMos,wanAppRtpLoss,wanAppRtpJitter',
|
||||||
'/voicediag 782 --only dnd,callForwarding,voicemail',
|
'/voicediag 782 --only dnd,callForwarding,voicemail',
|
||||||
'/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 eight SD-WAN checks (site, healthscore, link state, latency, jitter, loss, MOS, alarms) against the store\'s Prisma tenant.',
|
'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.',
|
||||||
|
'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.',
|
||||||
'`--only` restricts the run to specific check ids (comma-separated). Use `/voicediag list-checks` to see every registered id + its scope.',
|
'`--only` restricts the run to specific check ids (comma-separated). Use `/voicediag list-checks` to see every registered id + its scope.',
|
||||||
'`--window` overrides the WAN look-back window (accepts `15m`, `1h`, `6h`, `24h`, `1d`, or a bare minute count). Applies to healthscore + LQM (latency/jitter/loss/MOS) fetches. Alarms are floored at 60m. Default is 24h — set `WAN_STANDARD_WINDOW_MINUTES` for a different global default.',
|
'`--window` overrides the WAN look-back window (accepts `15m`, `1h`, `6h`, `24h`, `1d`, `7d`, or a bare minute count; hard-capped at 7d). Applies to healthscore + LQM (latency/jitter/loss/MOS) + per-app voice fetches + alarms. Alarms are floored at 60m. Default is 7d — pass `--window 24h` for a tighter view during live-incident triage, or set `WAN_STANDARD_WINDOW_MINUTES` for a different global default.',
|
||||||
'Voice-quality thresholds default to ITU-T G.114 / RFC 3550. Override any of them via `WAN_STANDARD_*` env vars (see `.env.example`). Kill-switch: `WAN_STANDARD_ENABLED=false` silences the whole WAN bucket.',
|
'Voice-quality thresholds default to ITU-T G.114 / RFC 3550. Override any of them via `WAN_STANDARD_*` env vars (see `.env.example`). Per-app thresholds live under `WAN_STANDARD_APP_*`. Kill-switch: `WAN_STANDARD_ENABLED=false` silences the whole WAN bucket.',
|
||||||
'HTTP equivalent: `?storeNum=<n>[&detailed=true][&only=id1,id2][&window=15m]`. HTTP callers see the markdown snapshot only — cards are chat-only.',
|
'HTTP equivalent: `?storeNum=<n>[&detailed=true][&only=id1,id2][&window=15m]`. HTTP callers see the markdown snapshot only — cards are chat-only.',
|
||||||
'Audit log: every remediation apply/cancel is logged under `voicediag:audit` with the requester identity.',
|
'Audit log: every remediation apply/cancel is logged under `voicediag:audit` with the requester identity.',
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -76,14 +76,17 @@ export async function handleVoiceDiag(bot, trigger) {
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
// Look-back window for WAN checks (healthscore + LQM + alarms).
|
// Look-back window for WAN checks (healthscore + LQM + alarms).
|
||||||
// Accepts `15m`, `1h`, `24h`, `1d` etc. Defaults to whatever
|
// Accepts `15m`, `1h`, `24h`, `1d`, `7d` etc. Defaults to whatever
|
||||||
// `buildContext` resolves via env WAN_STANDARD_WINDOW_MINUTES.
|
// `buildContext` resolves via env WAN_STANDARD_WINDOW_MINUTES
|
||||||
|
// (currently 7d — anything past that gets silently clamped by
|
||||||
|
// normalizeWindowMinutes since Prisma downsamples too coarsely
|
||||||
|
// beyond that to preserve worst-window signal).
|
||||||
const windowRaw = pickWindowArg(args) ?? query.window ?? null;
|
const windowRaw = pickWindowArg(args) ?? query.window ?? null;
|
||||||
const windowMinutes = parseWindowMinutes(windowRaw);
|
const windowMinutes = parseWindowMinutes(windowRaw);
|
||||||
if (windowRaw && windowMinutes == null) {
|
if (windowRaw && windowMinutes == null) {
|
||||||
await bot.say(
|
await bot.say(
|
||||||
'markdown',
|
'markdown',
|
||||||
`Unrecognised window \`${windowRaw}\`. Use \`15m\`, \`1h\`, \`6h\`, \`24h\`, or \`1d\`.`,
|
`Unrecognised window \`${windowRaw}\`. Use \`15m\`, \`1h\`, \`6h\`, \`24h\`, \`1d\`, or \`7d\`.`,
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,56 @@ export const paloAltoAxios = axios.create({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Global concurrency limiter ─────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Prisma throttles bursts hard — fan-out patterns like /voicediag
|
||||||
|
// (which fires 10+ parallel metric calls per request) can trip the
|
||||||
|
// tenant rate limit and cause a cascade where retries compete with
|
||||||
|
// still-pending original calls, wasting the whole batch. Cap the
|
||||||
|
// in-flight count with a small semaphore so bursts are naturally
|
||||||
|
// serialized instead. Empirically 3 works on the observed tenant —
|
||||||
|
// bump `PRISMA_MAX_INFLIGHT` in the env if the ceiling is looser.
|
||||||
|
//
|
||||||
|
// The permit is acquired in the request interceptor and released in
|
||||||
|
// BOTH response paths (success + error) so retries don't hold the
|
||||||
|
// permit through the backoff window (that would let 429'd requests
|
||||||
|
// stall queued fresh requests). 429 retries reacquire on their way
|
||||||
|
// back through the request interceptor.
|
||||||
|
const MAX_INFLIGHT = Math.max(1, Number(process.env.PRISMA_MAX_INFLIGHT) || 3);
|
||||||
|
let inflightCount = 0;
|
||||||
|
const inflightWaiters = [];
|
||||||
|
|
||||||
|
function acquirePermit() {
|
||||||
|
if (inflightCount < MAX_INFLIGHT) {
|
||||||
|
inflightCount++;
|
||||||
|
return Promise.resolve();
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => inflightWaiters.push(resolve));
|
||||||
|
}
|
||||||
|
|
||||||
|
function releasePermit() {
|
||||||
|
const next = inflightWaiters.shift();
|
||||||
|
if (next) {
|
||||||
|
// Hand permit directly to the next waiter — don't decrement +
|
||||||
|
// increment (that's a race window in case a new caller arrives
|
||||||
|
// between the two ops).
|
||||||
|
next();
|
||||||
|
} else {
|
||||||
|
inflightCount = Math.max(0, inflightCount - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Test-only: reset the semaphore between tests. */
|
||||||
|
export function _resetPrismaConcurrency() {
|
||||||
|
inflightCount = 0;
|
||||||
|
inflightWaiters.length = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Test-only: peek at the current in-flight count. */
|
||||||
|
export function _prismaInflightCount() {
|
||||||
|
return inflightCount;
|
||||||
|
}
|
||||||
|
|
||||||
// Request interceptor: (a) apply resolved baseURL on the fly if the
|
// Request interceptor: (a) apply resolved baseURL on the fly if the
|
||||||
// caller passed a relative URL; (b) inject the correct auth header
|
// caller passed a relative URL; (b) inject the correct auth header
|
||||||
// for the current auth mode; (c) prime the SASE unified SD-WAN
|
// for the current auth mode; (c) prime the SASE unified SD-WAN
|
||||||
|
|
@ -128,6 +178,21 @@ paloAltoAxios.interceptors.request.use(async (cfg) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Concurrency-cap acquire. This is the last step of the request
|
||||||
|
// interceptor so all pre-request work (baseURL, auth, session
|
||||||
|
// priming) happens WITHOUT holding a permit — otherwise a slow
|
||||||
|
// priming call would eat one of the three inflight slots for its
|
||||||
|
// whole duration and shrink the effective ceiling.
|
||||||
|
//
|
||||||
|
// `_permitAcquired` is a per-request flag consumed by the response
|
||||||
|
// interceptor. Priming (a nested paloAltoAxios call) is
|
||||||
|
// intentionally exempt via `_skipConcurrencyGate` so we don't
|
||||||
|
// deadlock the last permit waiting for its own dependency.
|
||||||
|
if (!cfg._skipConcurrencyGate) {
|
||||||
|
await acquirePermit();
|
||||||
|
cfg._permitAcquired = true;
|
||||||
|
}
|
||||||
|
|
||||||
logger('paloalto:request', `${cfg.method?.toUpperCase()} ${cfg.baseURL || ''}${cfg.url}`, 'debug');
|
logger('paloalto:request', `${cfg.method?.toUpperCase()} ${cfg.baseURL || ''}${cfg.url}`, 'debug');
|
||||||
return cfg;
|
return cfg;
|
||||||
});
|
});
|
||||||
|
|
@ -142,10 +207,27 @@ const MAX_429_RETRIES = 2;
|
||||||
const BASE_429_BACKOFF_MS = 1500;
|
const BASE_429_BACKOFF_MS = 1500;
|
||||||
|
|
||||||
paloAltoAxios.interceptors.response.use(
|
paloAltoAxios.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => {
|
||||||
|
// Success path: release the concurrency permit acquired in the
|
||||||
|
// request interceptor before returning to the caller.
|
||||||
|
if (response.config?._permitAcquired) {
|
||||||
|
response.config._permitAcquired = false;
|
||||||
|
releasePermit();
|
||||||
|
}
|
||||||
|
return response;
|
||||||
|
},
|
||||||
async (error) => {
|
async (error) => {
|
||||||
const status = error.response?.status;
|
const status = error.response?.status;
|
||||||
|
|
||||||
|
// Release the concurrency permit BEFORE any retry (401/429). The
|
||||||
|
// retry re-enters the request interceptor and will reacquire —
|
||||||
|
// holding the permit across the backoff would let a single
|
||||||
|
// throttled request block fresh callers.
|
||||||
|
if (error.config?._permitAcquired) {
|
||||||
|
error.config._permitAcquired = false;
|
||||||
|
releasePermit();
|
||||||
|
}
|
||||||
|
|
||||||
if (status === 401 && !error.config?._retry) {
|
if (status === 401 && !error.config?._retry) {
|
||||||
logger('paloalto:client', '401 → forcing token refresh + one-shot retry', 'warn');
|
logger('paloalto:client', '401 → forcing token refresh + one-shot retry', 'warn');
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -21,9 +21,16 @@ export {
|
||||||
|
|
||||||
export {
|
export {
|
||||||
LQM_METRIC_NAMES,
|
LQM_METRIC_NAMES,
|
||||||
|
APP_METRIC_NAMES,
|
||||||
getHealthscore,
|
getHealthscore,
|
||||||
getLqmMetric,
|
getLqmMetric,
|
||||||
|
getAppMetric,
|
||||||
getAlarms,
|
getAlarms,
|
||||||
} from './metrics.js';
|
} from './metrics.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
getPrismaUiBaseUrl,
|
||||||
|
buildAppDetailsUrl,
|
||||||
|
} from './urls.js';
|
||||||
|
|
||||||
export { default } from './client.js';
|
export { default } from './client.js';
|
||||||
|
|
|
||||||
|
|
@ -145,6 +145,11 @@ function nowIso() {
|
||||||
// Prisma's valid `interval` enum (verified via HTTP 400 error message).
|
// Prisma's valid `interval` enum (verified via HTTP 400 error message).
|
||||||
// `15min`, `30min` etc. are NOT accepted — snap up to the next valid
|
// `15min`, `30min` etc. are NOT accepted — snap up to the next valid
|
||||||
// bucket (1hour) rather than silently down-sampling.
|
// bucket (1hour) rather than silently down-sampling.
|
||||||
|
//
|
||||||
|
// Used for LQM and healthscore. Those endpoints return a SINGLE
|
||||||
|
// aggregated value per path/site (regardless of interval), so the
|
||||||
|
// interval only affects Prisma's internal downsampling — coarser is
|
||||||
|
// fine, and 1day for a 24h window is the cheapest ask.
|
||||||
function pickInterval(minutes) {
|
function pickInterval(minutes) {
|
||||||
if (minutes <= 1) return '1min';
|
if (minutes <= 1) return '1min';
|
||||||
if (minutes <= 5) return '5min';
|
if (minutes <= 5) return '5min';
|
||||||
|
|
@ -153,6 +158,27 @@ function pickInterval(minutes) {
|
||||||
return '1day';
|
return '1day';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// App-metric variant. UNLIKE LQM, per-app metrics return the FULL
|
||||||
|
// datapoint time series (metrics[].series[].data[].datapoints[]) and
|
||||||
|
// downstream code aggregates {avg, min, max, p95} client-side. That
|
||||||
|
// only works if the interval is fine enough to capture the transient
|
||||||
|
// windows we're trying to surface. HAR (2026-07-09) confirms the
|
||||||
|
// Prisma UI queries `5min` for a 24h window (yielding 288 datapoints
|
||||||
|
// per metric) — matching that keeps our worst-window statistics
|
||||||
|
// meaningful. Falling back to `1day` (as `pickInterval` does) would
|
||||||
|
// collapse the whole series into 1-2 aggregated points and hide the
|
||||||
|
// exact degradation these checks exist to catch.
|
||||||
|
function pickAppMetricInterval(minutes) {
|
||||||
|
if (minutes <= 1) return '1min';
|
||||||
|
if (minutes <= 5) return '5min';
|
||||||
|
if (minutes <= 60) return '5min'; // 12 points per hour
|
||||||
|
if (minutes <= 1440) return '5min'; // 24h → 288 points (HAR-confirmed)
|
||||||
|
// Beyond 24h, cap the point count with hourly buckets: 7d @ 1h = 168
|
||||||
|
// points which is still a manageable payload.
|
||||||
|
if (minutes <= 10080) return '1hour';
|
||||||
|
return '1day';
|
||||||
|
}
|
||||||
|
|
||||||
// Small helper that logs a failed metric call with a response-body
|
// Small helper that logs a failed metric call with a response-body
|
||||||
// preview AND (on 4xx) the request body that Prisma rejected — the
|
// preview AND (on 4xx) the request body that Prisma rejected — the
|
||||||
// most common failure mode is a metric name or filter key drifting
|
// most common failure mode is a metric name or filter key drifting
|
||||||
|
|
@ -291,6 +317,146 @@ export async function getLqmMetric(siteId, waninterfaceIds, metricKey, windowMin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
// Per-application "Application Path Details" metrics (v2.6 API)
|
||||||
|
// ──────────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// These are what Prisma's Application Path Details dashboard renders:
|
||||||
|
// real audio/video traffic quality per app (rtp-base,
|
||||||
|
// Webex_Calling_RTP, MS_Teams_RTP, etc.) — measured on ACTUAL user
|
||||||
|
// packets via DPI, not on synthetic LQM probes across the underlying
|
||||||
|
// link. For voice diagnostics this is
|
||||||
|
// the more meaningful signal — link probes can pass while real RTP
|
||||||
|
// experiences packet loss spikes and MOS dips during flap events
|
||||||
|
// that get averaged out of the link probe view.
|
||||||
|
//
|
||||||
|
// Registry entries were confirmed via HAR captures 2026-07-09 against
|
||||||
|
// a live tenant (site CG00127) for both `rtp-base` (id 15932000365560116)
|
||||||
|
// and `Webex_Calling_RTP` (id 1708539371717015196). The metric names,
|
||||||
|
// units, and filter shapes are identical across voice apps — the only
|
||||||
|
// per-app knob is filter.app, so the check code is app-agnostic. The
|
||||||
|
// winning shapes are shown per-metric in the table below.
|
||||||
|
//
|
||||||
|
// Key filter-shape quirks:
|
||||||
|
// - `filter.direction` is REQUIRED as `"Ingress"` for audio-quality
|
||||||
|
// metrics (loss/jitter/MOS are what you RECEIVE). Bandwidth is
|
||||||
|
// bidirectional aggregate and does NOT take a direction.
|
||||||
|
// - `filter.path_type` is REQUIRED for AppPerf* metrics (loss,
|
||||||
|
// jitter, bandwidth) but NOT for AppAudioMos.
|
||||||
|
// - `filter.app` uses the numeric app id (resolvable via
|
||||||
|
// `POST /sdwan/v2.5/api/appdefs/query`), NOT the display name.
|
||||||
|
//
|
||||||
|
// Response shape is IDENTICAL to v2.6 healthscore
|
||||||
|
// (metrics[].series[].data[].datapoints[{time, value}]) so downstream
|
||||||
|
// parsing can reuse the same helper.
|
||||||
|
|
||||||
|
const ALL_PATH_TYPES = Object.freeze(
|
||||||
|
['DirectInternet', 'VPN', 'PrivateWAN', 'PrivateVPN', 'ServiceLink'],
|
||||||
|
);
|
||||||
|
|
||||||
|
// eslint-disable-next-line no-restricted-syntax -- frozen shared registry.
|
||||||
|
export const APP_METRIC_NAMES = Object.freeze({
|
||||||
|
mos: {
|
||||||
|
name: 'AppAudioMos',
|
||||||
|
unit: 'count',
|
||||||
|
direction: 'Ingress',
|
||||||
|
includePathType: false,
|
||||||
|
// Lower is worse for MOS: 5=excellent, 4=good, 3.5=acceptable,
|
||||||
|
// <3=impaired, <2=unintelligible. Grade against worst-window
|
||||||
|
// rather than average — a 24h avg smooths over the flap events
|
||||||
|
// that actually degrade calls.
|
||||||
|
lowIsBad: true,
|
||||||
|
},
|
||||||
|
loss: {
|
||||||
|
name: 'AppPerfUDPAudioPacketLoss',
|
||||||
|
unit: 'percentage',
|
||||||
|
direction: 'Ingress',
|
||||||
|
includePathType: true,
|
||||||
|
lowIsBad: false,
|
||||||
|
},
|
||||||
|
jitter: {
|
||||||
|
name: 'AppPerfUDPAudioJitter',
|
||||||
|
unit: 'milliseconds',
|
||||||
|
direction: 'Ingress',
|
||||||
|
includePathType: true,
|
||||||
|
lowIsBad: false,
|
||||||
|
},
|
||||||
|
bandwidth: {
|
||||||
|
name: 'AppPerfUDPAudioBandwidth',
|
||||||
|
unit: 'Mbps',
|
||||||
|
direction: null,
|
||||||
|
includePathType: true,
|
||||||
|
lowIsBad: null, // bandwidth isn't a quality metric — used for context only
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// v2.6 monitor/metrics path (SASE unified prefix vs. legacy).
|
||||||
|
function appMetricsPath() {
|
||||||
|
return isSase()
|
||||||
|
? '/sdwan/monitor/v2.6/api/monitor/metrics'
|
||||||
|
: '/v2.6/api/monitor/metrics';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch a single per-app metric time series for a site.
|
||||||
|
*
|
||||||
|
* Returns the raw Prisma response body (with metrics[].series[].data
|
||||||
|
* [].datapoints[] intact) or null on failure. The datapoints array is
|
||||||
|
* usually 24h × 5min = 288 points; the caller is responsible for
|
||||||
|
* client-side aggregation (avg / min / max / p95) because for voice
|
||||||
|
* quality the WORST window matters more than the average.
|
||||||
|
*
|
||||||
|
* @param {string} siteId
|
||||||
|
* @param {string} appId Prisma numeric app id (NOT display name)
|
||||||
|
* @param {'mos'|'loss'|'jitter'|'bandwidth'} metricKey
|
||||||
|
* @param {number} windowMinutes default 1440 (24h) — matches the
|
||||||
|
* shipping default for WAN checks
|
||||||
|
* @returns {Promise<object|null>}
|
||||||
|
*/
|
||||||
|
export async function getAppMetric(siteId, appId, metricKey, windowMinutes = 1440) {
|
||||||
|
const spec = APP_METRIC_NAMES[metricKey];
|
||||||
|
if (!spec) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown app metric key "${metricKey}" ` +
|
||||||
|
`(expected: ${Object.keys(APP_METRIC_NAMES).join(', ')})`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!siteId || !appId) {
|
||||||
|
logger('paloalto:metrics',
|
||||||
|
`getAppMetric(${metricKey}): missing siteId or appId — skipping`, 'debug');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filter = {
|
||||||
|
site: [String(siteId)],
|
||||||
|
app: [String(appId)],
|
||||||
|
};
|
||||||
|
if (spec.includePathType) filter.path_type = [...ALL_PATH_TYPES];
|
||||||
|
if (spec.direction) filter.direction = spec.direction;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await paloAltoAxios.post(appMetricsPath(), {
|
||||||
|
start_time: windowStart(windowMinutes),
|
||||||
|
end_time: nowIso(),
|
||||||
|
// NOT pickInterval — see pickAppMetricInterval doc for why:
|
||||||
|
// app metrics need the fine-grained series or the client-side
|
||||||
|
// worst-window aggregation is meaningless.
|
||||||
|
interval: pickAppMetricInterval(windowMinutes),
|
||||||
|
metrics: [{
|
||||||
|
name: spec.name,
|
||||||
|
statistics: ['average'],
|
||||||
|
unit: spec.unit,
|
||||||
|
}],
|
||||||
|
filter,
|
||||||
|
view: {},
|
||||||
|
});
|
||||||
|
return res.data || null;
|
||||||
|
} catch (err) {
|
||||||
|
logMetricFailure(`getAppMetric(${siteId}, app=${appId}, ${metricKey})`, err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
// Alarms (via the events/query endpoint)
|
// Alarms (via the events/query endpoint)
|
||||||
// ──────────────────────────────────────────────
|
// ──────────────────────────────────────────────
|
||||||
|
|
|
||||||
52
integrations/paloalto/urls.js
Normal file
52
integrations/paloalto/urls.js
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
// src/integrations/paloalto/urls.js
|
||||||
|
//
|
||||||
|
// Prisma Strata Cloud Manager (UI) URL builders. Lives beside the
|
||||||
|
// API client so any new deep-link pattern we discover has one
|
||||||
|
// obvious home and one obvious set of tests.
|
||||||
|
//
|
||||||
|
// Kept intentionally tiny: no HTTP, no async, no fancy templating —
|
||||||
|
// each function is pure ("give me the URL for X"). Consumers should
|
||||||
|
// pass ids they already have from the API (site.id, appAudio.appId,
|
||||||
|
// etc.); nothing in here should do its own API lookups.
|
||||||
|
|
||||||
|
const PRISMA_UI_DEFAULT_BASE = 'https://stratacloudmanager.paloaltonetworks.com';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Strata Cloud Manager base URL.
|
||||||
|
*
|
||||||
|
* Defaults to the production hostname. Overridable via
|
||||||
|
* `PRISMA_UI_BASE_URL` for tenants using a partner-branded or
|
||||||
|
* region-specific SCM domain, and for tests. Read at call time so
|
||||||
|
* tests can toggle without a module cache reset.
|
||||||
|
*
|
||||||
|
* The trailing slash is normalized off so all builders below can
|
||||||
|
* safely concatenate `${base}/path/…`.
|
||||||
|
*/
|
||||||
|
export function getPrismaUiBaseUrl() {
|
||||||
|
const raw = process.env.PRISMA_UI_BASE_URL;
|
||||||
|
const base = raw && raw.trim() ? raw.trim() : PRISMA_UI_DEFAULT_BASE;
|
||||||
|
return base.replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deep link into the SCM "Application Path Details" view for a
|
||||||
|
* single app + site combination. This is the exact page the Prisma
|
||||||
|
* UI opens when the operator clicks an app on the site's dashboard —
|
||||||
|
* the RTP/MOS/loss/jitter charts we scrape via `getAppMetric()`.
|
||||||
|
*
|
||||||
|
* Sample:
|
||||||
|
* https://stratacloudmanager.paloaltonetworks.com/insights/
|
||||||
|
* operational/sdwan-applications/{appId}/site/{siteId}/details
|
||||||
|
*
|
||||||
|
* Returns null when either id is missing so callers can no-op
|
||||||
|
* without a null guard on every render path.
|
||||||
|
*
|
||||||
|
* @param {string|number} siteId Numeric Prisma site id
|
||||||
|
* @param {string|number} appId Numeric Prisma app id
|
||||||
|
* @returns {string|null}
|
||||||
|
*/
|
||||||
|
export function buildAppDetailsUrl(siteId, appId) {
|
||||||
|
if (!siteId || !appId) return null;
|
||||||
|
const base = getPrismaUiBaseUrl();
|
||||||
|
return `${base}/insights/operational/sdwan-applications/${appId}/site/${siteId}/details`;
|
||||||
|
}
|
||||||
|
|
@ -69,6 +69,27 @@
|
||||||
* the metric's identifier is wrong (400 METRIC_UNIT_NOT_SUPPORTED
|
* the metric's identifier is wrong (400 METRIC_UNIT_NOT_SUPPORTED
|
||||||
* or METRIC_NOT_FOUND).
|
* or METRIC_NOT_FOUND).
|
||||||
*
|
*
|
||||||
|
* try-shapes app-list <siteId>
|
||||||
|
* Discover which per-app metrics Prisma exposes for the site
|
||||||
|
* (Webex_Calling_RTP, rtp-base, MS_Teams_RTP, voice_rtp, ...).
|
||||||
|
* Run this FIRST before any of the app-audio-* probes so you
|
||||||
|
* know the right app id for your tenant.
|
||||||
|
*
|
||||||
|
* try-shapes app-audio-mos <siteId> <appId>
|
||||||
|
* try-shapes app-audio-loss <siteId> <appId>
|
||||||
|
* try-shapes app-audio-jitter <siteId> <appId>
|
||||||
|
* try-shapes app-audio-bandwidth <siteId> <appId>
|
||||||
|
* Sweep candidate metric name × unit combinations for the
|
||||||
|
* "Application Path Details" per-app DPI metrics. These are
|
||||||
|
* what actual voice traffic (e.g. Webex_Calling_RTP, rtp-base)
|
||||||
|
* experiences, which is often materially worse than the LQM
|
||||||
|
* link-probe signal. Uses
|
||||||
|
* the HAR-confirmed shape: filter.app=[<id>] (numeric id, NOT
|
||||||
|
* display name), view.individual="app", no filter.site. Run
|
||||||
|
* `appdefs <needle>` first to look up the app id.
|
||||||
|
* Once a shape lands, feed the winner back into
|
||||||
|
* integrations/paloalto/metrics.js as a getAppMetric().
|
||||||
|
*
|
||||||
* Global flags:
|
* Global flags:
|
||||||
* --json Emit JSON output instead of pretty-printed.
|
* --json Emit JSON output instead of pretty-printed.
|
||||||
* --show-body Print the request body on 2xx as well as 4xx.
|
* --show-body Print the request body on 2xx as well as 4xx.
|
||||||
|
|
@ -352,6 +373,54 @@ async function cmdRaw(args) {
|
||||||
printVerdict(verdict, args.flags);
|
printVerdict(verdict, args.flags);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* appdefs — resolve the tenant's app catalog. Optional filter arg:
|
||||||
|
* - `appdefs` → dump all apps (first `--limit` rows,
|
||||||
|
* default 500)
|
||||||
|
* - `appdefs <substring>` → grep the `display_name` and `name`
|
||||||
|
* fields for a case-insensitive match
|
||||||
|
*
|
||||||
|
* Uses the endpoint confirmed working on this tenant via app-list probe:
|
||||||
|
* POST /sdwan/v2.5/api/appdefs/query
|
||||||
|
*
|
||||||
|
* Response items each have `id`, `name`, `display_name`, `category`.
|
||||||
|
* The `id` is what per-app metric calls need in `filter.app`.
|
||||||
|
*/
|
||||||
|
async function cmdAppdefs(args) {
|
||||||
|
const needle = args._[1] || '';
|
||||||
|
const limit = Number(args.flags.limit) || 500;
|
||||||
|
heading(`appdefs — limit=${limit}${needle ? `, needle="${needle}"` : ''}`);
|
||||||
|
const verdict = await fire({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/sdwan/v2.5/api/appdefs/query',
|
||||||
|
body: { limit },
|
||||||
|
});
|
||||||
|
if (!verdict.ok) {
|
||||||
|
printVerdict(verdict, args.flags);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const items = verdict.data?.items || [];
|
||||||
|
info(`fetched ${items.length} appdefs (total_count=${verdict.data?.total_count || '?'})`);
|
||||||
|
const rows = needle
|
||||||
|
? items.filter((a) => {
|
||||||
|
const hay = [
|
||||||
|
a?.name, a?.display_name, a?.category, a?.id,
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
return hay.includes(needle.toLowerCase());
|
||||||
|
})
|
||||||
|
: items;
|
||||||
|
ok(`${rows.length} match${rows.length === 1 ? '' : 'es'}`);
|
||||||
|
console.log('id name display_name');
|
||||||
|
console.log('──────────────────────────── ─────────────────────────── ─────────────────────');
|
||||||
|
for (const a of rows.slice(0, 50)) {
|
||||||
|
const id = String(a?.id || '').padEnd(28);
|
||||||
|
const name = String(a?.name || '').padEnd(27);
|
||||||
|
const disp = String(a?.display_name || '');
|
||||||
|
console.log(`${id} ${name} ${disp}`);
|
||||||
|
}
|
||||||
|
if (rows.length > 50) info(`… +${rows.length - 50} more (use --limit to widen the initial fetch, or narrow the needle)`);
|
||||||
|
}
|
||||||
|
|
||||||
// ─── try-shapes: combinatorial schema testing ───────────────────────
|
// ─── try-shapes: combinatorial schema testing ───────────────────────
|
||||||
|
|
||||||
function pickInterval5min() { return '5min'; }
|
function pickInterval5min() { return '5min'; }
|
||||||
|
|
@ -605,7 +674,298 @@ async function cmdTryShapes(args) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
throw new Error(`unknown try-shapes target "${which}" — expected "health", "lqm", "lqm-latency", "lqm-jitter", "lqm-loss", or "lqm-mos"`);
|
// ── App-metrics matrix ────────────────────────────────────────────
|
||||||
|
// Explore Application Path Details signals — the "rtp-base" style
|
||||||
|
// per-app DPI metrics that the Prisma UI's Application Path Details
|
||||||
|
// page renders. These are what actual voice traffic experiences,
|
||||||
|
// as opposed to LQM which measures synthetic link probes.
|
||||||
|
//
|
||||||
|
// Unlike LQM, we don't yet know the metric name/unit or the exact
|
||||||
|
// filter shape (specifically, whether Prisma expects `app`, `apps`,
|
||||||
|
// `app_name`, or `application`). So each candidate combines:
|
||||||
|
// - endpoint (v2.6 monitor/metrics is the modern path; v2.0 has
|
||||||
|
// an appstats + aggregate_flows variant we also try)
|
||||||
|
// - metric name variants Prisma might use
|
||||||
|
// - unit variants
|
||||||
|
// - filter-key variants for the app selector
|
||||||
|
//
|
||||||
|
// Discovery flow: run `try-shapes app-list <siteId>` first to see
|
||||||
|
// what app names Prisma actually has for the site (rtp-base vs
|
||||||
|
// rtp vs voice_rtp vs ...). Then feed the right one into
|
||||||
|
// `try-shapes app-audio-mos|loss|jitter`.
|
||||||
|
// NOTE: HAR capture 2026-07-09 confirmed Prisma's v2.6 per-app family
|
||||||
|
// uses plain Title-case names (NO `PointMetric` suffix). Real names
|
||||||
|
// observed: ApplicationHealthscore/gauge, BandwidthUsage/Mbps,
|
||||||
|
// TCPFlowCount/count. The audio metric names below all follow the
|
||||||
|
// same convention — no `PointMetric`, no `Point`, just words.
|
||||||
|
//
|
||||||
|
// Also: filter uses `filter.app` with the APP ID (e.g. "15932...")
|
||||||
|
// NOT the display name ("rtp-base"). Look up the id with the
|
||||||
|
// `appdefs` subcommand first.
|
||||||
|
// Metric name × unit sweeps for the "Application Path Details"
|
||||||
|
// per-app metrics. Winners (confirmed via HAR 2026-07-09) are marked.
|
||||||
|
// The rejected guesses stay in as regression guards — if Prisma
|
||||||
|
// ever renames them we'll see it here.
|
||||||
|
const APP_METRIC_MATRIX = {
|
||||||
|
'app-audio-mos': {
|
||||||
|
label: 'per-app audio MOS score',
|
||||||
|
// WINNER: AppAudioMos + count. NOTE: this metric requires
|
||||||
|
// filter.direction="Ingress" (audio quality is what you
|
||||||
|
// RECEIVE) and does NOT use filter.path_type.
|
||||||
|
metricNames: [
|
||||||
|
'AppAudioMos', // ← LIVE WINNER (2026-07-09)
|
||||||
|
'AudioMOSScore', 'AudioMosScore', 'AudioMOS', 'AudioMos',
|
||||||
|
'ApplicationAudioMOS', 'MOSScore', 'VoiceMOS', 'VoiceMOSScore',
|
||||||
|
],
|
||||||
|
units: ['count', 'gauge', 'score'],
|
||||||
|
direction: 'Ingress',
|
||||||
|
includePathType: false,
|
||||||
|
},
|
||||||
|
'app-audio-loss': {
|
||||||
|
label: 'per-app audio packet loss',
|
||||||
|
// WINNER: AppPerfUDPAudioPacketLoss + percentage. Uses
|
||||||
|
// filter.direction="Ingress" AND filter.path_type=[all].
|
||||||
|
metricNames: [
|
||||||
|
'AppPerfUDPAudioPacketLoss', // ← LIVE WINNER (2026-07-09)
|
||||||
|
'AudioPacketLoss', 'AudioPktLoss', 'AudioLoss',
|
||||||
|
'ApplicationAudioPacketLoss', 'PacketLoss', 'VoicePacketLoss',
|
||||||
|
],
|
||||||
|
units: ['percentage', 'percent', 'gauge', 'count'],
|
||||||
|
direction: 'Ingress',
|
||||||
|
includePathType: true,
|
||||||
|
},
|
||||||
|
'app-audio-jitter': {
|
||||||
|
label: 'per-app audio jitter',
|
||||||
|
// WINNER: AppPerfUDPAudioJitter + milliseconds. Uses
|
||||||
|
// filter.direction="Ingress" AND filter.path_type=[all].
|
||||||
|
metricNames: [
|
||||||
|
'AppPerfUDPAudioJitter', // ← LIVE WINNER (2026-07-09)
|
||||||
|
'AudioJitter', 'ApplicationAudioJitter', 'Jitter', 'VoiceJitter',
|
||||||
|
],
|
||||||
|
units: ['milliseconds', 'gauge', 'count'],
|
||||||
|
direction: 'Ingress',
|
||||||
|
includePathType: true,
|
||||||
|
},
|
||||||
|
'app-audio-bandwidth': {
|
||||||
|
label: 'per-app audio bandwidth',
|
||||||
|
// WINNER: AppPerfUDPAudioBandwidth + Mbps. Uses filter.path_type
|
||||||
|
// but does NOT need filter.direction (bandwidth is bidirectional
|
||||||
|
// aggregate on this endpoint).
|
||||||
|
metricNames: [
|
||||||
|
'AppPerfUDPAudioBandwidth', // ← LIVE WINNER (2026-07-09)
|
||||||
|
'AudioBandwidth', 'AudioBandwidthUsage', 'ApplicationAudioBandwidth',
|
||||||
|
'BandwidthUsage', // Tenant-wide bandwidth (also works,
|
||||||
|
// useful control that filter.app narrows it)
|
||||||
|
],
|
||||||
|
units: ['Mbps', 'kbps', 'gauge'],
|
||||||
|
direction: null,
|
||||||
|
includePathType: true,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (APP_METRIC_MATRIX[which]) {
|
||||||
|
// Second positional arg is the APP ID (not the name) — Prisma's
|
||||||
|
// filter.app expects the numeric id. Run `appdefs <needle>` to
|
||||||
|
// discover an app id (or grab one from the browser dev tools on
|
||||||
|
// the Prisma UI's Application Path Details page).
|
||||||
|
const appId = args._[3];
|
||||||
|
if (!appId) {
|
||||||
|
throw new Error(
|
||||||
|
`usage: try-shapes ${which} <siteId> <appId>\n` +
|
||||||
|
` Use \`node scripts/prismaProbe.js appdefs <needle>\` to find the app id.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
heading(`try-shapes ${which} → siteId=${siteId} appId=${appId}`);
|
||||||
|
const target = APP_METRIC_MATRIX[which];
|
||||||
|
|
||||||
|
// HAR-confirmed request shape (rtp-base-metricCG00127.har,
|
||||||
|
// 2026-07-09): start_time+end_time required, filter.site works,
|
||||||
|
// audio-quality metrics need filter.direction="Ingress",
|
||||||
|
// AppPerf* metrics take filter.path_type, AppAudioMos does not,
|
||||||
|
// view is empty `{}` for these single-metric queries.
|
||||||
|
const endTime = new Date().toISOString();
|
||||||
|
const startTime24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
|
||||||
|
const ALL_PATH_TYPES = ['DirectInternet', 'VPN', 'PrivateWAN', 'PrivateVPN', 'ServiceLink'];
|
||||||
|
|
||||||
|
const candidates = target.metricNames.flatMap((name) =>
|
||||||
|
target.units.map((unit) => {
|
||||||
|
const filter = { site: [String(siteId)], app: [String(appId)] };
|
||||||
|
if (target.includePathType) filter.path_type = ALL_PATH_TYPES;
|
||||||
|
if (target.direction) filter.direction = target.direction;
|
||||||
|
const filterHint = [
|
||||||
|
`filter.app=[${appId}]`,
|
||||||
|
`filter.site=[${siteId}]`,
|
||||||
|
target.includePathType ? 'filter.path_type=[…]' : '',
|
||||||
|
target.direction ? `filter.direction="${target.direction}"` : '',
|
||||||
|
].filter(Boolean).join(', ');
|
||||||
|
return {
|
||||||
|
label: `name="${name}", unit="${unit}", ${filterHint}`,
|
||||||
|
body: {
|
||||||
|
start_time: startTime24h,
|
||||||
|
end_time: endTime,
|
||||||
|
interval: pickInterval5min(),
|
||||||
|
metrics: [{ name, statistics: ['average'], unit }],
|
||||||
|
filter,
|
||||||
|
view: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const results = await runCandidates(
|
||||||
|
'POST',
|
||||||
|
'/sdwan/monitor/v2.6/api/monitor/metrics',
|
||||||
|
candidates, {}, args.flags,
|
||||||
|
);
|
||||||
|
printSummary(`${which} (${target.label})`, results);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── App discovery — list which app names Prisma sees at the site
|
||||||
|
//
|
||||||
|
// Two-pronged approach:
|
||||||
|
// 1. Probe several plausible appdefs / appdef-query endpoints so
|
||||||
|
// we can dump a global catalog of app names Prisma knows.
|
||||||
|
// 2. Sweep a large-ish list of candidate metric names against the
|
||||||
|
// v2.6 monitor/metrics endpoint with `view.individual="app"`.
|
||||||
|
// Any metric that returns 200 + a data payload will surface
|
||||||
|
// the app names as a side-effect (the response includes an
|
||||||
|
// `app` dimension per row).
|
||||||
|
//
|
||||||
|
// The correct v2.6 metric names for per-app / per-app-path signals
|
||||||
|
// aren't documented consistently — Prisma renames them across
|
||||||
|
// versions. This sweep should reveal at least one that works so we
|
||||||
|
// can then use it as the base for per-app-audio-* probes.
|
||||||
|
//
|
||||||
|
// As a fast alternative to blind guessing, the operator can also
|
||||||
|
// open the "Application Path Details" page in the Prisma UI with
|
||||||
|
// browser dev tools open (Network tab, filter for /monitor/) — the
|
||||||
|
// page fires the exact API call we need to mimic. Copy the request
|
||||||
|
// body from there into the raw subcommand for an instant answer.
|
||||||
|
if (which === 'app-list') {
|
||||||
|
heading(`try-shapes app-list → siteId=${siteId}`);
|
||||||
|
|
||||||
|
// 1. Appdef catalog endpoints — different Prisma versions expose
|
||||||
|
// the list of known apps at different paths. All are read-only.
|
||||||
|
const appdefEndpoints = [
|
||||||
|
{ method: 'POST', url: '/sdwan/appdefs/v2.5/api/appdefs/query',
|
||||||
|
body: { limit: 200 },
|
||||||
|
label: 'POST /appdefs/v2.5/api/appdefs/query {limit:200}' },
|
||||||
|
{ method: 'POST', url: '/sdwan/appdefs/v2.1/api/appdefs/query',
|
||||||
|
body: { limit: 200 },
|
||||||
|
label: 'POST /appdefs/v2.1/api/appdefs/query {limit:200}' },
|
||||||
|
{ method: 'GET', url: '/sdwan/v2.5/api/appdefs',
|
||||||
|
label: 'GET /v2.5/api/appdefs (tenant-wide, top-level)' },
|
||||||
|
{ method: 'GET', url: '/sdwan/v2.1/api/appdefs',
|
||||||
|
label: 'GET /v2.1/api/appdefs (tenant-wide, top-level)' },
|
||||||
|
{ method: 'POST', url: '/sdwan/v2.5/api/appdefs/query',
|
||||||
|
body: { limit: 200 },
|
||||||
|
label: 'POST /v2.5/api/appdefs/query {limit:200}' },
|
||||||
|
{ method: 'GET', url: '/sdwan/config/v2.5/api/appdefs',
|
||||||
|
label: 'GET /config/v2.5/api/appdefs' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// 2. Metric-name catalog sweep. Confirmed via HAR capture 2026-07-09
|
||||||
|
// that Prisma's v2.6 per-app family does NOT use the `PointMetric`
|
||||||
|
// suffix seen in the LQM family. Real names are plain Title case
|
||||||
|
// like ApplicationHealthscore, BandwidthUsage, TCPFlowCount.
|
||||||
|
// Below is a mix of KNOWN-WORKING names (as regression guards +
|
||||||
|
// dimension probes) and PLAUSIBLE audio-metric guesses following
|
||||||
|
// the observed naming convention.
|
||||||
|
const perAppMetricNames = [
|
||||||
|
// ─── CONFIRMED WORKING NAMES (HAR 2026-07-09) ─────────────────
|
||||||
|
// Use these as canary checks — if a Prisma tenant update ever
|
||||||
|
// renames them, we'll see it here.
|
||||||
|
'ApplicationHealthscore',
|
||||||
|
'BandwidthUsage',
|
||||||
|
'TCPFlowCount',
|
||||||
|
'UDPFlowCount',
|
||||||
|
'AppSuccessfulConnections',
|
||||||
|
'AppSuccessfulTransactions',
|
||||||
|
'AppFailedToEstablish',
|
||||||
|
'AppTransactionFailures',
|
||||||
|
// ─── PLAUSIBLE AUDIO / MOS GUESSES (per naming convention) ────
|
||||||
|
// Prisma UI shows Audio MOS Score / Audio Packet Loss / Audio
|
||||||
|
// Jitter on the Application Path Details page — the API names
|
||||||
|
// probably follow the same PlainTitle convention we just learned.
|
||||||
|
'AudioMOSScore',
|
||||||
|
'AudioMosScore',
|
||||||
|
'AudioMOS',
|
||||||
|
'AudioMos',
|
||||||
|
'AudioPacketLoss',
|
||||||
|
'AudioPktLoss',
|
||||||
|
'AudioLoss',
|
||||||
|
'AudioJitter',
|
||||||
|
'AudioBandwidth',
|
||||||
|
'AudioBandwidthUsage',
|
||||||
|
'ApplicationAudioMOS',
|
||||||
|
'ApplicationAudioPacketLoss',
|
||||||
|
'ApplicationAudioJitter',
|
||||||
|
'MOSScore',
|
||||||
|
'VoiceMOS',
|
||||||
|
'VoiceMOSScore',
|
||||||
|
// ─── VIDEO EQUIVALENTS (nice-to-have) ─────────────────────────
|
||||||
|
'VideoMOSScore',
|
||||||
|
'VideoPacketLoss',
|
||||||
|
'VideoJitter',
|
||||||
|
'VideoBandwidth',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Metric sweep — uses the HAR-confirmed request shape:
|
||||||
|
// start_time + end_time (both required),
|
||||||
|
// view: { individual: "app", summary: false },
|
||||||
|
// NO filter.site (per-app queries are tenant-wide-per-app),
|
||||||
|
// NO filter.app (we're testing WHICH metric names exist for
|
||||||
|
// any app; adding an app id would narrow to a specific one).
|
||||||
|
//
|
||||||
|
// Sweep {gauge, count, Mbps} — the three units we've actually
|
||||||
|
// observed working. Skip the no-unit case (already known to
|
||||||
|
// return SCHEMA_CHECK_FAIL: "unit: is missing but it is required").
|
||||||
|
const endTime = new Date().toISOString();
|
||||||
|
const startTime = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||||
|
const metricSweepCandidates = [];
|
||||||
|
for (const name of perAppMetricNames) {
|
||||||
|
for (const unitVariant of ['gauge', 'count', 'Mbps']) {
|
||||||
|
metricSweepCandidates.push({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/sdwan/monitor/v2.6/api/monitor/metrics',
|
||||||
|
label: `v2.6 monitor/metrics name="${name}", unit="${unitVariant}"`,
|
||||||
|
body: {
|
||||||
|
start_time: startTime,
|
||||||
|
end_time: endTime,
|
||||||
|
interval: pickInterval5min(),
|
||||||
|
metrics: [{ name, statistics: ['average'], unit: unitVariant }],
|
||||||
|
view: { individual: 'app', summary: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = [];
|
||||||
|
for (const c of [...appdefEndpoints, ...metricSweepCandidates]) {
|
||||||
|
const results = await runCandidates(
|
||||||
|
c.method || 'POST', c.url,
|
||||||
|
[{ label: c.label, body: c.body || null }],
|
||||||
|
{}, args.flags,
|
||||||
|
);
|
||||||
|
combined.push(...results);
|
||||||
|
}
|
||||||
|
printSummary('app-list', combined);
|
||||||
|
|
||||||
|
info('If nothing passed, the fastest path from here is:');
|
||||||
|
info(' 1. Open the "Application Path Details" page for a site in the Prisma UI');
|
||||||
|
info(' 2. Open browser dev tools → Network tab → filter for "monitor" or "metrics"');
|
||||||
|
info(' 3. Right-click the request → Copy → Copy as cURL (or copy the request body)');
|
||||||
|
info(' 4. Paste the body into: `npm run prisma:probe -- raw POST <url> --body \'<json>\'`');
|
||||||
|
info('That surfaces the exact metric name + shape the UI uses.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`unknown try-shapes target "${which}" — expected "health", "lqm", ` +
|
||||||
|
`"lqm-latency", "lqm-jitter", "lqm-loss", "lqm-mos", ` +
|
||||||
|
`"app-list", "app-audio-mos", "app-audio-loss", "app-audio-jitter", or "app-audio-bandwidth"`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -741,12 +1101,18 @@ function printHelp() {
|
||||||
' lqm <siteId> <wiCsv> [--metric X] Fetch LQM metric (latency|jitter|loss|mos)',
|
' lqm <siteId> <wiCsv> [--metric X] Fetch LQM metric (latency|jitter|loss|mos)',
|
||||||
' alarms <siteId> [--window minutes] Fetch alarms',
|
' alarms <siteId> [--window minutes] Fetch alarms',
|
||||||
' raw <METHOD> <path> [--body JSON] Arbitrary authenticated request',
|
' raw <METHOD> <path> [--body JSON] Arbitrary authenticated request',
|
||||||
|
' appdefs [<needle>] [--limit N] Dump the tenant app catalog (optional substring filter)',
|
||||||
' try-shapes health <siteId> Test N healthscore body shapes',
|
' try-shapes health <siteId> Test N healthscore body shapes',
|
||||||
' try-shapes lqm <siteId> <wiCsv> Test N LQM body shapes',
|
' try-shapes lqm <siteId> <wiCsv> Test N LQM body shapes',
|
||||||
' try-shapes lqm-latency <siteId> <wiCsv> Sweep {name × unit} combos for latency',
|
' try-shapes lqm-latency <siteId> <wiCsv> Sweep {name × unit} combos for latency',
|
||||||
' try-shapes lqm-jitter <siteId> <wiCsv> Sweep {name × unit} combos for jitter',
|
' try-shapes lqm-jitter <siteId> <wiCsv> Sweep {name × unit} combos for jitter',
|
||||||
' try-shapes lqm-loss <siteId> <wiCsv> Sweep {name × unit} combos for packet loss',
|
' try-shapes lqm-loss <siteId> <wiCsv> Sweep {name × unit} combos for packet loss',
|
||||||
' try-shapes lqm-mos <siteId> <wiCsv> Sweep {name × unit} combos for MOS',
|
' try-shapes lqm-mos <siteId> <wiCsv> Sweep {name × unit} combos for MOS',
|
||||||
|
' try-shapes app-list <siteId> Discover per-site app names Prisma sees',
|
||||||
|
' try-shapes app-audio-mos <siteId> [app=rtp-base] Sweep per-app audio MOS shapes',
|
||||||
|
' try-shapes app-audio-loss <siteId> [app=rtp-base] Sweep per-app audio loss shapes',
|
||||||
|
' try-shapes app-audio-jitter <siteId> [app=rtp-base] Sweep per-app audio jitter shapes',
|
||||||
|
' try-shapes app-audio-bandwidth <siteId> [app=rtp-base] Sweep per-app audio BW shapes',
|
||||||
'',
|
'',
|
||||||
'Global flags:',
|
'Global flags:',
|
||||||
' --json JSON output',
|
' --json JSON output',
|
||||||
|
|
@ -759,6 +1125,11 @@ function printHelp() {
|
||||||
' node scripts/prismaProbe.js try-shapes health 16158173173100144',
|
' node scripts/prismaProbe.js try-shapes health 16158173173100144',
|
||||||
' node scripts/prismaProbe.js lqm 16158173173100144 16158173176610209 --metric loss',
|
' node scripts/prismaProbe.js lqm 16158173173100144 16158173176610209 --metric loss',
|
||||||
' node scripts/prismaProbe.js try-shapes lqm-loss 16158173173100144 16158173176610209,1666974885552003096',
|
' node scripts/prismaProbe.js try-shapes lqm-loss 16158173173100144 16158173176610209,1666974885552003096',
|
||||||
|
' node scripts/prismaProbe.js appdefs rtp',
|
||||||
|
' node scripts/prismaProbe.js appdefs --limit 1000',
|
||||||
|
' node scripts/prismaProbe.js try-shapes app-list 16158173173100144',
|
||||||
|
' node scripts/prismaProbe.js try-shapes app-audio-mos 16158173173100144 15932000365560116',
|
||||||
|
' node scripts/prismaProbe.js try-shapes app-audio-loss 16158173173100144 15932000365560116',
|
||||||
' node scripts/prismaProbe.js raw POST /sdwan/v3.7/api/events/query --body \'{"limit":{"count":5}}\'',
|
' node scripts/prismaProbe.js raw POST /sdwan/v3.7/api/events/query --body \'{"limit":{"count":5}}\'',
|
||||||
].join('\n'));
|
].join('\n'));
|
||||||
}
|
}
|
||||||
|
|
@ -781,6 +1152,7 @@ async function main() {
|
||||||
lqm: cmdLqm,
|
lqm: cmdLqm,
|
||||||
alarms: cmdAlarms,
|
alarms: cmdAlarms,
|
||||||
raw: cmdRaw,
|
raw: cmdRaw,
|
||||||
|
appdefs: cmdAppdefs,
|
||||||
'try-shapes': cmdTryShapes,
|
'try-shapes': cmdTryShapes,
|
||||||
};
|
};
|
||||||
const fn = dispatch[sub];
|
const fn = dispatch[sub];
|
||||||
|
|
|
||||||
|
|
@ -60,8 +60,10 @@ import {
|
||||||
import {
|
import {
|
||||||
getHealthscore,
|
getHealthscore,
|
||||||
getLqmMetric,
|
getLqmMetric,
|
||||||
|
getAppMetric,
|
||||||
getAlarms,
|
getAlarms,
|
||||||
} from '../../integrations/paloalto/metrics.js';
|
} from '../../integrations/paloalto/metrics.js';
|
||||||
|
import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compose everything /voicediag + /phonestatus care about for a
|
* Compose everything /voicediag + /phonestatus care about for a
|
||||||
|
|
@ -72,7 +74,10 @@ import {
|
||||||
* @param {object} [opts]
|
* @param {object} [opts]
|
||||||
* @param {number} [opts.windowMinutes] Look-back window for healthscore + LQM.
|
* @param {number} [opts.windowMinutes] Look-back window for healthscore + LQM.
|
||||||
* Also used as the alarm window unless `alarmWindowMinutes` is set.
|
* Also used as the alarm window unless `alarmWindowMinutes` is set.
|
||||||
* Defaults to `WAN_STANDARD_WINDOW_MINUTES` env var or 15.
|
* Defaults to `WAN_STANDARD_WINDOW_MINUTES` env var or 10080 (7d).
|
||||||
|
* Widened from 24h because per-app DPI metrics only get datapoints
|
||||||
|
* when calls actually happen — sporadic stores need a wider window
|
||||||
|
* for worst-window stats to be meaningful.
|
||||||
* @param {number} [opts.alarmWindowMinutes] Override the alarm window
|
* @param {number} [opts.alarmWindowMinutes] Override the alarm window
|
||||||
* independently (defaults to `max(60, windowMinutes)` since alarms
|
* independently (defaults to `max(60, windowMinutes)` since alarms
|
||||||
* at < 1h are usually too noisy to be actionable).
|
* at < 1h are usually too noisy to be actionable).
|
||||||
|
|
@ -104,6 +109,7 @@ export async function collectSdwanForStore(storeNum, opts = {}) {
|
||||||
healthscore: null,
|
healthscore: null,
|
||||||
links: emptyLinks,
|
links: emptyLinks,
|
||||||
alarms: emptyAlarms(),
|
alarms: emptyAlarms(),
|
||||||
|
appAudio: null,
|
||||||
errors,
|
errors,
|
||||||
fetchedAt: new Date().toISOString(),
|
fetchedAt: new Date().toISOString(),
|
||||||
window: { minutes: windowMinutes, alarmMinutes: alarmWindowMinutes },
|
window: { minutes: windowMinutes, alarmMinutes: alarmWindowMinutes },
|
||||||
|
|
@ -129,7 +135,15 @@ export async function collectSdwanForStore(storeNum, opts = {}) {
|
||||||
// getHealthscore → site only (filter shape is empty; site match done locally)
|
// getHealthscore → site only (filter shape is empty; site match done locally)
|
||||||
// getLqmMetric → requires waninterfaceIds (per-path)
|
// getLqmMetric → requires waninterfaceIds (per-path)
|
||||||
// getAlarms → site only
|
// getAlarms → site only
|
||||||
|
// getAppMetric → site + numeric app id. Feature-gated: only
|
||||||
|
// fires if the voice-app id env var is set.
|
||||||
|
// Fetches the four audio metrics in parallel,
|
||||||
|
// gracefully skips them if not configured.
|
||||||
const canFetchLqm = waninterfaceIds.length > 0;
|
const canFetchLqm = waninterfaceIds.length > 0;
|
||||||
|
const voiceAppCfg = resolveVoiceAppConfig();
|
||||||
|
const voiceAppId = voiceAppCfg.appId;
|
||||||
|
const voiceAppName = voiceAppCfg.appName;
|
||||||
|
const canFetchAppMetrics = Boolean(voiceAppId);
|
||||||
const [
|
const [
|
||||||
healthscoreRes,
|
healthscoreRes,
|
||||||
latencyRes,
|
latencyRes,
|
||||||
|
|
@ -137,6 +151,10 @@ export async function collectSdwanForStore(storeNum, opts = {}) {
|
||||||
lossRes,
|
lossRes,
|
||||||
mosRes,
|
mosRes,
|
||||||
alarmsRes,
|
alarmsRes,
|
||||||
|
appMosRes,
|
||||||
|
appLossRes,
|
||||||
|
appJitterRes,
|
||||||
|
appBwRes,
|
||||||
] = await Promise.allSettled([
|
] = await Promise.allSettled([
|
||||||
getHealthscore(site.id, windowMinutes),
|
getHealthscore(site.id, windowMinutes),
|
||||||
canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'latency', windowMinutes) : Promise.resolve(null),
|
canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'latency', windowMinutes) : Promise.resolve(null),
|
||||||
|
|
@ -144,6 +162,10 @@ export async function collectSdwanForStore(storeNum, opts = {}) {
|
||||||
canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'loss', windowMinutes) : Promise.resolve(null),
|
canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'loss', windowMinutes) : Promise.resolve(null),
|
||||||
canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'mos', windowMinutes) : Promise.resolve(null),
|
canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'mos', windowMinutes) : Promise.resolve(null),
|
||||||
getAlarms(site.id, alarmWindowMinutes),
|
getAlarms(site.id, alarmWindowMinutes),
|
||||||
|
canFetchAppMetrics ? getAppMetric(site.id, voiceAppId, 'mos', windowMinutes) : Promise.resolve(null),
|
||||||
|
canFetchAppMetrics ? getAppMetric(site.id, voiceAppId, 'loss', windowMinutes) : Promise.resolve(null),
|
||||||
|
canFetchAppMetrics ? getAppMetric(site.id, voiceAppId, 'jitter', windowMinutes) : Promise.resolve(null),
|
||||||
|
canFetchAppMetrics ? getAppMetric(site.id, voiceAppId, 'bandwidth', windowMinutes) : Promise.resolve(null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
recordFailure(errors, 'healthscore', healthscoreRes);
|
recordFailure(errors, 'healthscore', healthscoreRes);
|
||||||
|
|
@ -152,6 +174,16 @@ export async function collectSdwanForStore(storeNum, opts = {}) {
|
||||||
recordFailure(errors, 'lqm.loss', lossRes);
|
recordFailure(errors, 'lqm.loss', lossRes);
|
||||||
recordFailure(errors, 'lqm.mos', mosRes);
|
recordFailure(errors, 'lqm.mos', mosRes);
|
||||||
recordFailure(errors, 'alarms', alarmsRes);
|
recordFailure(errors, 'alarms', alarmsRes);
|
||||||
|
if (canFetchAppMetrics) {
|
||||||
|
// Scope names stay stable across app changes so the check
|
||||||
|
// envelope (getAppAudio) doesn't need to know which app is
|
||||||
|
// configured — the semantics ("audio MOS for the voice app")
|
||||||
|
// are what matter, not which RFC 3550 app it is.
|
||||||
|
recordFailure(errors, 'app.voice.mos', appMosRes);
|
||||||
|
recordFailure(errors, 'app.voice.loss', appLossRes);
|
||||||
|
recordFailure(errors, 'app.voice.jitter', appJitterRes);
|
||||||
|
recordFailure(errors, 'app.voice.bandwidth', appBwRes);
|
||||||
|
}
|
||||||
|
|
||||||
// Healthscore response covers the whole tenant (see filter shape
|
// Healthscore response covers the whole tenant (see filter shape
|
||||||
// note in metrics.js). Match the requested site locally.
|
// note in metrics.js). Match the requested site locally.
|
||||||
|
|
@ -206,15 +238,39 @@ export async function collectSdwanForStore(storeNum, opts = {}) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Parse app-metric time series into {avg, min, max, samples} tuples
|
||||||
|
// per metric. Only present when the voice-app env is configured
|
||||||
|
// (PRISMA_APP_ID_VOICE, with PRISMA_APP_ID_RTP_BASE honored for
|
||||||
|
// backwards compatibility).
|
||||||
|
//
|
||||||
|
// `detailsUrl` is the SCM UI deep-link into "Application Path
|
||||||
|
// Details" for this exact (site, app) pair — computed here so both
|
||||||
|
// renderers (WAN diag + voice diag) share the same URL without
|
||||||
|
// duplicating the pattern.
|
||||||
|
const appAudio = canFetchAppMetrics
|
||||||
|
? {
|
||||||
|
appId: voiceAppId,
|
||||||
|
appName: voiceAppName,
|
||||||
|
detailsUrl: buildAppDetailsUrl(site.id, voiceAppId),
|
||||||
|
mos: summarizeAppSeries(valueOf(appMosRes), 'AppAudioMos'),
|
||||||
|
loss: summarizeAppSeries(valueOf(appLossRes), 'AppPerfUDPAudioPacketLoss'),
|
||||||
|
jitter: summarizeAppSeries(valueOf(appJitterRes), 'AppPerfUDPAudioJitter'),
|
||||||
|
bandwidth: summarizeAppSeries(valueOf(appBwRes), 'AppPerfUDPAudioBandwidth'),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
|
||||||
const elapsed = Date.now() - startedAt;
|
const elapsed = Date.now() - startedAt;
|
||||||
const alarmDiag = alarms?._diag
|
const alarmDiag = alarms?._diag
|
||||||
? ` alarms=${alarms._diag.rawEventCount}raw→${alarms._diag.rawEventCount - alarms._diag.droppedForSiteMismatch}scoped`
|
? ` alarms=${alarms._diag.rawEventCount}raw→${alarms._diag.rawEventCount - alarms._diag.droppedForSiteMismatch}scoped`
|
||||||
: '';
|
: '';
|
||||||
|
const appAudioDiag = appAudio
|
||||||
|
? ` app=${appAudio.appName}(${appAudio.appId}) app.mos=[${appAudio.mos?.samples || 0}pts,min=${appAudio.mos?.min ?? 'n/a'}]`
|
||||||
|
: '';
|
||||||
logger(
|
logger(
|
||||||
'sdwan:enrich',
|
'sdwan:enrich',
|
||||||
`store ${storeNum} → site ${site.name} (${site.id}): ${elements.length} elements, ` +
|
`store ${storeNum} → site ${site.name} (${site.id}): ${elements.length} elements, ` +
|
||||||
`${links.length} links, health=${healthscore?.value ?? 'n/a'},${alarmDiag} errors=${errors.length}, ` +
|
`${links.length} links, health=${healthscore?.value ?? 'n/a'},${alarmDiag}${appAudioDiag} ` +
|
||||||
`window=${windowMinutes}m/alarms=${alarmWindowMinutes}m, ${elapsed}ms`,
|
`errors=${errors.length}, window=${windowMinutes}m/alarms=${alarmWindowMinutes}m, ${elapsed}ms`,
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -235,6 +291,10 @@ export async function collectSdwanForStore(storeNum, opts = {}) {
|
||||||
healthscore,
|
healthscore,
|
||||||
links,
|
links,
|
||||||
alarms,
|
alarms,
|
||||||
|
// Per-application "Application Path Details" style metrics — real
|
||||||
|
// voice-quality signal from DPI on actual RTP traffic. Null when
|
||||||
|
// PRISMA_APP_ID_RTP_BASE is not configured (feature-gated).
|
||||||
|
appAudio,
|
||||||
errors,
|
errors,
|
||||||
fetchedAt: new Date().toISOString(),
|
fetchedAt: new Date().toISOString(),
|
||||||
// Report the effective look-back so renderers can surface it and
|
// Report the effective look-back so renderers can surface it and
|
||||||
|
|
@ -243,27 +303,166 @@ export async function collectSdwanForStore(storeNum, opts = {}) {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default WAN look-back window (24h in minutes). Chosen because the
|
// ─── App-metric time-series aggregator ──────────────────────────────
|
||||||
// primary users of /phonestatus + /voicediag are triaging trouble
|
//
|
||||||
// tickets after the fact, where "was voice quality bad today?" is
|
// App metrics come back as a real time series (usually 24h × 5min =
|
||||||
// more useful than "how does it look right this second?". Operators
|
// 288 datapoints) rather than a single aggregated number like LQM.
|
||||||
// wanting a live snapshot can pass `--window 15m` on /voicediag or
|
// For voice quality, the worst-window matters more than the average:
|
||||||
// set WAN_STANDARD_WINDOW_MINUTES=15 in the environment.
|
// a store that averages 4.0 MOS over 24h but dipped to 1.85 for
|
||||||
const DEFAULT_WINDOW_MINUTES = 1440;
|
// several 5-min windows had unusable calls in those windows even
|
||||||
|
// though the average looks fine.
|
||||||
|
//
|
||||||
|
// This helper extracts {avg, min, max, p95, samples} from the
|
||||||
|
// standard v2.6 response shape:
|
||||||
|
// metrics[0].series[0].data[0].datapoints[{time, value}]
|
||||||
|
//
|
||||||
|
// Returns null for missing / malformed responses. All-null datapoints
|
||||||
|
// (e.g. TCPFlowCount for a UDP-only app in the HAR) return
|
||||||
|
// { samples: N, avg/min/max: null } so downstream callers can
|
||||||
|
// distinguish "we asked but Prisma had no data" from "we didn't ask".
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} raw v2.6 monitor/metrics response body
|
||||||
|
* @param {string} expectedName metric name we expect at metrics[].series[].name
|
||||||
|
* @returns {{avg, min, max, p95, samples, expectedName}|null}
|
||||||
|
*/
|
||||||
|
export function summarizeAppSeries(raw, expectedName) {
|
||||||
|
if (!raw || typeof raw !== 'object') return null;
|
||||||
|
const series = raw.metrics?.[0]?.series?.[0];
|
||||||
|
if (!series) return null;
|
||||||
|
// Sanity check: match by name so a mis-wired call can't silently
|
||||||
|
// land a jitter response into the loss bucket.
|
||||||
|
if (expectedName && series.name && series.name !== expectedName) {
|
||||||
|
logger('sdwan:enrich',
|
||||||
|
`summarizeAppSeries: expected metric name "${expectedName}", got "${series.name}"`, 'warn');
|
||||||
|
}
|
||||||
|
const datapoints = series.data?.[0]?.datapoints;
|
||||||
|
if (!Array.isArray(datapoints)) return null;
|
||||||
|
|
||||||
|
const values = datapoints
|
||||||
|
.map((p) => (typeof p?.value === 'number' && Number.isFinite(p.value) ? p.value : null))
|
||||||
|
.filter((v) => v !== null);
|
||||||
|
|
||||||
|
const summary = {
|
||||||
|
expectedName,
|
||||||
|
unit: series.unit || null,
|
||||||
|
interval: series.interval || null,
|
||||||
|
samples: datapoints.length,
|
||||||
|
validSamples: values.length,
|
||||||
|
avg: null, min: null, max: null, p95: null,
|
||||||
|
// Keep the raw values around so a check can compute additional
|
||||||
|
// stats (percent-of-samples-above-threshold, etc.) without having
|
||||||
|
// to re-parse the raw response. Small (<= 288 numbers per metric)
|
||||||
|
// so no memory concern.
|
||||||
|
values,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (values.length === 0) return summary;
|
||||||
|
|
||||||
|
let sum = 0, min = values[0], max = values[0];
|
||||||
|
for (const v of values) { sum += v; if (v < min) min = v; if (v > max) max = v; }
|
||||||
|
const sorted = [...values].sort((a, b) => a - b);
|
||||||
|
const p95Idx = Math.min(sorted.length - 1, Math.floor(0.95 * sorted.length));
|
||||||
|
|
||||||
|
summary.avg = round2(sum / values.length);
|
||||||
|
summary.min = round2(min);
|
||||||
|
summary.max = round2(max);
|
||||||
|
summary.p95 = round2(sorted[p95Idx]);
|
||||||
|
return summary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Voice-application (DPI) env resolution ─────────────────────────
|
||||||
|
//
|
||||||
|
// The per-app DPI feature is generic — different tenants may target
|
||||||
|
// different voice apps (rtp-base, Webex_Calling_RTP, MS Teams RTP,
|
||||||
|
// Zoom, etc.). Rather than hardcode a specific app, resolve id +
|
||||||
|
// display name from env at request time.
|
||||||
|
//
|
||||||
|
// Precedence:
|
||||||
|
// 1. PRISMA_APP_ID_VOICE — canonical, generic env var
|
||||||
|
// 2. PRISMA_APP_ID_RTP_BASE — backwards-compat with the
|
||||||
|
// original release, logged as
|
||||||
|
// deprecated on use
|
||||||
|
//
|
||||||
|
// Display name defaults to the app-key hint ('rtp-base' if only the
|
||||||
|
// legacy env is set, 'voice' otherwise); PRISMA_APP_NAME_VOICE
|
||||||
|
// overrides for the operator's preferred UI label.
|
||||||
|
//
|
||||||
|
// Exported so tests can exercise the resolution logic without
|
||||||
|
// spinning up the full enrichment path.
|
||||||
|
let _rtpBaseDeprecationLogged = false;
|
||||||
|
export function resolveVoiceAppConfig() {
|
||||||
|
const genericId = process.env.PRISMA_APP_ID_VOICE;
|
||||||
|
const legacyId = process.env.PRISMA_APP_ID_RTP_BASE;
|
||||||
|
const nameOverride = process.env.PRISMA_APP_NAME_VOICE;
|
||||||
|
|
||||||
|
let appId = null;
|
||||||
|
let defaultName = 'voice';
|
||||||
|
|
||||||
|
if (genericId && genericId.trim()) {
|
||||||
|
appId = genericId.trim();
|
||||||
|
} else if (legacyId && legacyId.trim()) {
|
||||||
|
appId = legacyId.trim();
|
||||||
|
defaultName = 'rtp-base';
|
||||||
|
if (!_rtpBaseDeprecationLogged) {
|
||||||
|
logger('sdwan:enrich',
|
||||||
|
'PRISMA_APP_ID_RTP_BASE is deprecated — rename to PRISMA_APP_ID_VOICE ' +
|
||||||
|
'(and optionally set PRISMA_APP_NAME_VOICE for a friendly display label). ' +
|
||||||
|
'The old name will continue to work but will be removed in a future release.',
|
||||||
|
'warn');
|
||||||
|
_rtpBaseDeprecationLogged = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const appName = (nameOverride && nameOverride.trim())
|
||||||
|
? nameOverride.trim()
|
||||||
|
: defaultName;
|
||||||
|
|
||||||
|
return { appId, appName };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @internal Test-only: reset the once-per-process deprecation flag. */
|
||||||
|
export function _resetVoiceAppDeprecationFlag() {
|
||||||
|
_rtpBaseDeprecationLogged = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function round2(n) {
|
||||||
|
if (!Number.isFinite(n)) return null;
|
||||||
|
return Math.round(n * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default WAN look-back window (7 days in minutes). Widened from 24h
|
||||||
|
// because per-app DPI metrics (Webex_Calling_RTP etc.) only get
|
||||||
|
// datapoints WHEN CALLS HAPPEN — a store that only takes 3-4 Webex
|
||||||
|
// calls per day gives us ~30 samples in 24h, well below the point
|
||||||
|
// where "worst-window" stats mean anything. Seven days consistently
|
||||||
|
// yields 150-300+ per-app samples across stores of any size and
|
||||||
|
// still keeps the LQM/healthscore signal meaningful. The trade-off
|
||||||
|
// is that a fresh outage is diluted in a 7-day average, so:
|
||||||
|
// - The app-metric checks grade against WORST-window (already do),
|
||||||
|
// not average, so this doesn't blunt their signal.
|
||||||
|
// - Operators triaging a live incident should pass `--window 1h`
|
||||||
|
// or `--window 24h` (or set WAN_STANDARD_WINDOW_MINUTES) to
|
||||||
|
// narrow the view. The env override still works exactly the same.
|
||||||
|
const DEFAULT_WINDOW_MINUTES = 10080; // 7 × 24 × 60
|
||||||
|
const MAX_WINDOW_MINUTES = 10080; // hard cap — see pickInterval
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snap an arbitrary window request to a reasonable, sane value.
|
* Snap an arbitrary window request to a reasonable, sane value.
|
||||||
* Falls back to the env default (WAN_STANDARD_WINDOW_MINUTES) or 1440
|
* Falls back to the env default (WAN_STANDARD_WINDOW_MINUTES) or
|
||||||
* (24h). Prisma's own upper bound seems to sit around 30 days for
|
* 10080 (7d). Prisma's own upper bound sits at ~30 days for aiops
|
||||||
* aiops queries, but voice-quality signal degrades past a day of
|
* queries, but per-app DPI series past 7d hit interval-collapse
|
||||||
* averaging so we cap at 24h for now.
|
* (Prisma downsamples to '1day', which loses the transient windows
|
||||||
|
* these checks are designed to catch). We hard-cap at 7d to keep
|
||||||
|
* the guarantees consistent — set MAX_WINDOW_MINUTES higher if you
|
||||||
|
* ever need to push past it, and re-verify pickAppMetricInterval /
|
||||||
|
* pickInterval interact cleanly with the new upper bound.
|
||||||
*/
|
*/
|
||||||
function normalizeWindowMinutes(m) {
|
function normalizeWindowMinutes(m) {
|
||||||
const raw = Number.isFinite(Number(m)) && Number(m) > 0
|
const raw = Number.isFinite(Number(m)) && Number(m) > 0
|
||||||
? Number(m)
|
? Number(m)
|
||||||
: Number(process.env.WAN_STANDARD_WINDOW_MINUTES) || DEFAULT_WINDOW_MINUTES;
|
: Number(process.env.WAN_STANDARD_WINDOW_MINUTES) || DEFAULT_WINDOW_MINUTES;
|
||||||
// Clamp to [1, 1440] (1 min .. 24 h).
|
return Math.max(1, Math.min(MAX_WINDOW_MINUTES, Math.floor(raw)));
|
||||||
return Math.max(1, Math.min(1440, Math.floor(raw)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Response normalisers ─────────────────────────────────────────
|
// ─── Response normalisers ─────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -181,6 +181,13 @@ function renderDetails(details) {
|
||||||
if ('siteId' in details && 'siteName' in details) {
|
if ('siteId' in details && 'siteName' in details) {
|
||||||
return renderSiteDetails(details);
|
return renderSiteDetails(details);
|
||||||
}
|
}
|
||||||
|
// Per-app audio MOS/loss/jitter summary — has
|
||||||
|
// {avg,min,max,worst,badSampleCount,badSamplePct,appName,samples,
|
||||||
|
// validSamples,interval,warnThresh,errorThresh}. Match on the
|
||||||
|
// combination unique to that shape.
|
||||||
|
if ('appName' in details && 'worst' in details && 'validSamples' in details) {
|
||||||
|
return renderAppAudioDetails(details);
|
||||||
|
}
|
||||||
if ('value' in details && 'warnThresh' in details && 'errorThresh' in details) {
|
if ('value' in details && 'warnThresh' in details && 'errorThresh' in details) {
|
||||||
return renderThresholdDetails(details);
|
return renderThresholdDetails(details);
|
||||||
}
|
}
|
||||||
|
|
@ -247,6 +254,66 @@ function renderLinkStateDetails(details) {
|
||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-app audio-quality (MOS/loss/jitter) detail formatter.
|
||||||
|
* The summary carries: {appName, worst, avg, min, max, p95,
|
||||||
|
* samples, validSamples, interval, warnThresh, errorThresh,
|
||||||
|
* badSampleCount, badSamplePct, standardLabel, unit}.
|
||||||
|
*
|
||||||
|
* Output (example, MOS):
|
||||||
|
*
|
||||||
|
* - App: Webex_Calling_RTP (voice traffic, DPI)
|
||||||
|
* - Threshold: warn < 4, error < 3.5
|
||||||
|
* - Worst window: 1.85 · Avg: 4.03 · p95: 4.28
|
||||||
|
* - Range: 1.85 – 4.41 across 288/288 samples @ 5min
|
||||||
|
* - Time in warn/error: 42 samples (15%)
|
||||||
|
*/
|
||||||
|
function renderAppAudioDetails(details) {
|
||||||
|
const {
|
||||||
|
appName, worst, avg, min, max, p95,
|
||||||
|
samples, validSamples, interval,
|
||||||
|
warnThresh, errorThresh, standardLabel, unit,
|
||||||
|
badSampleCount, badSamplePct,
|
||||||
|
detailsUrl,
|
||||||
|
} = details;
|
||||||
|
|
||||||
|
const u = unit || '';
|
||||||
|
const lines = [` - App: ${appName || 'voice'} (voice traffic, DPI)`];
|
||||||
|
|
||||||
|
if (standardLabel) {
|
||||||
|
lines.push(` - Threshold: ${standardLabel}`);
|
||||||
|
} else if (Number.isFinite(warnThresh) || Number.isFinite(errorThresh)) {
|
||||||
|
lines.push(` - Threshold: warn @ ${warnThresh ?? '?'}, error @ ${errorThresh ?? '?'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const worstStr = worst == null ? '—' : `${worst}${u}`;
|
||||||
|
const avgStr = avg == null ? '—' : `${avg}${u}`;
|
||||||
|
const p95Str = p95 == null ? '—' : `${p95}${u}`;
|
||||||
|
lines.push(` - Worst window: **${worstStr}** · Avg: ${avgStr} · p95: ${p95Str}`);
|
||||||
|
|
||||||
|
if (min != null && max != null) {
|
||||||
|
lines.push(` - Range: ${min}${u} – ${max}${u} across ${validSamples}/${samples} samples @ ${interval || '5min'}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isFinite(badSampleCount) && Number.isFinite(badSamplePct)) {
|
||||||
|
if (badSampleCount > 0) {
|
||||||
|
lines.push(` - Time in warn/error: ${badSampleCount} sample${badSampleCount === 1 ? '' : 's'} (~${badSamplePct}% of window)`);
|
||||||
|
} else {
|
||||||
|
lines.push(` - Time in warn/error: 0 samples (clean throughout window)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SCM UI deep-link — always the last row so it acts as a "next
|
||||||
|
// step" for anyone inspecting the details. Only rendered when the
|
||||||
|
// enrichment layer was able to build a URL (needs both site.id +
|
||||||
|
// appAudio.appId — either missing → null skips this line cleanly).
|
||||||
|
if (detailsUrl) {
|
||||||
|
lines.push(` - [View in Prisma UI](${detailsUrl})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Alarm counts + optional category breakdown + rolled-up recent
|
* Alarm counts + optional category breakdown + rolled-up recent
|
||||||
* samples. Handles both the old shape (bare `critical/major/minor +
|
* samples. Handles both the old shape (bare `critical/major/minor +
|
||||||
|
|
|
||||||
|
|
@ -92,6 +92,20 @@ export function renderWanDiagnosticsMarkdown(data, opts = {}) {
|
||||||
out += `_No WAN path metrics available for this site._\n`;
|
out += `_No WAN path metrics available for this site._\n`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-app "Application Path Details" section — real voice-quality
|
||||||
|
// signal from DPI on actual RTP traffic. Which app is measured
|
||||||
|
// depends on the tenant's configured voice app (Webex_Calling_RTP,
|
||||||
|
// rtp-base, MS_Teams_RTP, etc.). Shown BEFORE alarms because when
|
||||||
|
// it fires, it's usually more actionable than an overlay alarm:
|
||||||
|
// it's "your calls sounded bad at these times", not "a tunnel
|
||||||
|
// bounced but recovered". Feature-gated on PRISMA_APP_ID_VOICE
|
||||||
|
// (backwards-compat: PRISMA_APP_ID_RTP_BASE) — omitted entirely
|
||||||
|
// when not configured so this doesn't add empty sections for
|
||||||
|
// tenants that opted out.
|
||||||
|
if (data.appAudio) {
|
||||||
|
out += renderAppAudioSection(data.appAudio, data.window);
|
||||||
|
}
|
||||||
|
|
||||||
// Alarms summary (only if any are active). Samples are rolled up
|
// Alarms summary (only if any are active). Samples are rolled up
|
||||||
// by (code + severity) so 20 identical NETWORK_ANYNETLINK_DOWN
|
// by (code + severity) so 20 identical NETWORK_ANYNETLINK_DOWN
|
||||||
// events show as one line with "×20" instead of pasting 20 nearly-
|
// events show as one line with "×20" instead of pasting 20 nearly-
|
||||||
|
|
@ -137,7 +151,10 @@ export function renderWanDiagnosticsMarkdown(data, opts = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (footer) {
|
if (footer) {
|
||||||
out += `\n*WAN metrics pulled at ${new Date().toLocaleTimeString()} from Prisma SD-WAN. Use \`/voicediag ${storeNum || data.storeNum} --only wanLatency,wanJitter,wanLoss,wanMos,wanHealthscore,wanLinkState,wanAlarms\` for pass/warn/error breakdowns.*`;
|
const store = storeNum || data.storeNum;
|
||||||
|
out += `\n*WAN metrics pulled at ${new Date().toLocaleTimeString()} from Prisma SD-WAN. ` +
|
||||||
|
`Use \`/voicediag ${store} --only wanLatency,wanJitter,wanLoss,wanMos,wanHealthscore,wanLinkState,wanAlarms\` for link-probe breakdowns, ` +
|
||||||
|
`or \`/voicediag ${store} --only wanAppRtpMos,wanAppRtpLoss,wanAppRtpJitter\` for per-app RTP quality.*`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return out.trim();
|
return out.trim();
|
||||||
|
|
@ -157,6 +174,85 @@ function humanWindowLabel(minutes) {
|
||||||
return `${minutes}m`;
|
return `${minutes}m`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render the "Voice Traffic Quality" section — real DPI measurements
|
||||||
|
* on actual RTP frames rather than synthetic link probes. Shows
|
||||||
|
* worst-window / avg / % of samples degraded per metric, with icons
|
||||||
|
* keyed off the WORST-window value (that's the signal that actually
|
||||||
|
* correlates with "the operator got a bad-calls ticket").
|
||||||
|
*
|
||||||
|
* Section header includes the configured voice-app name (e.g.
|
||||||
|
* "Webex_Calling_RTP", "rtp-base") so the operator knows which DPI
|
||||||
|
* signature was measured — different apps have very different
|
||||||
|
* traffic patterns and one may show issues the other misses.
|
||||||
|
*
|
||||||
|
* @param {object} appAudio ctx.sdwanData.appAudio
|
||||||
|
* @param {object} window ctx.sdwanData.window
|
||||||
|
*/
|
||||||
|
function renderAppAudioSection(appAudio, window) {
|
||||||
|
if (!appAudio) return '';
|
||||||
|
const { mos, loss, jitter, bandwidth, appName, detailsUrl } = appAudio;
|
||||||
|
// If literally every metric is null/empty, skip entirely — usually
|
||||||
|
// means the tenant has the env var set but this site has no RTP
|
||||||
|
// traffic yet.
|
||||||
|
const anyData = [mos, loss, jitter, bandwidth].some(
|
||||||
|
(s) => s && s.validSamples > 0,
|
||||||
|
);
|
||||||
|
if (!anyData) return '';
|
||||||
|
|
||||||
|
const winLabel = window?.minutes ? humanWindowLabel(window.minutes) : '7d';
|
||||||
|
// Deep-link into the Strata Cloud Manager "Application Path
|
||||||
|
// Details" page for this site+app. Only rendered when a URL was
|
||||||
|
// computed (detailsUrl is null if either id is missing).
|
||||||
|
const linkSuffix = detailsUrl
|
||||||
|
? ` — [View in Prisma UI](${detailsUrl})`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
let out = `\n**Voice Traffic Quality (${appName || 'voice'}, last ${winLabel})**${linkSuffix}\n`;
|
||||||
|
out += `_Measured on real RTP frames via Prisma DPI — worst-window matters more than avg for voice._\n`;
|
||||||
|
|
||||||
|
const mosLine = fmtAppMetricLine('MOS', mos, '', 3.5, 4.0, /* lowIsBad */ true);
|
||||||
|
const lossLine = fmtAppMetricLine('Loss', loss, '%', 15, 5, /* lowIsBad */ false);
|
||||||
|
const jitterLine = fmtAppMetricLine('Jitter', jitter, 'ms', 50, 30, /* lowIsBad */ false);
|
||||||
|
const bwLine = fmtAppMetricLine('Bandwidth', bandwidth, 'Mbps', null, null, /* lowIsBad */ false);
|
||||||
|
|
||||||
|
for (const line of [mosLine, lossLine, jitterLine, bwLine].filter(Boolean)) {
|
||||||
|
out += `- ${line}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row of the voice-traffic-quality section.
|
||||||
|
*
|
||||||
|
* @param {string} label
|
||||||
|
* @param {object|null} summary {avg, min, max, samples, validSamples, ...}
|
||||||
|
* @param {string} unit display unit ("%", "ms", "", "Mbps")
|
||||||
|
* @param {number|null} errThresh
|
||||||
|
* @param {number|null} warnThresh
|
||||||
|
* @param {boolean} lowIsBad true for MOS (lower is worse)
|
||||||
|
*/
|
||||||
|
function fmtAppMetricLine(label, summary, unit, errThresh, warnThresh, lowIsBad) {
|
||||||
|
if (!summary) return '';
|
||||||
|
if (summary.validSamples === 0) {
|
||||||
|
return `❓ **${label}** — no data (${summary.samples || 0} samples, all null)`;
|
||||||
|
}
|
||||||
|
const worst = lowIsBad ? summary.min : summary.max;
|
||||||
|
let icon;
|
||||||
|
if (errThresh == null && warnThresh == null) {
|
||||||
|
icon = 'ℹ️'; // no threshold → context-only (bandwidth)
|
||||||
|
} else {
|
||||||
|
icon = iconFromNumeric(worst, errThresh, warnThresh, lowIsBad);
|
||||||
|
}
|
||||||
|
const worstLabel = lowIsBad ? 'worst (lowest)' : 'worst';
|
||||||
|
return (
|
||||||
|
`${icon} **${label}** — ${worstLabel}: **${worst}${unit}** • ` +
|
||||||
|
`avg: ${summary.avg}${unit} • ${summary.validSamples}/${summary.samples} samples ` +
|
||||||
|
`@ ${summary.interval || '5min'}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function renderOneLink(link, t) {
|
function renderOneLink(link, t) {
|
||||||
const nameLabel = link.interfaceName || link.interfaceId;
|
const nameLabel = link.interfaceName || link.interfaceId;
|
||||||
const transport = link.transportType ? ` [${link.transportType}]` : '';
|
const transport = link.transportType ? ` [${link.transportType}]` : '';
|
||||||
|
|
|
||||||
|
|
@ -12,19 +12,24 @@ objects, and hands them to the renderer + adaptive-card layer in
|
||||||
/voicediag <storeNum> default: hides OK checks, posts fixable cards
|
/voicediag <storeNum> default: hides OK checks, posts fixable cards
|
||||||
/voicediag <storeNum> detail include OK checks + expand every details block
|
/voicediag <storeNum> detail include OK checks + expand every details block
|
||||||
/voicediag <storeNum> --only dnd,callForwarding
|
/voicediag <storeNum> --only dnd,callForwarding
|
||||||
/voicediag <storeNum> --window 15m narrow the WAN look-back (default 24h)
|
/voicediag <storeNum> --window 24h narrow the WAN look-back (default 7d)
|
||||||
/voicediag list-checks enumerate every registered check + its scope
|
/voicediag list-checks enumerate every registered check + its scope
|
||||||
```
|
```
|
||||||
|
|
||||||
HTTP path: `GET /voicediag?storeNum=<n>[&detailed=true][&only=dnd,callWaiting][&window=15m]`.
|
HTTP path: `GET /voicediag?storeNum=<n>[&detailed=true][&only=dnd,callWaiting][&window=15m]`.
|
||||||
|
|
||||||
`--window` accepts `Nm` / `Nh` / `Nd` shorthand (e.g. `15m`, `1h`, `6h`,
|
`--window` accepts `Nm` / `Nh` / `Nd` shorthand (e.g. `15m`, `1h`, `6h`,
|
||||||
`24h`, `1d`) or a bare integer of minutes. Applies to the WAN
|
`24h`, `1d`, `7d`) or a bare integer of minutes; hard-capped at 7d.
|
||||||
healthscore + LQM (per-path latency/jitter/loss/MOS) fetches. Alarms
|
Applies to the WAN healthscore + LQM (per-path latency/jitter/loss/MOS)
|
||||||
are floored at 60m regardless — sub-hour alarm queries are usually
|
+ per-app voice-DPI fetches + alarms. Alarms are floored at 60m
|
||||||
too noisy to be actionable. Global default is
|
regardless — sub-hour alarm queries are usually too noisy to be
|
||||||
`WAN_STANDARD_WINDOW_MINUTES` (env, defaults to 1440 / 24h). The
|
actionable. Global default is `WAN_STANDARD_WINDOW_MINUTES` (env,
|
||||||
same default is used by the `/phonestatus` WAN follow-up (no CLI
|
defaults to `10080` / 7 days). The 7d default was chosen because
|
||||||
|
per-app DPI metrics (Webex_Calling_RTP etc.) only get datapoints when
|
||||||
|
calls actually happen — sporadic stores need a wider window for
|
||||||
|
worst-window statistics to be meaningful. For live-incident triage
|
||||||
|
where you want a fresh snapshot, pass `--window 1h` or `--window 24h`.
|
||||||
|
The same default is used by the `/phonestatus` WAN follow-up (no CLI
|
||||||
override on that surface — set the env if you want a different value
|
override on that surface — set the env if you want a different value
|
||||||
globally).
|
globally).
|
||||||
HTTP callers get the markdown snapshot only — remediation cards are
|
HTTP callers get the markdown snapshot only — remediation cards are
|
||||||
|
|
@ -147,6 +152,9 @@ in parallel, and normalises to a stable shape the checks + the
|
||||||
| `wanJitter` | <= `WAN_STANDARD_JITTER_WARN_MS` (default 30ms) | warn > 30ms, **error** > 50ms | RFC 3550 jitter-buffer envelope |
|
| `wanJitter` | <= `WAN_STANDARD_JITTER_WARN_MS` (default 30ms) | warn > 30ms, **error** > 50ms | RFC 3550 jitter-buffer envelope |
|
||||||
| `wanLoss` | <= `WAN_STANDARD_LOSS_WARN_PCT` (default 1%) | warn > 1%, **error** > 3% | G.711 PLC tolerance |
|
| `wanLoss` | <= `WAN_STANDARD_LOSS_WARN_PCT` (default 1%) | warn > 1%, **error** > 3% | G.711 PLC tolerance |
|
||||||
| `wanMos` | >= `WAN_STANDARD_MOS_WARN` (default 4.0) | warn < 4.0, **error** < 3.5 | ITU-T P.800 MOS scale. LOW is bad — the accessor flips the comparison. |
|
| `wanMos` | >= `WAN_STANDARD_MOS_WARN` (default 4.0) | warn < 4.0, **error** < 3.5 | ITU-T P.800 MOS scale. LOW is bad — the accessor flips the comparison. |
|
||||||
|
| `wanAppRtpMos` | Worst 5-min window >= `WAN_STANDARD_APP_MOS_WARN` (default 4.0) | warn < 4.0, **error** < 3.5 | Real DPI measurement on the configured voice app's RTP frames (e.g. `Webex_Calling_RTP`, `rtp-base`). Graded against the WORST window in the series — catches transient degradation link-probe averages hide. Skipped when `PRISMA_APP_ID_VOICE` unset. |
|
||||||
|
| `wanAppRtpLoss` | Worst 5-min window <= `WAN_STANDARD_APP_LOSS_WARN_PCT` (default 5%) | warn > 5%, **error** > 15% | DPI packet-loss for the same voice app. Same feature gate. |
|
||||||
|
| `wanAppRtpJitter` | Worst 5-min window <= `WAN_STANDARD_APP_JITTER_WARN_MS` (default 30ms) | warn > 30ms, **error** > 50ms | DPI jitter for the same voice app. Same feature gate. |
|
||||||
| `wanAlarms` | Zero critical + zero major alarms in the last hour | warn on major, **error** on critical | Minor alarms are info-only. Pass-through of Prisma severity. |
|
| `wanAlarms` | Zero critical + zero major alarms in the last hour | warn on major, **error** on critical | Minor alarms are info-only. Pass-through of Prisma severity. |
|
||||||
|
|
||||||
Environment overrides (all optional — defaults match the standards
|
Environment overrides (all optional — defaults match the standards
|
||||||
|
|
@ -164,7 +172,16 @@ above):
|
||||||
| `WAN_STANDARD_MOS_ERROR` | `3.5` | MOS error threshold |
|
| `WAN_STANDARD_MOS_ERROR` | `3.5` | MOS error threshold |
|
||||||
| `WAN_STANDARD_HEALTHSCORE_WARN` | `80` | Site healthscore warn threshold (0-100) |
|
| `WAN_STANDARD_HEALTHSCORE_WARN` | `80` | Site healthscore warn threshold (0-100) |
|
||||||
| `WAN_STANDARD_HEALTHSCORE_ERROR` | `60` | Site healthscore error threshold |
|
| `WAN_STANDARD_HEALTHSCORE_ERROR` | `60` | Site healthscore error threshold |
|
||||||
| `WAN_STANDARD_ENABLED` | `true` | Global kill-switch for the WAN bucket. `false` silences all 8 WAN checks while a Prisma cleanup / integration validation is in flight. |
|
| `WAN_STANDARD_APP_MOS_WARN` | `4.0` | Per-app worst-window MOS warn threshold |
|
||||||
|
| `WAN_STANDARD_APP_MOS_ERROR` | `3.5` | Per-app worst-window MOS error threshold |
|
||||||
|
| `WAN_STANDARD_APP_LOSS_WARN_PCT` | `5` | Per-app worst-window packet-loss warn threshold, % |
|
||||||
|
| `WAN_STANDARD_APP_LOSS_ERROR_PCT` | `15` | Per-app worst-window packet-loss error threshold, % |
|
||||||
|
| `WAN_STANDARD_APP_JITTER_WARN_MS` | `30` | Per-app worst-window jitter warn threshold, ms |
|
||||||
|
| `WAN_STANDARD_APP_JITTER_ERROR_MS` | `50` | Per-app worst-window jitter error threshold, ms |
|
||||||
|
| `PRISMA_APP_ID_VOICE` | _(unset)_ | Numeric Prisma app id for the tenant's voice app. Recommended: `Webex_Calling_RTP` for Webex Calling shops (excludes non-Webex UDP noise), otherwise `rtp-base` for the generic RTP signature. When set, /phonestatus + /voicediag pull real DPI voice-quality metrics from Prisma's Application Path Details endpoint. Discover with `npm run prisma:probe -- appdefs webex` (or `rtp`) or from the Prisma UI URL. Leave unset to skip the extra API calls. |
|
||||||
|
| `PRISMA_APP_NAME_VOICE` | `voice` | Display label for the app configured above. Set to the exact Prisma UI label (e.g. `Webex_Calling_RTP`) so section headers and check messages cross-reference cleanly. |
|
||||||
|
| `PRISMA_APP_ID_RTP_BASE` | _(unset)_ | **Deprecated.** Backwards-compat fallback for the original release. Still honored if set and `PRISMA_APP_ID_VOICE` is unset (with a one-time deprecation warning in the log); display name defaults to `rtp-base` in that case. Rename to `PRISMA_APP_ID_VOICE` at your convenience. |
|
||||||
|
| `WAN_STANDARD_ENABLED` | `true` | Global kill-switch for the WAN bucket. `false` silences all 11 WAN checks while a Prisma cleanup / integration validation is in flight. |
|
||||||
|
|
||||||
Prisma credentials and auth-mode selection live under the
|
Prisma credentials and auth-mode selection live under the
|
||||||
`Palo Alto Prisma SD-WAN` block in `.env.example` (SASE OAuth 2.0
|
`Palo Alto Prisma SD-WAN` block in `.env.example` (SASE OAuth 2.0
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,9 @@ import { wanLatencyCheck } from './wan/wanLatency.js';
|
||||||
import { wanJitterCheck } from './wan/wanJitter.js';
|
import { wanJitterCheck } from './wan/wanJitter.js';
|
||||||
import { wanLossCheck } from './wan/wanLoss.js';
|
import { wanLossCheck } from './wan/wanLoss.js';
|
||||||
import { wanMosCheck } from './wan/wanMos.js';
|
import { wanMosCheck } from './wan/wanMos.js';
|
||||||
|
import { wanAppRtpMosCheck } from './wan/wanAppRtpMos.js';
|
||||||
|
import { wanAppRtpLossCheck } from './wan/wanAppRtpLoss.js';
|
||||||
|
import { wanAppRtpJitterCheck } from './wan/wanAppRtpJitter.js';
|
||||||
import { wanAlarmsCheck } from './wan/wanAlarms.js';
|
import { wanAlarmsCheck } from './wan/wanAlarms.js';
|
||||||
|
|
||||||
// Order: user-facing feature signals first (things an operator can
|
// Order: user-facing feature signals first (things an operator can
|
||||||
|
|
@ -77,6 +80,15 @@ export const CHECKS = [
|
||||||
wanJitterCheck,
|
wanJitterCheck,
|
||||||
wanLossCheck,
|
wanLossCheck,
|
||||||
wanMosCheck,
|
wanMosCheck,
|
||||||
|
// Per-app (DPI) audio-quality checks. These are graded against the
|
||||||
|
// WORST 5-min window in the series rather than the average, so they
|
||||||
|
// catch transient degradation the LQM per-link averages smooth over.
|
||||||
|
// Feature-gated on the PRISMA_APP_ID_VOICE env var (with
|
||||||
|
// PRISMA_APP_ID_RTP_BASE honored for backwards compat) — skipped
|
||||||
|
// with an actionable message when not configured.
|
||||||
|
wanAppRtpMosCheck,
|
||||||
|
wanAppRtpLossCheck,
|
||||||
|
wanAppRtpJitterCheck,
|
||||||
wanAlarmsCheck,
|
wanAlarmsCheck,
|
||||||
phoneOnlineCheck,
|
phoneOnlineCheck,
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,28 @@ export const getMosError = () => num('WAN_STANDARD_MOS_ERROR', 3.
|
||||||
export const getHealthscoreWarn = () => num('WAN_STANDARD_HEALTHSCORE_WARN', 80);
|
export const getHealthscoreWarn = () => num('WAN_STANDARD_HEALTHSCORE_WARN', 80);
|
||||||
export const getHealthscoreError = () => num('WAN_STANDARD_HEALTHSCORE_ERROR', 60);
|
export const getHealthscoreError = () => num('WAN_STANDARD_HEALTHSCORE_ERROR', 60);
|
||||||
|
|
||||||
|
// ─── Per-application (DPI) audio-quality thresholds ──────────────
|
||||||
|
// Applied against the WORST-window value in the series (not the avg)
|
||||||
|
// since these signals come as 288-point 24h time series and the whole
|
||||||
|
// point of app-quality checks is to catch transient degradation the
|
||||||
|
// LQM link-probe average smooths away. Defaults match the ITU-T
|
||||||
|
// references but are DELIBERATELY less strict than the link-probe
|
||||||
|
// thresholds because false positives from a single bad 5-min window
|
||||||
|
// would drown the operator in noise. Tune via env if your traffic
|
||||||
|
// mix is more sensitive.
|
||||||
|
//
|
||||||
|
// MOS (worst-window, lower is worse — call quality drops)
|
||||||
|
export const getAppMosWarn = () => num('WAN_STANDARD_APP_MOS_WARN', 4.0);
|
||||||
|
export const getAppMosError = () => num('WAN_STANDARD_APP_MOS_ERROR', 3.5);
|
||||||
|
|
||||||
|
// Loss (worst-window percentage, higher is worse)
|
||||||
|
export const getAppLossWarnPct = () => num('WAN_STANDARD_APP_LOSS_WARN_PCT', 5);
|
||||||
|
export const getAppLossErrorPct = () => num('WAN_STANDARD_APP_LOSS_ERROR_PCT', 15);
|
||||||
|
|
||||||
|
// Jitter (worst-window ms, higher is worse)
|
||||||
|
export const getAppJitterWarnMs = () => num('WAN_STANDARD_APP_JITTER_WARN_MS', 30);
|
||||||
|
export const getAppJitterErrorMs = () => num('WAN_STANDARD_APP_JITTER_ERROR_MS', 50);
|
||||||
|
|
||||||
// ─── Utilities used by multiple checks ────────────────────────────
|
// ─── Utilities used by multiple checks ────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -97,6 +119,242 @@ export function labelForLink(link) {
|
||||||
return `${name}${el}${tx}`;
|
return `${name}${el}${tx}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Accessor for the per-app audio-quality summary computed in
|
||||||
|
* sdwanEnrichment.js. Returns a small envelope so the caller can
|
||||||
|
* distinguish four states cleanly:
|
||||||
|
*
|
||||||
|
* {configured:false, summary:null, fetchError:null}
|
||||||
|
* Voice-app env not set → checks return "not configured"
|
||||||
|
* skipped. See sdwanEnrichment.js:resolveVoiceAppConfig() for
|
||||||
|
* the env-var contract (PRISMA_APP_ID_VOICE, or
|
||||||
|
* PRISMA_APP_ID_RTP_BASE for backwards compat). This is a
|
||||||
|
* config decision, not a transient failure.
|
||||||
|
*
|
||||||
|
* {configured:true, summary:null, fetchError:'<msg>'}
|
||||||
|
* Env set + Prisma fetch failed (429 storm, timeout, schema
|
||||||
|
* drift). Different UX from "not configured" — surfaces the
|
||||||
|
* real error so the operator can retry or fix.
|
||||||
|
*
|
||||||
|
* {configured:true, summary:{...},fetchError:null}
|
||||||
|
* Fetch succeeded but Prisma returned zero valid samples
|
||||||
|
* (no matching RTP traffic in the window). Skipped with a
|
||||||
|
* "no traffic seen" message.
|
||||||
|
*
|
||||||
|
* {configured:true, summary:{...validSamples>0...}, fetchError:null}
|
||||||
|
* Green path — check grades against the summary.
|
||||||
|
*
|
||||||
|
* @param {object} ctx
|
||||||
|
* @param {'mos'|'loss'|'jitter'|'bandwidth'} which
|
||||||
|
*/
|
||||||
|
export function getAppAudio(ctx, which) {
|
||||||
|
const container = ctx?.sdwanData?.appAudio || null;
|
||||||
|
if (!container) {
|
||||||
|
return {
|
||||||
|
configured: false,
|
||||||
|
summary: null,
|
||||||
|
fetchError: null,
|
||||||
|
appName: null,
|
||||||
|
detailsUrl: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Error-scope names are app-agnostic ("app.voice.<metric>") so the
|
||||||
|
// check doesn't have to know which specific voice app is
|
||||||
|
// configured — it just asks "was the fetch for MOS on the voice
|
||||||
|
// app OK?" See sdwanEnrichment.js:recordFailure() for the writer.
|
||||||
|
const scope = `app.voice.${which}`;
|
||||||
|
const errRec = Array.isArray(ctx?.sdwanData?.errors)
|
||||||
|
? ctx.sdwanData.errors.find((e) => e?.scope === scope)
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
configured: true,
|
||||||
|
summary: container[which] || null,
|
||||||
|
fetchError: errRec?.message || null,
|
||||||
|
appName: container.appName || 'voice',
|
||||||
|
// SCM UI deep-link computed once in sdwanEnrichment.js. Threaded
|
||||||
|
// through here so the check details carry it into the voiceDiag
|
||||||
|
// renderer as a "View in Prisma UI" link.
|
||||||
|
detailsUrl: container.detailsUrl || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Humanize the raw Prisma unit string into a display-friendly suffix.
|
||||||
|
* The API returns strings like `"percentage"` and `"milliseconds"`
|
||||||
|
* which read like error messages when concatenated into a UI value
|
||||||
|
* (`"11.83percentage"`). This normalizes to the units an operator
|
||||||
|
* actually recognizes.
|
||||||
|
*
|
||||||
|
* Exposed so both the renderer and the check message use the same
|
||||||
|
* mapping — divergence there would produce mismatched icons vs
|
||||||
|
* numbers.
|
||||||
|
*/
|
||||||
|
export function humanizeMetricUnit(raw) {
|
||||||
|
if (!raw) return '';
|
||||||
|
const lc = String(raw).toLowerCase();
|
||||||
|
if (lc === 'percentage' || lc === 'percent') return '%';
|
||||||
|
if (lc === 'milliseconds' || lc === 'ms') return 'ms';
|
||||||
|
if (lc === 'count' || lc === 'gauge' || lc === 'score') return '';
|
||||||
|
if (lc === 'kbps') return 'kbps';
|
||||||
|
if (lc === 'mbps') return 'Mbps';
|
||||||
|
if (lc === 'bps') return 'bps';
|
||||||
|
if (lc === 'seconds' || lc === 's') return 's';
|
||||||
|
// Anything else — return as-is, prefixed with a space so it's
|
||||||
|
// visually separated from the number (`"12 something"` reads
|
||||||
|
// better than `"12something"`).
|
||||||
|
return ` ${raw}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Grade a per-app audio quality summary against warn/error thresholds.
|
||||||
|
* Handles the four cases the envelope from `getAppAudio()` can be in:
|
||||||
|
* not-configured, fetch-failed, no-traffic, gradeable.
|
||||||
|
*
|
||||||
|
* @param {object} args
|
||||||
|
* @param {object} args.audio envelope from getAppAudio():
|
||||||
|
* {configured, summary, fetchError, appName}
|
||||||
|
* @param {string} args.label display label ("audio MOS", "audio loss", …)
|
||||||
|
* @param {string} args.unit display unit ("%", "ms", "")
|
||||||
|
* @param {number} args.warnThresh
|
||||||
|
* @param {number} args.errorThresh
|
||||||
|
* @param {boolean} args.lowIsBad true → warn/error when the
|
||||||
|
* WORST-window VALUE FALLS BELOW
|
||||||
|
* the threshold (MOS). false →
|
||||||
|
* warn/error when the WORST-window
|
||||||
|
* value RISES ABOVE (loss, jitter).
|
||||||
|
* @param {string} [args.standardLabel] optional inline "warn > X"
|
||||||
|
* description embedded in the message
|
||||||
|
* @returns {object} CheckResult
|
||||||
|
*/
|
||||||
|
export function evaluateAppAudioMetric({
|
||||||
|
audio, label, unit, warnThresh, errorThresh, lowIsBad = false,
|
||||||
|
standardLabel,
|
||||||
|
}) {
|
||||||
|
const appName = audio?.appName || 'the voice app';
|
||||||
|
|
||||||
|
if (!audio || audio.configured === false) {
|
||||||
|
return {
|
||||||
|
status: 'skipped',
|
||||||
|
message:
|
||||||
|
`Per-app ${label} unavailable — set PRISMA_APP_ID_VOICE in the ` +
|
||||||
|
`environment (with optional PRISMA_APP_NAME_VOICE for the display ` +
|
||||||
|
`name) to enable per-application DPI metrics.`,
|
||||||
|
details: null,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audio.fetchError) {
|
||||||
|
return {
|
||||||
|
status: 'skipped',
|
||||||
|
message:
|
||||||
|
`Per-app ${label} for ${appName}: Prisma fetch failed — ${audio.fetchError}. ` +
|
||||||
|
`Common cause: parallel-request rate limiting (429). Retry in ~30 seconds.`,
|
||||||
|
details: { fetchError: audio.fetchError, appName },
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const summary = audio.summary;
|
||||||
|
if (!summary) {
|
||||||
|
return {
|
||||||
|
status: 'skipped',
|
||||||
|
message:
|
||||||
|
`Per-app ${label} for ${appName}: no summary produced (Prisma returned an ` +
|
||||||
|
`empty or malformed response). Check paloalto:metrics logs for the raw payload.`,
|
||||||
|
details: null,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (!summary.validSamples || summary.validSamples === 0) {
|
||||||
|
return {
|
||||||
|
status: 'skipped',
|
||||||
|
message:
|
||||||
|
`Per-app ${label} for ${appName}: Prisma returned ` +
|
||||||
|
`${summary.samples || 0} datapoint(s), all null — no voice traffic ` +
|
||||||
|
`matched by DPI in the window.`,
|
||||||
|
details: summary,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const worst = lowIsBad ? summary.min : summary.max;
|
||||||
|
const grade = (v) => {
|
||||||
|
if (v == null) return 'skipped';
|
||||||
|
if (lowIsBad) {
|
||||||
|
if (v < errorThresh) return 'error';
|
||||||
|
if (v < warnThresh) return 'warn';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
if (v > errorThresh) return 'error';
|
||||||
|
if (v > warnThresh) return 'warn';
|
||||||
|
return 'ok';
|
||||||
|
};
|
||||||
|
|
||||||
|
const status = grade(worst);
|
||||||
|
|
||||||
|
// Percent of samples that were in warn or error range — the "how
|
||||||
|
// often was it bad" number that turns the worst-window verdict into
|
||||||
|
// an actionable "N% of your voice calls saw this" statistic.
|
||||||
|
const bad = summary.values.filter(
|
||||||
|
(v) => grade(v) === 'warn' || grade(v) === 'error',
|
||||||
|
).length;
|
||||||
|
const badPct = summary.validSamples > 0
|
||||||
|
? Math.round((bad / summary.validSamples) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
// Store both the raw API unit (for debugging) and the humanized
|
||||||
|
// display unit (what the renderer + message use). Overriding the
|
||||||
|
// spread unit with the check-arg unit lets each check pick a nice
|
||||||
|
// suffix ('%', 'ms') without depending on the API's verbose one.
|
||||||
|
const details = {
|
||||||
|
...summary,
|
||||||
|
rawUnit: summary.unit || null,
|
||||||
|
unit,
|
||||||
|
warnThresh,
|
||||||
|
errorThresh,
|
||||||
|
standardLabel: standardLabel || null,
|
||||||
|
worst,
|
||||||
|
badSampleCount: bad,
|
||||||
|
badSamplePct: badPct,
|
||||||
|
appName,
|
||||||
|
// SCM UI deep-link. The renderer surfaces this as a
|
||||||
|
// "View in Prisma UI" markdown link so an operator can jump from
|
||||||
|
// the diag output straight to the Prisma UI dashboard for the
|
||||||
|
// exact site+app combination without hunting for it.
|
||||||
|
detailsUrl: audio.detailsUrl || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const worstStr = worst == null ? '—' : `${worst}${unit}`;
|
||||||
|
const avgStr = summary.avg == null ? '—' : `${summary.avg}${unit}`;
|
||||||
|
const cmp = lowIsBad ? '<' : '>';
|
||||||
|
const threshStr = status === 'error'
|
||||||
|
? `${cmp} ${errorThresh}${unit}`
|
||||||
|
: `${cmp} ${warnThresh}${unit}`;
|
||||||
|
|
||||||
|
if (status === 'error' || status === 'warn') {
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
message:
|
||||||
|
`Actual voice traffic (${appName}) worst-window ${label}: ${worstStr} ${threshStr} ` +
|
||||||
|
`(avg ${avgStr} over ${summary.validSamples} × ${summary.interval || '5min'} samples). ` +
|
||||||
|
`${badPct}% of samples were ${status === 'error' ? 'in the error range' : 'past warn'}. ` +
|
||||||
|
`This is measured on real RTP traffic — the WAN link probes may still look fine.`,
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'ok',
|
||||||
|
message:
|
||||||
|
`Actual voice traffic (${appName}) ${label} within range: worst ${worstStr}, ` +
|
||||||
|
`avg ${avgStr} across ${summary.validSamples} × ${summary.interval || '5min'} samples.`,
|
||||||
|
details,
|
||||||
|
remediation: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Evaluate a numeric per-link metric against warn/error thresholds
|
* Evaluate a numeric per-link metric against warn/error thresholds
|
||||||
* across every path, returning a CheckResult. Worst path drives
|
* across every path, returning a CheckResult. Worst path drives
|
||||||
|
|
|
||||||
57
services/voiceDiag/checks/wan/wanAppRtpJitter.js
Normal file
57
services/voiceDiag/checks/wan/wanAppRtpJitter.js
Normal file
|
|
@ -0,0 +1,57 @@
|
||||||
|
// src/services/voiceDiag/checks/wan/wanAppRtpJitter.js
|
||||||
|
//
|
||||||
|
// Per-application audio jitter — measured on ACTUAL RTP voice
|
||||||
|
// traffic via Prisma DPI. Metric name on the v2.6 monitor/metrics
|
||||||
|
// endpoint is `AppPerfUDPAudioJitter` (unit: milliseconds), takes
|
||||||
|
// filter.direction="Ingress" AND filter.path_type=[all].
|
||||||
|
//
|
||||||
|
// See wanAppRtpMos.js for the app-selection contract — the check is
|
||||||
|
// app-agnostic; the tenant picks which voice app to grade via env.
|
||||||
|
//
|
||||||
|
// Grades against the WORST-window jitter — same reasoning as the
|
||||||
|
// MOS + loss checks: a brief burst that ruins in-flight calls is
|
||||||
|
// exactly what needs surfacing.
|
||||||
|
//
|
||||||
|
// Skipped when the voice-app env is not configured.
|
||||||
|
|
||||||
|
import {
|
||||||
|
maybeSkippedByKillSwitch,
|
||||||
|
getAppAudio,
|
||||||
|
evaluateAppAudioMetric,
|
||||||
|
getAppJitterWarnMs,
|
||||||
|
getAppJitterErrorMs,
|
||||||
|
} from './_helpers.js';
|
||||||
|
|
||||||
|
export const WAN_APP_RTP_JITTER_STANDARDS = Object.freeze({
|
||||||
|
maxWarnMs: 30,
|
||||||
|
maxErrorMs: 50,
|
||||||
|
unit: 'ms',
|
||||||
|
reference: 'RFC 3550 (jitter buffer tolerance)',
|
||||||
|
gradeAgainst: 'worst-window',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const wanAppRtpJitterCheck = {
|
||||||
|
id: 'wanAppRtpJitter',
|
||||||
|
label: 'SD-WAN Voice Traffic Jitter (worst window)',
|
||||||
|
requires: ['sdwanSite'],
|
||||||
|
scope: null,
|
||||||
|
standards: WAN_APP_RTP_JITTER_STANDARDS,
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const skip = maybeSkippedByKillSwitch(wanAppRtpJitterCheck);
|
||||||
|
if (skip) return skip;
|
||||||
|
|
||||||
|
const audio = getAppAudio(ctx, 'jitter');
|
||||||
|
const warnThresh = getAppJitterWarnMs();
|
||||||
|
const errorThresh = getAppJitterErrorMs();
|
||||||
|
return evaluateAppAudioMetric({
|
||||||
|
audio,
|
||||||
|
label: 'jitter',
|
||||||
|
unit: 'ms',
|
||||||
|
warnThresh,
|
||||||
|
errorThresh,
|
||||||
|
lowIsBad: false,
|
||||||
|
standardLabel: `warn > ${warnThresh}ms, error > ${errorThresh}ms`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
58
services/voiceDiag/checks/wan/wanAppRtpLoss.js
Normal file
58
services/voiceDiag/checks/wan/wanAppRtpLoss.js
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
// src/services/voiceDiag/checks/wan/wanAppRtpLoss.js
|
||||||
|
//
|
||||||
|
// Per-application audio packet loss — measured on ACTUAL RTP voice
|
||||||
|
// traffic via Prisma DPI. Metric name on the v2.6 monitor/metrics
|
||||||
|
// endpoint is `AppPerfUDPAudioPacketLoss` (unit: percentage), takes
|
||||||
|
// filter.direction="Ingress" AND filter.path_type=[all].
|
||||||
|
//
|
||||||
|
// See wanAppRtpMos.js for the app-selection contract — the check is
|
||||||
|
// app-agnostic; the tenant picks which voice app to grade via env.
|
||||||
|
//
|
||||||
|
// Grades against the WORST-window loss in the series. A 5-minute
|
||||||
|
// burst of 20% loss makes calls unusable during that window even
|
||||||
|
// if the 24h avg is 2% — always show the operator the worst-case
|
||||||
|
// number so they know a bad experience is real, not aggregated away.
|
||||||
|
//
|
||||||
|
// Skipped when the voice-app env is not configured.
|
||||||
|
|
||||||
|
import {
|
||||||
|
maybeSkippedByKillSwitch,
|
||||||
|
getAppAudio,
|
||||||
|
evaluateAppAudioMetric,
|
||||||
|
getAppLossWarnPct,
|
||||||
|
getAppLossErrorPct,
|
||||||
|
} from './_helpers.js';
|
||||||
|
|
||||||
|
export const WAN_APP_RTP_LOSS_STANDARDS = Object.freeze({
|
||||||
|
maxWarnPct: 5,
|
||||||
|
maxErrorPct: 15,
|
||||||
|
unit: '%',
|
||||||
|
reference: 'ITU-T G.113 (voice loss tolerance)',
|
||||||
|
gradeAgainst: 'worst-window',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const wanAppRtpLossCheck = {
|
||||||
|
id: 'wanAppRtpLoss',
|
||||||
|
label: 'SD-WAN Voice Traffic Packet Loss (worst window)',
|
||||||
|
requires: ['sdwanSite'],
|
||||||
|
scope: null,
|
||||||
|
standards: WAN_APP_RTP_LOSS_STANDARDS,
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const skip = maybeSkippedByKillSwitch(wanAppRtpLossCheck);
|
||||||
|
if (skip) return skip;
|
||||||
|
|
||||||
|
const audio = getAppAudio(ctx, 'loss');
|
||||||
|
const warnThresh = getAppLossWarnPct();
|
||||||
|
const errorThresh = getAppLossErrorPct();
|
||||||
|
return evaluateAppAudioMetric({
|
||||||
|
audio,
|
||||||
|
label: 'packet loss',
|
||||||
|
unit: '%',
|
||||||
|
warnThresh,
|
||||||
|
errorThresh,
|
||||||
|
lowIsBad: false,
|
||||||
|
standardLabel: `warn > ${warnThresh}%, error > ${errorThresh}%`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
65
services/voiceDiag/checks/wan/wanAppRtpMos.js
Normal file
65
services/voiceDiag/checks/wan/wanAppRtpMos.js
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
// src/services/voiceDiag/checks/wan/wanAppRtpMos.js
|
||||||
|
//
|
||||||
|
// Per-application audio MOS — measured on ACTUAL RTP voice traffic
|
||||||
|
// via Prisma DPI, not on synthetic link probes. Metric name on the
|
||||||
|
// v2.6 monitor/metrics endpoint is `AppAudioMos` (unit: count),
|
||||||
|
// requires filter.direction="Ingress".
|
||||||
|
//
|
||||||
|
// Which app is measured depends on the tenant's configured voice
|
||||||
|
// application (PRISMA_APP_ID_VOICE env). Common choices are
|
||||||
|
// `Webex_Calling_RTP` (recommended for Webex Calling shops — the
|
||||||
|
// Webex-specific DPI signature excludes noise from other UDP
|
||||||
|
// traffic that pollutes generic RTP), `rtp-base`, `MS_Teams_RTP`,
|
||||||
|
// etc. The check is app-agnostic; the app name is threaded through
|
||||||
|
// the message + details so operators know which app was graded.
|
||||||
|
//
|
||||||
|
// Grades against the WORST-window MOS in the series. A store that
|
||||||
|
// averages 4.0 MOS over 24h but dipped to 1.85 for several 5-min
|
||||||
|
// windows had unusable calls in those windows — the average smooths
|
||||||
|
// that away and the LQM link-probe average smooths it away twice.
|
||||||
|
// This check is the escape hatch for that failure mode.
|
||||||
|
//
|
||||||
|
// Skipped when the voice-app env is not configured (feature-gated
|
||||||
|
// to keep the extra Prisma API load opt-in per tenant).
|
||||||
|
|
||||||
|
import {
|
||||||
|
maybeSkippedByKillSwitch,
|
||||||
|
getAppAudio,
|
||||||
|
evaluateAppAudioMetric,
|
||||||
|
getAppMosWarn,
|
||||||
|
getAppMosError,
|
||||||
|
} from './_helpers.js';
|
||||||
|
|
||||||
|
export const WAN_APP_RTP_MOS_STANDARDS = Object.freeze({
|
||||||
|
minWarnMos: 4.0,
|
||||||
|
minErrorMos: 3.5,
|
||||||
|
unit: 'count',
|
||||||
|
reference: 'ITU-T P.800 (MOS)',
|
||||||
|
gradeAgainst: 'worst-window',
|
||||||
|
});
|
||||||
|
|
||||||
|
export const wanAppRtpMosCheck = {
|
||||||
|
id: 'wanAppRtpMos',
|
||||||
|
label: 'SD-WAN Voice Traffic MOS (worst window)',
|
||||||
|
requires: ['sdwanSite'],
|
||||||
|
scope: null,
|
||||||
|
standards: WAN_APP_RTP_MOS_STANDARDS,
|
||||||
|
|
||||||
|
async run(ctx) {
|
||||||
|
const skip = maybeSkippedByKillSwitch(wanAppRtpMosCheck);
|
||||||
|
if (skip) return skip;
|
||||||
|
|
||||||
|
const audio = getAppAudio(ctx, 'mos');
|
||||||
|
const warnThresh = getAppMosWarn();
|
||||||
|
const errorThresh = getAppMosError();
|
||||||
|
return evaluateAppAudioMetric({
|
||||||
|
audio,
|
||||||
|
label: 'MOS',
|
||||||
|
unit: '',
|
||||||
|
warnThresh,
|
||||||
|
errorThresh,
|
||||||
|
lowIsBad: true,
|
||||||
|
standardLabel: `warn < ${warnThresh}, error < ${errorThresh}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
@ -176,4 +176,6 @@ test('humanizeWindow: canonical values', () => {
|
||||||
assert.equal(humanizeWindow(360), '6h');
|
assert.equal(humanizeWindow(360), '6h');
|
||||||
assert.equal(humanizeWindow(1440), '1d');
|
assert.equal(humanizeWindow(1440), '1d');
|
||||||
assert.equal(humanizeWindow(2880), '2d');
|
assert.equal(humanizeWindow(2880), '2d');
|
||||||
|
assert.equal(humanizeWindow(10080), '7d',
|
||||||
|
'the new default WAN window must round-trip cleanly');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,13 @@ import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
|
|
||||||
import { paloAltoAxios, getPrismaToken, _resetPrismaAuthCache } from '../integrations/paloalto/client.js';
|
import {
|
||||||
|
paloAltoAxios,
|
||||||
|
getPrismaToken,
|
||||||
|
_resetPrismaAuthCache,
|
||||||
|
_resetPrismaConcurrency,
|
||||||
|
_prismaInflightCount,
|
||||||
|
} from '../integrations/paloalto/client.js';
|
||||||
|
|
||||||
const FAKE_TOKEN_1 = 'token-round-1';
|
const FAKE_TOKEN_1 = 'token-round-1';
|
||||||
const FAKE_TOKEN_2 = 'token-round-2';
|
const FAKE_TOKEN_2 = 'token-round-2';
|
||||||
|
|
@ -54,9 +60,16 @@ async function makeFakeAuthServer(handlers = {}) {
|
||||||
}
|
}
|
||||||
const handler = explicitHandler;
|
const handler = explicitHandler;
|
||||||
if (handler) {
|
if (handler) {
|
||||||
const result = handler({ req, body });
|
// Handlers may return a Promise so a test can pause the
|
||||||
|
// response until an external gate resolves — used by the
|
||||||
|
// concurrency-limiter test to hold in-flight requests open.
|
||||||
|
Promise.resolve(handler({ req, body })).then((result) => {
|
||||||
res.writeHead(result.status || 200, { 'Content-Type': 'application/json' });
|
res.writeHead(result.status || 200, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify(result.body || {}));
|
res.end(JSON.stringify(result.body || {}));
|
||||||
|
}).catch((err) => {
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end(JSON.stringify({ err: err.message }));
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
res.writeHead(404);
|
res.writeHead(404);
|
||||||
|
|
@ -324,3 +337,114 @@ test('client: axios instance retries once after 401 with forced refresh', async
|
||||||
_resetPrismaAuthCache();
|
_resetPrismaAuthCache();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Concurrency limiter ───────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Regression guard for the /voicediag rate-limit cascade: without
|
||||||
|
// the semaphore, fanning out 10 metric calls in parallel would blow
|
||||||
|
// past Prisma's per-second cap and 429 half of them. The cap has to
|
||||||
|
// bound concurrent in-flight AXIOS calls at MAX_INFLIGHT (default 3)
|
||||||
|
// AND has to release on both success + failure paths so 429 retries
|
||||||
|
// don't stall queued callers.
|
||||||
|
//
|
||||||
|
// Testing strategy: the server introduces a small artificial delay
|
||||||
|
// so we can observe the peak concurrent request count. All
|
||||||
|
// responses complete before the test's finally block, so no socket
|
||||||
|
// races on server-close.
|
||||||
|
|
||||||
|
test('client: concurrency limiter caps peak concurrent server-side requests at MAX_INFLIGHT=3', async () => {
|
||||||
|
_resetPrismaAuthCache();
|
||||||
|
_resetPrismaConcurrency();
|
||||||
|
clearAllEnv();
|
||||||
|
|
||||||
|
let peakConcurrent = 0;
|
||||||
|
let concurrent = 0;
|
||||||
|
|
||||||
|
const fake = await makeFakeAuthServer({
|
||||||
|
'POST /oauth2/access_token': () => ({
|
||||||
|
status: 200, body: { access_token: FAKE_TOKEN_1, expires_in: 900 },
|
||||||
|
}),
|
||||||
|
// ~80ms delay per response — long enough to let the client fill
|
||||||
|
// its 3-permit window, short enough that all 8 requests finish
|
||||||
|
// in ~250-400ms without complex gating logic.
|
||||||
|
'GET /slow': async () => {
|
||||||
|
concurrent += 1;
|
||||||
|
if (concurrent > peakConcurrent) peakConcurrent = concurrent;
|
||||||
|
await new Promise((r) => setTimeout(r, 80));
|
||||||
|
concurrent -= 1;
|
||||||
|
return { status: 200, body: { ok: true } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
process.env.PRISMA_AUTH_MODE = 'sase';
|
||||||
|
process.env.PRISMA_SASE_BASE_URL = fake.baseUrl;
|
||||||
|
process.env.PRISMA_AUTH_URL = `${fake.baseUrl}/oauth2/access_token`;
|
||||||
|
process.env.PRISMA_CLIENT_ID = 'id';
|
||||||
|
process.env.PRISMA_CLIENT_SECRET = 'secret';
|
||||||
|
process.env.PRISMA_TSG_ID = 'tsg';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 8 }, () => paloAltoAxios.get('/slow')),
|
||||||
|
);
|
||||||
|
assert.equal(results.length, 8);
|
||||||
|
assert.ok(
|
||||||
|
peakConcurrent <= 3,
|
||||||
|
`peak concurrent server-side requests should be <= 3 (semaphore cap), was ${peakConcurrent}`,
|
||||||
|
);
|
||||||
|
// If the cap works, the first batch of 3 completes ~80ms in,
|
||||||
|
// and the next batch fills — peak MUST equal MAX_INFLIGHT under
|
||||||
|
// any realistic scheduling. If it's under 3, the semaphore is
|
||||||
|
// too tight or requests are strictly serial (which would also
|
||||||
|
// be a bug).
|
||||||
|
assert.ok(peakConcurrent >= 2,
|
||||||
|
`expected the client to actually parallelize (peak >= 2), was ${peakConcurrent}`);
|
||||||
|
assert.equal(_prismaInflightCount(), 0,
|
||||||
|
'inflight counter must return to 0 after all requests finish');
|
||||||
|
} finally {
|
||||||
|
await fake.close();
|
||||||
|
clearAllEnv();
|
||||||
|
_resetPrismaAuthCache();
|
||||||
|
_resetPrismaConcurrency();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('client: concurrency permit released on 500 (failure path drains cleanly, no permit leak)', async () => {
|
||||||
|
_resetPrismaAuthCache();
|
||||||
|
_resetPrismaConcurrency();
|
||||||
|
clearAllEnv();
|
||||||
|
|
||||||
|
let apiCalls = 0;
|
||||||
|
const fake = await makeFakeAuthServer({
|
||||||
|
'POST /oauth2/access_token': () => ({
|
||||||
|
status: 200, body: { access_token: FAKE_TOKEN_1, expires_in: 900 },
|
||||||
|
}),
|
||||||
|
'GET /explode': () => { apiCalls += 1; return { status: 500, body: { err: 'boom' } }; },
|
||||||
|
});
|
||||||
|
process.env.PRISMA_AUTH_MODE = 'sase';
|
||||||
|
process.env.PRISMA_SASE_BASE_URL = fake.baseUrl;
|
||||||
|
process.env.PRISMA_AUTH_URL = `${fake.baseUrl}/oauth2/access_token`;
|
||||||
|
process.env.PRISMA_CLIENT_ID = 'id';
|
||||||
|
process.env.PRISMA_CLIENT_SECRET = 'secret';
|
||||||
|
process.env.PRISMA_TSG_ID = 'tsg';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 6 requests that all 500. If the permit isn't released on the
|
||||||
|
// error path, the 4th onwards would hang forever — the outer
|
||||||
|
// 20s axios timeout would fire and the test would fail with
|
||||||
|
// timeout rather than clean rejections. All should reject
|
||||||
|
// cleanly and inflight must return to zero.
|
||||||
|
const results = await Promise.allSettled(
|
||||||
|
Array.from({ length: 6 }, () => paloAltoAxios.get('/explode')),
|
||||||
|
);
|
||||||
|
assert.equal(results.filter((r) => r.status === 'rejected').length, 6,
|
||||||
|
'all 6 requests should have rejected — none hung');
|
||||||
|
assert.equal(apiCalls, 6, 'all 6 reached the server (queue drained)');
|
||||||
|
assert.equal(_prismaInflightCount(), 0,
|
||||||
|
'inflight counter should return to 0 after failure — no permit leak');
|
||||||
|
} finally {
|
||||||
|
await fake.close();
|
||||||
|
clearAllEnv();
|
||||||
|
_resetPrismaAuthCache();
|
||||||
|
_resetPrismaConcurrency();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,9 @@ import http from 'node:http';
|
||||||
import {
|
import {
|
||||||
getHealthscore,
|
getHealthscore,
|
||||||
getLqmMetric,
|
getLqmMetric,
|
||||||
|
getAppMetric,
|
||||||
getAlarms,
|
getAlarms,
|
||||||
|
APP_METRIC_NAMES,
|
||||||
} from '../integrations/paloalto/metrics.js';
|
} from '../integrations/paloalto/metrics.js';
|
||||||
import { _resetPrismaAuthCache } from '../integrations/paloalto/client.js';
|
import { _resetPrismaAuthCache } from '../integrations/paloalto/client.js';
|
||||||
|
|
||||||
|
|
@ -329,3 +331,214 @@ test('getAlarms: uses events/query endpoint (not /monitor/alarms which 404s)', a
|
||||||
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Per-application metrics — body-shape regressions ──────────────
|
||||||
|
//
|
||||||
|
// Ground truth for these assertions comes from two HAR captures
|
||||||
|
// against a live tenant (site CG00127), both taken 2026-07-09:
|
||||||
|
// - /Users/McqueenJ/Downloads/rtp-base-metricCG00127.har
|
||||||
|
// (app id 15932000365560116)
|
||||||
|
// - /Users/McqueenJ/Downloads/webex-base-metricCG00127.har
|
||||||
|
// (app id 1708539371717015196 → Webex_Calling_RTP)
|
||||||
|
//
|
||||||
|
// The metric names, units, and filter shapes are IDENTICAL across
|
||||||
|
// voice apps — only filter.app differs. That's why the check code is
|
||||||
|
// app-agnostic and these tests use `<APP_ID>` as a filler rather than
|
||||||
|
// pinning one app.
|
||||||
|
//
|
||||||
|
// Every non-obvious body key here is one Prisma silently rejects if
|
||||||
|
// wrong. Do NOT relax these without a fresh HAR — see
|
||||||
|
// scripts/prismaProbe.js `try-shapes app-audio-*` to re-discover.
|
||||||
|
|
||||||
|
test('getAppMetric(mos): AppAudioMos requires direction="Ingress" and OMITS filter.path_type', async () => {
|
||||||
|
_resetPrismaAuthCache();
|
||||||
|
let seenBody = null;
|
||||||
|
const fake = await makeFakePrisma({
|
||||||
|
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
|
||||||
|
seenBody = body;
|
||||||
|
return { body: { metrics: [{ series: [{ name: 'AppAudioMos', unit: 'count', data: [{ datapoints: [] }] }] }] } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setSaseEnv(fake.baseUrl);
|
||||||
|
try {
|
||||||
|
await getAppMetric('site-CG127', '15932000365560116', 'mos', 60);
|
||||||
|
assert.ok(seenBody, 'request reached fake server');
|
||||||
|
|
||||||
|
// The winning metric name from the HAR — anything else 400s
|
||||||
|
// with METRIC_NOT_SUPPORTED, so pin it.
|
||||||
|
assert.deepEqual(seenBody.metrics, [{
|
||||||
|
name: 'AppAudioMos', statistics: ['average'], unit: 'count',
|
||||||
|
}]);
|
||||||
|
|
||||||
|
// AppAudioMos is the ONE audio metric that does NOT take
|
||||||
|
// filter.path_type. If we add it, the schema check flips from
|
||||||
|
// "no matching metric" to "unsupported filter" — different bug
|
||||||
|
// symptom, but same net "returns nothing" outcome.
|
||||||
|
assert.ok(!('path_type' in seenBody.filter),
|
||||||
|
'AppAudioMos must NOT include filter.path_type (unlike AppPerfUDP*)');
|
||||||
|
|
||||||
|
// Direction is REQUIRED. Without it Prisma returns 400.
|
||||||
|
assert.equal(seenBody.filter.direction, 'Ingress',
|
||||||
|
'AppAudioMos requires filter.direction="Ingress" (audio quality is what you RECEIVE)');
|
||||||
|
|
||||||
|
assert.deepEqual(seenBody.filter.site, ['site-CG127']);
|
||||||
|
assert.deepEqual(seenBody.filter.app, ['15932000365560116'],
|
||||||
|
'app id must be sent as a STRING inside an ARRAY, not a bare int or a bare string');
|
||||||
|
|
||||||
|
// Empty view {} — the single-metric per-app queries use this
|
||||||
|
// rather than {summary:true} or {individual:'app'}.
|
||||||
|
assert.deepEqual(seenBody.view, {});
|
||||||
|
|
||||||
|
assert.ok(seenBody.start_time && seenBody.end_time,
|
||||||
|
'start_time + end_time both required — v2.6 monitor/metrics rejects without them');
|
||||||
|
} finally {
|
||||||
|
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAppMetric(loss): AppPerfUDPAudioPacketLoss includes ALL 5 path_types', async () => {
|
||||||
|
_resetPrismaAuthCache();
|
||||||
|
let seenBody = null;
|
||||||
|
const fake = await makeFakePrisma({
|
||||||
|
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
|
||||||
|
seenBody = body;
|
||||||
|
return { body: { metrics: [{ series: [{ name: 'AppPerfUDPAudioPacketLoss', unit: 'percentage', data: [{ datapoints: [] }] }] }] } };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setSaseEnv(fake.baseUrl);
|
||||||
|
try {
|
||||||
|
await getAppMetric('site-CG127', '15932000365560116', 'loss', 60);
|
||||||
|
assert.equal(seenBody.metrics[0].name, 'AppPerfUDPAudioPacketLoss');
|
||||||
|
// Case-sensitive — 'Percentage' returned METRIC_UNIT_NOT_SUPPORTED
|
||||||
|
// in earlier probe runs. Lowercase-p is a live constraint.
|
||||||
|
assert.equal(seenBody.metrics[0].unit, 'percentage');
|
||||||
|
assert.equal(seenBody.filter.direction, 'Ingress');
|
||||||
|
|
||||||
|
const paths = seenBody.filter.path_type;
|
||||||
|
assert.ok(Array.isArray(paths));
|
||||||
|
for (const t of ['DirectInternet', 'VPN', 'PrivateWAN', 'PrivateVPN', 'ServiceLink']) {
|
||||||
|
assert.ok(paths.includes(t), `path_type must include "${t}" (Prisma UI passes all 5)`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAppMetric(jitter): AppPerfUDPAudioJitter uses milliseconds unit', async () => {
|
||||||
|
_resetPrismaAuthCache();
|
||||||
|
let seenBody = null;
|
||||||
|
const fake = await makeFakePrisma({
|
||||||
|
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
|
||||||
|
seenBody = body;
|
||||||
|
return { body: {} };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setSaseEnv(fake.baseUrl);
|
||||||
|
try {
|
||||||
|
await getAppMetric('site-CG127', '15932000365560116', 'jitter', 60);
|
||||||
|
assert.equal(seenBody.metrics[0].name, 'AppPerfUDPAudioJitter');
|
||||||
|
assert.equal(seenBody.metrics[0].unit, 'milliseconds');
|
||||||
|
assert.equal(seenBody.filter.direction, 'Ingress');
|
||||||
|
assert.ok(Array.isArray(seenBody.filter.path_type));
|
||||||
|
} finally {
|
||||||
|
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAppMetric(bandwidth): AppPerfUDPAudioBandwidth has NO direction (bidirectional aggregate)', async () => {
|
||||||
|
_resetPrismaAuthCache();
|
||||||
|
let seenBody = null;
|
||||||
|
const fake = await makeFakePrisma({
|
||||||
|
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
|
||||||
|
seenBody = body;
|
||||||
|
return { body: {} };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setSaseEnv(fake.baseUrl);
|
||||||
|
try {
|
||||||
|
await getAppMetric('site-CG127', '15932000365560116', 'bandwidth', 60);
|
||||||
|
assert.equal(seenBody.metrics[0].name, 'AppPerfUDPAudioBandwidth');
|
||||||
|
assert.equal(seenBody.metrics[0].unit, 'Mbps');
|
||||||
|
assert.ok(!('direction' in seenBody.filter),
|
||||||
|
'bandwidth is a bidirectional aggregate — Prisma rejects filter.direction on this metric');
|
||||||
|
assert.ok(Array.isArray(seenBody.filter.path_type));
|
||||||
|
} finally {
|
||||||
|
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAppMetric: no-op with clean null return when siteId or appId missing', async () => {
|
||||||
|
// No network setup — if the wrapper tried to make a call, the
|
||||||
|
// client would fail to resolve the SASE URL and throw. A silent
|
||||||
|
// null keeps the enrichment pipeline resilient when a caller
|
||||||
|
// forgets to configure PRISMA_APP_ID_VOICE (or the legacy
|
||||||
|
// PRISMA_APP_ID_RTP_BASE).
|
||||||
|
const r1 = await getAppMetric(null, '1708539371717015196', 'mos');
|
||||||
|
const r2 = await getAppMetric('site-X', null, 'mos');
|
||||||
|
assert.equal(r1, null);
|
||||||
|
assert.equal(r2, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAppMetric: uses 5min interval for 24h window (NOT 1day — preserves 288-point time series)', async () => {
|
||||||
|
// The whole point of client-side aggregation is that we can
|
||||||
|
// extract WORST-window numbers from the series. If we ask for
|
||||||
|
// interval=1day on a 1440min window, Prisma returns 1-2
|
||||||
|
// aggregated datapoints and the min/max collapse to the same
|
||||||
|
// value as the avg — completely defeating the purpose. HAR
|
||||||
|
// confirms the Prisma UI uses 5min for the 24h view.
|
||||||
|
_resetPrismaAuthCache();
|
||||||
|
let seenBody = null;
|
||||||
|
const fake = await makeFakePrisma({
|
||||||
|
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
|
||||||
|
seenBody = body;
|
||||||
|
return { body: {} };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setSaseEnv(fake.baseUrl);
|
||||||
|
try {
|
||||||
|
await getAppMetric('site-CG127', '15932000365560116', 'mos', 1440);
|
||||||
|
assert.equal(seenBody.interval, '5min',
|
||||||
|
'1440-min window MUST use 5min interval (yields ~288 pts, matches HAR). ' +
|
||||||
|
'Falling back to 1day would collapse the series to 1-2 aggregated pts.');
|
||||||
|
} finally {
|
||||||
|
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getAppMetric: uses 1hour interval for 7d window (168 points — the new default)', async () => {
|
||||||
|
_resetPrismaAuthCache();
|
||||||
|
let seenBody = null;
|
||||||
|
const fake = await makeFakePrisma({
|
||||||
|
'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => {
|
||||||
|
seenBody = body;
|
||||||
|
return { body: {} };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
setSaseEnv(fake.baseUrl);
|
||||||
|
try {
|
||||||
|
// 7 days = 10080 min. This is now the default window (widened
|
||||||
|
// from 24h so sporadic Webex Calling stores get enough per-app
|
||||||
|
// samples). 5min buckets would be 2016 points per metric × 4
|
||||||
|
// metrics = 8064 numbers per site request. Snap to 1hour (168
|
||||||
|
// pts) to keep payloads reasonable while still catching
|
||||||
|
// worst-hour degradation.
|
||||||
|
await getAppMetric('site-CG127', '1708539371717015196', 'mos', 10080);
|
||||||
|
assert.equal(seenBody.interval, '1hour');
|
||||||
|
} finally {
|
||||||
|
await fake.close(); _resetPrismaAuthCache(); clearEnv();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('APP_METRIC_NAMES: registry exposes the HAR-confirmed winners', () => {
|
||||||
|
// Cheap "someone renamed the constant" check. The four keys are
|
||||||
|
// the API surface that sdwanEnrichment.js iterates over — a rename
|
||||||
|
// would silently drop that metric from the enrichment payload
|
||||||
|
// (parseAppSeries would return null and the check would go to
|
||||||
|
// skipped rather than error).
|
||||||
|
assert.equal(APP_METRIC_NAMES.mos.name, 'AppAudioMos');
|
||||||
|
assert.equal(APP_METRIC_NAMES.loss.name, 'AppPerfUDPAudioPacketLoss');
|
||||||
|
assert.equal(APP_METRIC_NAMES.jitter.name, 'AppPerfUDPAudioJitter');
|
||||||
|
assert.equal(APP_METRIC_NAMES.bandwidth.name, 'AppPerfUDPAudioBandwidth');
|
||||||
|
assert.equal(APP_METRIC_NAMES.mos.lowIsBad, true,
|
||||||
|
'MOS grades against worst-window minimum — lowIsBad must be true');
|
||||||
|
});
|
||||||
|
|
|
||||||
83
tests/paloalto.urls.test.js
Normal file
83
tests/paloalto.urls.test.js
Normal file
|
|
@ -0,0 +1,83 @@
|
||||||
|
// tests/paloalto.urls.test.js
|
||||||
|
//
|
||||||
|
// Pure unit tests for integrations/paloalto/urls.js — no HTTP, no
|
||||||
|
// mocks. The builders here feed deep-link markdown into user-facing
|
||||||
|
// output, so any silent format change would show up as broken links
|
||||||
|
// in production. The tests spell out both the exact URL shape and
|
||||||
|
// the boundary conditions (null ids, base-URL env override).
|
||||||
|
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
|
||||||
|
import {
|
||||||
|
getPrismaUiBaseUrl,
|
||||||
|
buildAppDetailsUrl,
|
||||||
|
} from '../integrations/paloalto/urls.js';
|
||||||
|
|
||||||
|
function withEnv(kvs, fn) {
|
||||||
|
const prev = {};
|
||||||
|
for (const k of Object.keys(kvs)) prev[k] = process.env[k];
|
||||||
|
try {
|
||||||
|
for (const [k, v] of Object.entries(kvs)) {
|
||||||
|
if (v === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = v;
|
||||||
|
}
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
for (const [k, v] of Object.entries(prev)) {
|
||||||
|
if (v === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = v;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('getPrismaUiBaseUrl: defaults to the production SCM hostname', () => {
|
||||||
|
withEnv({ PRISMA_UI_BASE_URL: undefined }, () => {
|
||||||
|
assert.equal(getPrismaUiBaseUrl(), 'https://stratacloudmanager.paloaltonetworks.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPrismaUiBaseUrl: PRISMA_UI_BASE_URL override is honored', () => {
|
||||||
|
withEnv({ PRISMA_UI_BASE_URL: 'https://scm.partner.example.com' }, () => {
|
||||||
|
assert.equal(getPrismaUiBaseUrl(), 'https://scm.partner.example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPrismaUiBaseUrl: trailing slashes are stripped so `${base}/x` concats cleanly', () => {
|
||||||
|
// A trailing slash would produce "//insights/…" which some routers
|
||||||
|
// treat as a different route from "/insights/…". Normalize.
|
||||||
|
withEnv({ PRISMA_UI_BASE_URL: 'https://scm.example.com///' }, () => {
|
||||||
|
assert.equal(getPrismaUiBaseUrl(), 'https://scm.example.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getPrismaUiBaseUrl: empty / whitespace env falls back to default', () => {
|
||||||
|
withEnv({ PRISMA_UI_BASE_URL: ' ' }, () => {
|
||||||
|
assert.equal(getPrismaUiBaseUrl(), 'https://stratacloudmanager.paloaltonetworks.com');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildAppDetailsUrl: builds the exact SCM Application Path Details URL', () => {
|
||||||
|
// Ground truth from the user (CG00127 + rtp-base app).
|
||||||
|
withEnv({ PRISMA_UI_BASE_URL: undefined }, () => {
|
||||||
|
const url = buildAppDetailsUrl('16190109915660160', '15932000365560116');
|
||||||
|
assert.equal(
|
||||||
|
url,
|
||||||
|
'https://stratacloudmanager.paloaltonetworks.com/insights/operational/sdwan-applications/15932000365560116/site/16190109915660160/details',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildAppDetailsUrl: null / missing ids → null (renderers no-op on falsy)', () => {
|
||||||
|
assert.equal(buildAppDetailsUrl(null, '15932000365560116'), null);
|
||||||
|
assert.equal(buildAppDetailsUrl('16190109915660160', null), null);
|
||||||
|
assert.equal(buildAppDetailsUrl(null, null), null);
|
||||||
|
assert.equal(buildAppDetailsUrl('', ''), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('buildAppDetailsUrl: numeric ids coerced to strings in the URL (Prisma ids are int-like)', () => {
|
||||||
|
withEnv({ PRISMA_UI_BASE_URL: 'https://scm.example.com' }, () => {
|
||||||
|
const url = buildAppDetailsUrl(16190109915660160n, 15932000365560116n);
|
||||||
|
assert.match(url, /15932000365560116\/site\/16190109915660160\/details$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -168,3 +168,114 @@ test('renderer: healthscore missing → "n/a" with unknown icon', () => {
|
||||||
const md = renderWanDiagnosticsMarkdown(baseData({ healthscore: null }), { storeNum: '782' });
|
const md = renderWanDiagnosticsMarkdown(baseData({ healthscore: null }), { storeNum: '782' });
|
||||||
assert.match(md, /Healthscore.*n\/a/);
|
assert.match(md, /Healthscore.*n\/a/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Per-app "Voice Traffic Quality" section ────────────────────────
|
||||||
|
|
||||||
|
function appAudioSummary({ values, unit = '', interval = '5min' } = {}) {
|
||||||
|
const nums = values.filter((v) => Number.isFinite(v));
|
||||||
|
const avg = nums.length ? Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 100) / 100 : null;
|
||||||
|
return {
|
||||||
|
unit, interval,
|
||||||
|
samples: values.length, validSamples: nums.length,
|
||||||
|
avg,
|
||||||
|
min: nums.length ? Math.min(...nums) : null,
|
||||||
|
max: nums.length ? Math.max(...nums) : null,
|
||||||
|
p95: nums.length ? nums.sort((a, b) => a - b)[Math.floor(0.95 * nums.length)] || nums[nums.length - 1] : null,
|
||||||
|
values,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('renderer: renders Voice Traffic Quality section when appAudio present with samples', () => {
|
||||||
|
// Uses Webex_Calling_RTP as the configured app — the renderer must
|
||||||
|
// pick up appName from the fixture, not hardcode "rtp-base".
|
||||||
|
const data = baseData({
|
||||||
|
appAudio: {
|
||||||
|
appId: '1708539371717015196', appName: 'Webex_Calling_RTP',
|
||||||
|
mos: appAudioSummary({ values: [3.55, 3.55, 1.85, 4.41], unit: 'count' }),
|
||||||
|
loss: appAudioSummary({ values: [22, 0, 69, 0], unit: '%' }),
|
||||||
|
jitter: appAudioSummary({ values: [0, 0, 0], unit: 'ms' }),
|
||||||
|
bandwidth: appAudioSummary({ values: [0.5, 0.6, 0.4], unit: 'Mbps' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '127' });
|
||||||
|
assert.match(md, /Voice Traffic Quality \(Webex_Calling_RTP/,
|
||||||
|
'section header must reflect the tenant-configured app name');
|
||||||
|
assert.match(md, /DPI/, 'callout for "real DPI measurement" surfaces');
|
||||||
|
// Worst-window numbers must appear (that's the whole point).
|
||||||
|
assert.match(md, /1\.85/, 'worst MOS surfaced');
|
||||||
|
assert.match(md, /69%/, 'worst loss surfaced');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: still renders correctly with legacy "rtp-base" appName (backwards-compat)', () => {
|
||||||
|
// Tenants that stayed on the legacy PRISMA_APP_ID_RTP_BASE env get
|
||||||
|
// appName='rtp-base' from resolveVoiceAppConfig() — must render
|
||||||
|
// identically to any other app.
|
||||||
|
const data = baseData({
|
||||||
|
appAudio: {
|
||||||
|
appId: '15932000365560116', appName: 'rtp-base',
|
||||||
|
mos: appAudioSummary({ values: [4.35], unit: 'count' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '127' });
|
||||||
|
assert.match(md, /Voice Traffic Quality \(rtp-base/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: omits Voice Traffic Quality section entirely when appAudio is null', () => {
|
||||||
|
const md = renderWanDiagnosticsMarkdown(baseData(), { storeNum: '782' });
|
||||||
|
assert.doesNotMatch(md, /Voice Traffic Quality/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: omits Voice Traffic Quality section when appAudio has no valid samples', () => {
|
||||||
|
const empty = appAudioSummary({ values: [null, null] });
|
||||||
|
const data = baseData({
|
||||||
|
appAudio: { appId: 'x', appName: 'Webex_Calling_RTP', mos: empty, loss: empty, jitter: empty, bandwidth: empty },
|
||||||
|
});
|
||||||
|
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '782' });
|
||||||
|
assert.doesNotMatch(md, /Voice Traffic Quality/,
|
||||||
|
'no data across every metric → skip the header entirely rather than show empty rows');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: MOS row uses worst-lowest label (not worst-highest)', () => {
|
||||||
|
const data = baseData({
|
||||||
|
appAudio: {
|
||||||
|
appId: 'x', appName: 'Webex_Calling_RTP',
|
||||||
|
mos: appAudioSummary({ values: [4.4, 4.3, 3.9], unit: '' }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '782' });
|
||||||
|
assert.match(md, /worst \(lowest\)/, 'MOS is lowIsBad — must say "lowest" not "highest"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: "View in Prisma UI" deep link renders when appAudio.detailsUrl is present', () => {
|
||||||
|
const data = baseData({
|
||||||
|
appAudio: {
|
||||||
|
appId: '1708539371717015196', appName: 'Webex_Calling_RTP',
|
||||||
|
detailsUrl: 'https://stratacloudmanager.paloaltonetworks.com/insights/operational/sdwan-applications/1708539371717015196/site/16190109915660160/details',
|
||||||
|
mos: appAudioSummary({ values: [4.35] }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '127' });
|
||||||
|
assert.match(
|
||||||
|
md,
|
||||||
|
/\[View in Prisma UI\]\(https:\/\/stratacloudmanager\.paloaltonetworks\.com\/insights\/operational\/sdwan-applications\/1708539371717015196\/site\/16190109915660160\/details\)/,
|
||||||
|
'deep link must be rendered as a markdown link inside the section header',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: no deep link when appAudio.detailsUrl is missing (defensive)', () => {
|
||||||
|
const data = baseData({
|
||||||
|
appAudio: {
|
||||||
|
appId: 'x', appName: 'Webex_Calling_RTP',
|
||||||
|
mos: appAudioSummary({ values: [4.35] }),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const md = renderWanDiagnosticsMarkdown(data, { storeNum: '127' });
|
||||||
|
assert.doesNotMatch(md, /View in Prisma UI/,
|
||||||
|
'link line should be omitted entirely when the URL is null');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: footer mentions the per-app --only shortcut', () => {
|
||||||
|
const md = renderWanDiagnosticsMarkdown(baseData(), { storeNum: '782', footer: true });
|
||||||
|
assert.match(md, /wanAppRtpMos,wanAppRtpLoss,wanAppRtpJitter/,
|
||||||
|
'footer should point operators at the per-app checks by id');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,9 @@ import {
|
||||||
parseHealthscore,
|
parseHealthscore,
|
||||||
buildLinkRows,
|
buildLinkRows,
|
||||||
parseAlarms,
|
parseAlarms,
|
||||||
|
summarizeAppSeries,
|
||||||
|
resolveVoiceAppConfig,
|
||||||
|
_resetVoiceAppDeprecationFlag,
|
||||||
} from '../services/enrichment/sdwanEnrichment.js';
|
} from '../services/enrichment/sdwanEnrichment.js';
|
||||||
import { _resetSitesCache } from '../integrations/paloalto/sites.js';
|
import { _resetSitesCache } from '../integrations/paloalto/sites.js';
|
||||||
import { _resetPrismaAuthCache } from '../integrations/paloalto/client.js';
|
import { _resetPrismaAuthCache } from '../integrations/paloalto/client.js';
|
||||||
|
|
@ -768,26 +771,36 @@ test('collectSdwanForStore: reports the effective window on the returned payload
|
||||||
});
|
});
|
||||||
setupSaseEnv(fake.baseUrl);
|
setupSaseEnv(fake.baseUrl);
|
||||||
try {
|
try {
|
||||||
// Default (no opts): 24h — the shipping default optimised for
|
// Default (no opts): 7 days (10080 min). Widened from 24h because
|
||||||
// "was the site healthy today" over "is it healthy right now".
|
// per-app DPI metrics only get datapoints when calls actually
|
||||||
// Override via WAN_STANDARD_WINDOW_MINUTES or --window on /voicediag.
|
// happen — sporadic Webex Calling stores need a wider window for
|
||||||
|
// worst-window statistics to be meaningful. Override via
|
||||||
|
// WAN_STANDARD_WINDOW_MINUTES env or --window on /voicediag.
|
||||||
let data = await collectSdwanForStore(782);
|
let data = await collectSdwanForStore(782);
|
||||||
assert.equal(data.window?.minutes, 1440);
|
assert.equal(data.window?.minutes, 10080);
|
||||||
assert.equal(data.window?.alarmMinutes, 1440,
|
assert.equal(data.window?.alarmMinutes, 10080,
|
||||||
'alarms match the requested window when it is >= 60m');
|
'alarms match the requested window when it is >= 60m');
|
||||||
|
|
||||||
// Explicit 15m override → both windows go to 15/60 (alarm floor)
|
// Explicit 15m override → metric window narrows, alarms floor at 60m
|
||||||
data = await collectSdwanForStore(782, { windowMinutes: 15 });
|
data = await collectSdwanForStore(782, { windowMinutes: 15 });
|
||||||
assert.equal(data.window?.minutes, 15);
|
assert.equal(data.window?.minutes, 15);
|
||||||
assert.equal(data.window?.alarmMinutes, 60,
|
assert.equal(data.window?.alarmMinutes, 60,
|
||||||
'alarms floor at 60m even when the requested window is smaller');
|
'alarms floor at 60m even when the requested window is smaller');
|
||||||
|
|
||||||
// Out-of-range values are clamped to [1, 1440]
|
// Explicit 24h override — the common "live triage" case
|
||||||
|
data = await collectSdwanForStore(782, { windowMinutes: 1440 });
|
||||||
|
assert.equal(data.window?.minutes, 1440,
|
||||||
|
'24h stays 24h — no forced upgrade to 7d');
|
||||||
|
assert.equal(data.window?.alarmMinutes, 1440);
|
||||||
|
|
||||||
|
// Out-of-range values are clamped to [1, 10080]
|
||||||
data = await collectSdwanForStore(782, { windowMinutes: 99999 });
|
data = await collectSdwanForStore(782, { windowMinutes: 99999 });
|
||||||
assert.equal(data.window?.minutes, 1440, 'capped at 24h');
|
assert.equal(data.window?.minutes, 10080,
|
||||||
|
'capped at 7d — Prisma downsamples per-app series past this to 1-day buckets');
|
||||||
|
|
||||||
data = await collectSdwanForStore(782, { windowMinutes: -5 });
|
data = await collectSdwanForStore(782, { windowMinutes: -5 });
|
||||||
assert.equal(data.window?.minutes, 1440, 'invalid → falls to env default (24h)');
|
assert.equal(data.window?.minutes, 10080,
|
||||||
|
'invalid → falls to env default (7d, was 24h before the 2026-07-09 widen)');
|
||||||
} finally {
|
} finally {
|
||||||
await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv();
|
await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv();
|
||||||
}
|
}
|
||||||
|
|
@ -845,3 +858,213 @@ test('collectSdwanForStore: preserves per-metric failure in errors[]', async ()
|
||||||
await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv();
|
await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Unit tests: summarizeAppSeries ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// App metrics come back as a real time series (usually 288 pts over
|
||||||
|
// 24h at 5-min intervals). The summarizer must extract worst-window
|
||||||
|
// stats reliably regardless of null values, empty series, and the
|
||||||
|
// v2.6 response shape's quirks.
|
||||||
|
|
||||||
|
test('summarizeAppSeries: nil / malformed inputs → null', () => {
|
||||||
|
assert.equal(summarizeAppSeries(null, 'X'), null);
|
||||||
|
assert.equal(summarizeAppSeries({}, 'X'), null);
|
||||||
|
assert.equal(summarizeAppSeries({ metrics: [] }, 'X'), null);
|
||||||
|
assert.equal(summarizeAppSeries({ metrics: [{ series: [] }] }, 'X'), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeAppSeries: HAR-shape MOS response → {avg,min,max,p95}', () => {
|
||||||
|
// Truncated version of the live rtp-base MOS response from
|
||||||
|
// rtp-base-metricCG00127.har (2026-07-09). The webex HAR
|
||||||
|
// (webex-base-metricCG00127.har) has an identical body shape —
|
||||||
|
// this single test covers both apps.
|
||||||
|
const resp = {
|
||||||
|
metrics: [{
|
||||||
|
series: [{
|
||||||
|
name: 'AppAudioMos',
|
||||||
|
unit: 'count',
|
||||||
|
interval: '5min',
|
||||||
|
data: [{
|
||||||
|
statistics: 'average',
|
||||||
|
datapoints: [
|
||||||
|
{ value: 3.5569644, time: 'T1' },
|
||||||
|
{ value: 3.55610356, time: 'T2' },
|
||||||
|
{ value: 3.92861172, time: 'T3' },
|
||||||
|
{ value: 1.8523214, time: 'T4' }, // worst window from the HAR
|
||||||
|
{ value: 4.4092858, time: 'T5' }, // best window
|
||||||
|
{ value: 4.0257409, time: 'T6' },
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
const s = summarizeAppSeries(resp, 'AppAudioMos');
|
||||||
|
assert.ok(s, 'summary produced');
|
||||||
|
assert.equal(s.samples, 6);
|
||||||
|
assert.equal(s.validSamples, 6);
|
||||||
|
assert.equal(s.unit, 'count');
|
||||||
|
assert.equal(s.interval, '5min');
|
||||||
|
assert.equal(s.min, 1.85, 'worst-window MOS surfaced (the whole reason for the check)');
|
||||||
|
assert.equal(s.max, 4.41);
|
||||||
|
// Avg ≈ 3.5548 → 3.55 after round2
|
||||||
|
assert.equal(s.avg, 3.55);
|
||||||
|
assert.ok(Array.isArray(s.values), 'raw values retained for downstream stats');
|
||||||
|
assert.equal(s.values.length, 6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeAppSeries: all-null datapoints → validSamples=0, avg/min/max null', () => {
|
||||||
|
const resp = {
|
||||||
|
metrics: [{ series: [{
|
||||||
|
name: 'AppPerfUDPAudioJitter', unit: 'milliseconds', interval: '5min',
|
||||||
|
data: [{ datapoints: [{ value: null, time: 'T1' }, { time: 'T2' }] }],
|
||||||
|
}] }],
|
||||||
|
};
|
||||||
|
const s = summarizeAppSeries(resp, 'AppPerfUDPAudioJitter');
|
||||||
|
assert.equal(s.samples, 2);
|
||||||
|
assert.equal(s.validSamples, 0);
|
||||||
|
assert.equal(s.avg, null);
|
||||||
|
assert.equal(s.min, null);
|
||||||
|
assert.equal(s.max, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('summarizeAppSeries: mixed null + numeric → nulls ignored in aggregates', () => {
|
||||||
|
const resp = {
|
||||||
|
metrics: [{ series: [{
|
||||||
|
name: 'AppPerfUDPAudioPacketLoss', unit: 'percentage', interval: '5min',
|
||||||
|
data: [{ datapoints: [
|
||||||
|
{ value: null, time: 'T1' },
|
||||||
|
{ value: 22, time: 'T2' },
|
||||||
|
{ value: 0, time: 'T3' },
|
||||||
|
{ value: null, time: 'T4' },
|
||||||
|
{ value: 5, time: 'T5' },
|
||||||
|
] }],
|
||||||
|
}] }],
|
||||||
|
};
|
||||||
|
const s = summarizeAppSeries(resp, 'AppPerfUDPAudioPacketLoss');
|
||||||
|
assert.equal(s.samples, 5);
|
||||||
|
assert.equal(s.validSamples, 3);
|
||||||
|
assert.equal(s.min, 0);
|
||||||
|
assert.equal(s.max, 22);
|
||||||
|
assert.equal(s.avg, 9, '(22 + 0 + 5) / 3 = 9');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── resolveVoiceAppConfig ──────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// Reads env vars at call time and returns {appId, appName} for the
|
||||||
|
// tenant-configured voice application. Two env vars are honored:
|
||||||
|
// - PRISMA_APP_ID_VOICE (canonical) + PRISMA_APP_NAME_VOICE (name)
|
||||||
|
// - PRISMA_APP_ID_RTP_BASE (legacy, backwards-compat only)
|
||||||
|
//
|
||||||
|
// Every test here restores the original env so parallel test files
|
||||||
|
// don't stomp each other, and clears the deprecation-log dedupe flag
|
||||||
|
// so the deprecation-warning test can observe fresh log behavior.
|
||||||
|
|
||||||
|
function withVoiceEnv(overrides, fn) {
|
||||||
|
const KEYS = ['PRISMA_APP_ID_VOICE', 'PRISMA_APP_ID_RTP_BASE', 'PRISMA_APP_NAME_VOICE'];
|
||||||
|
const saved = Object.fromEntries(KEYS.map((k) => [k, process.env[k]]));
|
||||||
|
try {
|
||||||
|
for (const k of KEYS) delete process.env[k];
|
||||||
|
for (const [k, v] of Object.entries(overrides)) {
|
||||||
|
if (v !== undefined) process.env[k] = v;
|
||||||
|
}
|
||||||
|
_resetVoiceAppDeprecationFlag();
|
||||||
|
return fn();
|
||||||
|
} finally {
|
||||||
|
for (const k of KEYS) {
|
||||||
|
if (saved[k] === undefined) delete process.env[k];
|
||||||
|
else process.env[k] = saved[k];
|
||||||
|
}
|
||||||
|
_resetVoiceAppDeprecationFlag();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test('resolveVoiceAppConfig: nothing set → {appId:null, appName:"voice"}', () => {
|
||||||
|
withVoiceEnv({}, () => {
|
||||||
|
const cfg = resolveVoiceAppConfig();
|
||||||
|
assert.equal(cfg.appId, null);
|
||||||
|
assert.equal(cfg.appName, 'voice');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveVoiceAppConfig: PRISMA_APP_ID_VOICE alone → id set, default name "voice"', () => {
|
||||||
|
withVoiceEnv({ PRISMA_APP_ID_VOICE: '1708539371717015196' }, () => {
|
||||||
|
const cfg = resolveVoiceAppConfig();
|
||||||
|
assert.equal(cfg.appId, '1708539371717015196');
|
||||||
|
assert.equal(cfg.appName, 'voice',
|
||||||
|
'unnamed config gets the generic "voice" label — tenants override with PRISMA_APP_NAME_VOICE');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveVoiceAppConfig: PRISMA_APP_NAME_VOICE overrides display label', () => {
|
||||||
|
// The recommended Webex Calling deployment.
|
||||||
|
withVoiceEnv({
|
||||||
|
PRISMA_APP_ID_VOICE: '1708539371717015196',
|
||||||
|
PRISMA_APP_NAME_VOICE: 'Webex_Calling_RTP',
|
||||||
|
}, () => {
|
||||||
|
const cfg = resolveVoiceAppConfig();
|
||||||
|
assert.equal(cfg.appId, '1708539371717015196');
|
||||||
|
assert.equal(cfg.appName, 'Webex_Calling_RTP');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveVoiceAppConfig: legacy PRISMA_APP_ID_RTP_BASE honored, appName defaults to "rtp-base"', () => {
|
||||||
|
// Backwards-compat path — existing deployments must keep working
|
||||||
|
// without any env changes. Display name defaults to "rtp-base" so
|
||||||
|
// dashboards/screenshots that reference the old label still match.
|
||||||
|
withVoiceEnv({ PRISMA_APP_ID_RTP_BASE: '15932000365560116' }, () => {
|
||||||
|
const cfg = resolveVoiceAppConfig();
|
||||||
|
assert.equal(cfg.appId, '15932000365560116');
|
||||||
|
assert.equal(cfg.appName, 'rtp-base');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveVoiceAppConfig: legacy id + explicit name override → uses both', () => {
|
||||||
|
withVoiceEnv({
|
||||||
|
PRISMA_APP_ID_RTP_BASE: '15932000365560116',
|
||||||
|
PRISMA_APP_NAME_VOICE: 'rtp',
|
||||||
|
}, () => {
|
||||||
|
const cfg = resolveVoiceAppConfig();
|
||||||
|
assert.equal(cfg.appId, '15932000365560116');
|
||||||
|
assert.equal(cfg.appName, 'rtp',
|
||||||
|
'explicit PRISMA_APP_NAME_VOICE wins over the legacy-default "rtp-base"');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveVoiceAppConfig: PRISMA_APP_ID_VOICE takes precedence over legacy PRISMA_APP_ID_RTP_BASE', () => {
|
||||||
|
// Migration scenario: operator set the new env alongside the old
|
||||||
|
// one before removing the old one. New value must win.
|
||||||
|
withVoiceEnv({
|
||||||
|
PRISMA_APP_ID_VOICE: '1708539371717015196',
|
||||||
|
PRISMA_APP_ID_RTP_BASE: '15932000365560116',
|
||||||
|
}, () => {
|
||||||
|
const cfg = resolveVoiceAppConfig();
|
||||||
|
assert.equal(cfg.appId, '1708539371717015196',
|
||||||
|
'canonical env var wins — legacy is only a fallback');
|
||||||
|
assert.equal(cfg.appName, 'voice',
|
||||||
|
'name default follows the new env, not the legacy one');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveVoiceAppConfig: whitespace-only values treated as unset', () => {
|
||||||
|
withVoiceEnv({
|
||||||
|
PRISMA_APP_ID_VOICE: ' ',
|
||||||
|
PRISMA_APP_ID_RTP_BASE: '\t\n',
|
||||||
|
PRISMA_APP_NAME_VOICE: ' ',
|
||||||
|
}, () => {
|
||||||
|
const cfg = resolveVoiceAppConfig();
|
||||||
|
assert.equal(cfg.appId, null);
|
||||||
|
assert.equal(cfg.appName, 'voice',
|
||||||
|
'whitespace-only PRISMA_APP_NAME_VOICE falls back to generic default');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('resolveVoiceAppConfig: values are trimmed (env vars often carry accidental whitespace)', () => {
|
||||||
|
withVoiceEnv({
|
||||||
|
PRISMA_APP_ID_VOICE: ' 1708539371717015196 ',
|
||||||
|
PRISMA_APP_NAME_VOICE: ' Webex_Calling_RTP ',
|
||||||
|
}, () => {
|
||||||
|
const cfg = resolveVoiceAppConfig();
|
||||||
|
assert.equal(cfg.appId, '1708539371717015196', 'id trimmed');
|
||||||
|
assert.equal(cfg.appName, 'Webex_Calling_RTP', 'name trimmed');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -524,6 +524,9 @@ test('parseWindowMinutes: unit-suffixed values (m/h/d) resolve to minutes', asyn
|
||||||
assert.equal(parseWindowMinutes('24h'), 1440);
|
assert.equal(parseWindowMinutes('24h'), 1440);
|
||||||
assert.equal(parseWindowMinutes('1d'), 1440);
|
assert.equal(parseWindowMinutes('1d'), 1440);
|
||||||
assert.equal(parseWindowMinutes('1day'), 1440);
|
assert.equal(parseWindowMinutes('1day'), 1440);
|
||||||
|
assert.equal(parseWindowMinutes('7d'), 10080,
|
||||||
|
'7d resolves to 10080 min — matches the default window and the hard cap');
|
||||||
|
assert.equal(parseWindowMinutes('7days'), 10080);
|
||||||
assert.equal(parseWindowMinutes('2 hours'), 120);
|
assert.equal(parseWindowMinutes('2 hours'), 120);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -531,6 +534,7 @@ test('parseWindowMinutes: bare integer treated as minutes', async () => {
|
||||||
const { parseWindowMinutes } = await import('../commands/voiceDiag.js');
|
const { parseWindowMinutes } = await import('../commands/voiceDiag.js');
|
||||||
assert.equal(parseWindowMinutes('30'), 30);
|
assert.equal(parseWindowMinutes('30'), 30);
|
||||||
assert.equal(parseWindowMinutes('1440'), 1440);
|
assert.equal(parseWindowMinutes('1440'), 1440);
|
||||||
|
assert.equal(parseWindowMinutes('10080'), 10080);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('parseWindowMinutes: empty / null → undefined (falls to env default)', async () => {
|
test('parseWindowMinutes: empty / null → undefined (falls to env default)', async () => {
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,11 @@ import { wanLatencyCheck } from '../services/voiceDiag/checks/wan/wanLatency.js'
|
||||||
import { wanJitterCheck } from '../services/voiceDiag/checks/wan/wanJitter.js';
|
import { wanJitterCheck } from '../services/voiceDiag/checks/wan/wanJitter.js';
|
||||||
import { wanLossCheck } from '../services/voiceDiag/checks/wan/wanLoss.js';
|
import { wanLossCheck } from '../services/voiceDiag/checks/wan/wanLoss.js';
|
||||||
import { wanMosCheck } from '../services/voiceDiag/checks/wan/wanMos.js';
|
import { wanMosCheck } from '../services/voiceDiag/checks/wan/wanMos.js';
|
||||||
|
import { wanAppRtpMosCheck } from '../services/voiceDiag/checks/wan/wanAppRtpMos.js';
|
||||||
|
import { wanAppRtpLossCheck } from '../services/voiceDiag/checks/wan/wanAppRtpLoss.js';
|
||||||
|
import { wanAppRtpJitterCheck } from '../services/voiceDiag/checks/wan/wanAppRtpJitter.js';
|
||||||
import { wanAlarmsCheck } from '../services/voiceDiag/checks/wan/wanAlarms.js';
|
import { wanAlarmsCheck } from '../services/voiceDiag/checks/wan/wanAlarms.js';
|
||||||
|
import { humanizeMetricUnit } from '../services/voiceDiag/checks/wan/_helpers.js';
|
||||||
import { CHECKS } from '../services/voiceDiag/checks/index.js';
|
import { CHECKS } from '../services/voiceDiag/checks/index.js';
|
||||||
|
|
||||||
// ─── Helpers ────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────
|
||||||
|
|
@ -393,7 +397,7 @@ test('wanAlarms: message reflects the effective alarm window (not hardcoded 1h)'
|
||||||
|
|
||||||
test('every WAN check exposes a standards object', () => {
|
test('every WAN check exposes a standards object', () => {
|
||||||
const wanChecks = CHECKS.filter((c) => c.id.startsWith('wan'));
|
const wanChecks = CHECKS.filter((c) => c.id.startsWith('wan'));
|
||||||
assert.equal(wanChecks.length, 8, 'expected 8 registered WAN checks');
|
assert.equal(wanChecks.length, 11, 'expected 11 registered WAN checks (8 link + 3 app-DPI)');
|
||||||
const missing = wanChecks.filter((c) => !c.standards || typeof c.standards !== 'object');
|
const missing = wanChecks.filter((c) => !c.standards || typeof c.standards !== 'object');
|
||||||
assert.deepEqual(missing.map((c) => c.id), []);
|
assert.deepEqual(missing.map((c) => c.id), []);
|
||||||
});
|
});
|
||||||
|
|
@ -409,9 +413,16 @@ test('WAN checks registered in the expected order after port bucket', () => {
|
||||||
'wanJitter',
|
'wanJitter',
|
||||||
'wanLoss',
|
'wanLoss',
|
||||||
'wanMos',
|
'wanMos',
|
||||||
|
// Per-app DPI checks land after the link-probe checks — the
|
||||||
|
// link-probe pass/fail is the coarse signal, then the per-app
|
||||||
|
// check refines it. This ordering shows up in the /voicediag
|
||||||
|
// output as "link is up + green" followed by "but actual RTP
|
||||||
|
// saw..." which reads naturally for an operator.
|
||||||
|
'wanAppRtpMos',
|
||||||
|
'wanAppRtpLoss',
|
||||||
|
'wanAppRtpJitter',
|
||||||
'wanAlarms',
|
'wanAlarms',
|
||||||
]);
|
]);
|
||||||
// WAN bucket sits after the port bucket and before phoneOnline.
|
|
||||||
const portEnabledIdx = ids.indexOf('portEnabled');
|
const portEnabledIdx = ids.indexOf('portEnabled');
|
||||||
const wanSiteIdx = ids.indexOf('wanSite');
|
const wanSiteIdx = ids.indexOf('wanSite');
|
||||||
const phoneOnlineIdx = ids.indexOf('phoneOnline');
|
const phoneOnlineIdx = ids.indexOf('phoneOnline');
|
||||||
|
|
@ -425,3 +436,253 @@ test('no WAN check declares a remediation (diagnostic-only)', () => {
|
||||||
assert.equal(c.remediations, undefined, `${c.id} should not expose remediations`);
|
assert.equal(c.remediations, undefined, `${c.id} should not expose remediations`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── wanAppRtp* (per-app DPI voice-quality checks) ──────────────────
|
||||||
|
//
|
||||||
|
// These grade against the WORST-window value in the series rather
|
||||||
|
// than the average — the whole point is to catch transient
|
||||||
|
// degradation the 24h link-probe average smooths away. Every case
|
||||||
|
// below spells out which value the check MUST grade against (min for
|
||||||
|
// MOS, max for loss/jitter) because if the accessor picks the wrong
|
||||||
|
// side of the summary a well-averaged store would silently pass
|
||||||
|
// while its calls sound terrible.
|
||||||
|
|
||||||
|
function mkAppCtx({ mos, loss, jitter, bandwidth } = {}) {
|
||||||
|
const ctx = mkWanCtx({});
|
||||||
|
// Uses Webex_Calling_RTP as the exemplar in fixtures since it's the
|
||||||
|
// recommended production choice — but the checks are app-agnostic
|
||||||
|
// so any app id/name should behave identically.
|
||||||
|
ctx.sdwanData.appAudio = {
|
||||||
|
appId: '1708539371717015196',
|
||||||
|
appName: 'Webex_Calling_RTP',
|
||||||
|
mos, loss, jitter, bandwidth,
|
||||||
|
};
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|
||||||
|
function seriesSummary({ values, unit = '', interval = '5min' } = {}) {
|
||||||
|
// Build a shape identical to summarizeAppSeries() output so the
|
||||||
|
// check exercises the real threshold path rather than a stub.
|
||||||
|
const nums = values.filter((v) => typeof v === 'number' && Number.isFinite(v));
|
||||||
|
let min = null, max = null, avg = null, p95 = null;
|
||||||
|
if (nums.length > 0) {
|
||||||
|
min = Math.min(...nums);
|
||||||
|
max = Math.max(...nums);
|
||||||
|
avg = Math.round((nums.reduce((a, b) => a + b, 0) / nums.length) * 100) / 100;
|
||||||
|
const sorted = [...nums].sort((a, b) => a - b);
|
||||||
|
p95 = sorted[Math.min(sorted.length - 1, Math.floor(0.95 * sorted.length))];
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
unit, interval,
|
||||||
|
samples: values.length, validSamples: nums.length,
|
||||||
|
avg, min, max, p95, values,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
test('wanAppRtpMos: skipped when appAudio missing (feature not configured)', async () => {
|
||||||
|
const ctx = mkWanCtx({}); // no appAudio
|
||||||
|
const r = await wanAppRtpMosCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'skipped');
|
||||||
|
// Message must point operators at the canonical env var name.
|
||||||
|
// Backwards-compat handling for the legacy PRISMA_APP_ID_RTP_BASE
|
||||||
|
// lives in resolveVoiceAppConfig(); the surface message points at
|
||||||
|
// the new name only.
|
||||||
|
assert.match(r.message, /PRISMA_APP_ID_VOICE/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wanAppRtpMos: ok when worst-window MOS >= warn (default 4.0)', async () => {
|
||||||
|
const ctx = mkAppCtx({ mos: seriesSummary({ values: [4.1, 4.3, 4.5, 4.0] }) });
|
||||||
|
const r = await wanAppRtpMosCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wanAppRtpMos: warn when worst-window MOS < 4.0 but >= 3.5 (avg irrelevant)', async () => {
|
||||||
|
// Avg = 4.05 (looks fine) but one 5-min window dipped to 3.8 →
|
||||||
|
// must warn, not pass. This is the whole reason the check exists.
|
||||||
|
const ctx = mkAppCtx({ mos: seriesSummary({ values: [4.3, 4.2, 3.8, 4.5] }) });
|
||||||
|
const r = await wanAppRtpMosCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.match(r.message, /3\.8/, 'worst-window value must appear in message');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wanAppRtpMos: error when worst-window MOS < 3.5 (matches HAR failure case)', async () => {
|
||||||
|
// Real Webex_Calling_RTP numbers from CG00127
|
||||||
|
// (webex-base-metricCG00127.har, 2026-07-09): min=1.60, avg=4.27.
|
||||||
|
// Even though avg is fine, the min alone must fire error since
|
||||||
|
// those 5-min windows rendered calls unintelligible.
|
||||||
|
const ctx = mkAppCtx({ mos: seriesSummary({ values: [4.4, 4.3, 3.92, 1.60, 4.41, 4.03] }) });
|
||||||
|
const r = await wanAppRtpMosCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'error');
|
||||||
|
assert.match(r.message, /1\.6/);
|
||||||
|
assert.match(r.message, /worst-window/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wanAppRtpLoss: ok when worst-window loss <= 5%', async () => {
|
||||||
|
const ctx = mkAppCtx({ loss: seriesSummary({ values: [0, 0.5, 1, 2, 4] }) });
|
||||||
|
const r = await wanAppRtpLossCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wanAppRtpLoss: warn when worst-window loss > 5% but <= 15%', async () => {
|
||||||
|
const ctx = mkAppCtx({ loss: seriesSummary({ values: [0, 0, 10, 0] }) });
|
||||||
|
const r = await wanAppRtpLossCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'warn');
|
||||||
|
assert.match(r.message, /10%/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wanAppRtpLoss: error when worst-window loss > 15% (matches HAR failure)', async () => {
|
||||||
|
// Real Webex_Calling_RTP numbers from CG00127: max=26.88% loss.
|
||||||
|
// Must error even though avg was only ~1.37%.
|
||||||
|
const ctx = mkAppCtx({ loss: seriesSummary({ values: [0, 5, 26.88, 0, 3] }) });
|
||||||
|
const r = await wanAppRtpLossCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'error');
|
||||||
|
assert.match(r.message, /26\.88%/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wanAppRtpJitter: ok when all-zero series (nothing to grade badly)', async () => {
|
||||||
|
// The HAR shows AppPerfUDPAudioJitter often returns all zeros for
|
||||||
|
// this tenant. Must be treated as "clean throughout", NOT skipped
|
||||||
|
// and NOT bad — the metric legitimately reports zero and that IS
|
||||||
|
// an ok result.
|
||||||
|
const ctx = mkAppCtx({ jitter: seriesSummary({ values: [0, 0, 0, 0, 0] }) });
|
||||||
|
const r = await wanAppRtpJitterCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'ok');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wanAppRtpJitter: error when worst-window > 50ms', async () => {
|
||||||
|
const ctx = mkAppCtx({ jitter: seriesSummary({ values: [1, 2, 75, 3, 0] }) });
|
||||||
|
const r = await wanAppRtpJitterCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'error');
|
||||||
|
assert.match(r.message, /75ms/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wanAppRtp*: skipped when validSamples=0 (Prisma returned data but all-null)', async () => {
|
||||||
|
const emptyish = seriesSummary({ values: [null, null, null] });
|
||||||
|
const ctx = mkAppCtx({ mos: emptyish, loss: emptyish, jitter: emptyish });
|
||||||
|
for (const check of [wanAppRtpMosCheck, wanAppRtpLossCheck, wanAppRtpJitterCheck]) {
|
||||||
|
const r = await check.run(ctx);
|
||||||
|
assert.equal(r.status, 'skipped', `${check.id} → skipped on all-null`);
|
||||||
|
assert.match(r.message, /no voice traffic/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wanAppRtp*: kill switch (WAN_STANDARD_ENABLED=false) silences all three', async () => {
|
||||||
|
await withKillSwitchOn(async () => {
|
||||||
|
const ctx = mkAppCtx({ mos: seriesSummary({ values: [1.5] }) });
|
||||||
|
for (const check of [wanAppRtpMosCheck, wanAppRtpLossCheck, wanAppRtpJitterCheck]) {
|
||||||
|
const r = await check.run(ctx);
|
||||||
|
assert.equal(r.status, 'skipped', `${check.id} kill-switch triggered`);
|
||||||
|
assert.match(r.message, /WAN_STANDARD_ENABLED=false/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wanAppRtp*: badSamplePct exposes how often the metric was in warn/error', async () => {
|
||||||
|
// 4 of 10 samples in error range → 40% bad.
|
||||||
|
const values = [0, 0, 20, 22, 0, 30, 0, 0, 50, 0]; // 4 > 15%
|
||||||
|
const ctx = mkAppCtx({ loss: seriesSummary({ values }) });
|
||||||
|
const r = await wanAppRtpLossCheck.run(ctx);
|
||||||
|
assert.equal(r.status, 'error');
|
||||||
|
assert.equal(r.details.badSampleCount, 4);
|
||||||
|
assert.equal(r.details.badSamplePct, 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wanAppRtp*: fetch-failure surfaces the underlying error message (not "set env var")', async () => {
|
||||||
|
// Regression for the 429-cascade UX bug: previously, a fetch
|
||||||
|
// failure with the env var CONFIGURED caused the check to say
|
||||||
|
// "set PRISMA_APP_ID_RTP_BASE" — actively misleading. Now the
|
||||||
|
// check tells the operator the real reason (429, timeout, etc).
|
||||||
|
//
|
||||||
|
// Error-scope names are app-agnostic ("app.voice.*") so this
|
||||||
|
// still routes correctly regardless of which voice app the tenant
|
||||||
|
// configured.
|
||||||
|
const ctx = mkWanCtx({});
|
||||||
|
ctx.sdwanData.appAudio = {
|
||||||
|
appId: '1708539371717015196',
|
||||||
|
appName: 'Webex_Calling_RTP',
|
||||||
|
mos: null, // fetch failed
|
||||||
|
loss: null, // fetch failed
|
||||||
|
jitter: seriesSummary({ values: [0, 0, 0] }), // succeeded
|
||||||
|
bandwidth: seriesSummary({ values: [0.5] }),
|
||||||
|
};
|
||||||
|
ctx.sdwanData.errors = [
|
||||||
|
{ scope: 'app.voice.mos', message: 'Request failed with status code 429 (HTTP 429)' },
|
||||||
|
{ scope: 'app.voice.loss', message: 'timeout of 20000ms exceeded' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mosResult = await wanAppRtpMosCheck.run(ctx);
|
||||||
|
assert.equal(mosResult.status, 'skipped');
|
||||||
|
assert.match(mosResult.message, /429/, 'must surface the underlying 429');
|
||||||
|
assert.doesNotMatch(mosResult.message, /PRISMA_APP_ID_VOICE/,
|
||||||
|
'must NOT say "set env var" — the env IS set, the fetch just failed');
|
||||||
|
assert.match(mosResult.message, /Retry in ~30/, 'includes actionable "try again" hint');
|
||||||
|
|
||||||
|
const lossResult = await wanAppRtpLossCheck.run(ctx);
|
||||||
|
assert.match(lossResult.message, /timeout/);
|
||||||
|
|
||||||
|
// Jitter succeeded so it should grade normally, not be dragged
|
||||||
|
// down by its siblings' failures.
|
||||||
|
const jitterResult = await wanAppRtpJitterCheck.run(ctx);
|
||||||
|
assert.equal(jitterResult.status, 'ok',
|
||||||
|
'per-metric fetch failures must not cascade into sibling metrics');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('humanizeMetricUnit: maps raw Prisma unit strings to concise display suffixes', () => {
|
||||||
|
// The important cases — everything we've seen in a real Prisma
|
||||||
|
// response. These are what caused "11.83percentage" / "50milliseconds"
|
||||||
|
// to leak into the UI before this was added.
|
||||||
|
assert.equal(humanizeMetricUnit('percentage'), '%');
|
||||||
|
assert.equal(humanizeMetricUnit('percent'), '%');
|
||||||
|
assert.equal(humanizeMetricUnit('milliseconds'), 'ms');
|
||||||
|
assert.equal(humanizeMetricUnit('ms'), 'ms');
|
||||||
|
assert.equal(humanizeMetricUnit('count'), '',
|
||||||
|
'MOS unit "count" renders as empty — MOS numbers are self-explanatory');
|
||||||
|
assert.equal(humanizeMetricUnit('gauge'), '');
|
||||||
|
assert.equal(humanizeMetricUnit('Mbps'), 'Mbps');
|
||||||
|
assert.equal(humanizeMetricUnit('kbps'), 'kbps');
|
||||||
|
assert.equal(humanizeMetricUnit(''), '');
|
||||||
|
assert.equal(humanizeMetricUnit(null), '');
|
||||||
|
// Fallback path: unknown unit — return with a leading space so
|
||||||
|
// "12 widgets" reads better than "12widgets".
|
||||||
|
assert.equal(humanizeMetricUnit('widgets'), ' widgets');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wanAppRtp*: details.detailsUrl threads the SCM deep-link from appAudio through the check', async () => {
|
||||||
|
const ctx = mkWanCtx({});
|
||||||
|
ctx.sdwanData.appAudio = {
|
||||||
|
appId: '1708539371717015196', appName: 'Webex_Calling_RTP',
|
||||||
|
detailsUrl: 'https://stratacloudmanager.paloaltonetworks.com/insights/operational/sdwan-applications/1708539371717015196/site/16190109915660160/details',
|
||||||
|
mos: seriesSummary({ values: [1.85, 4.4] }),
|
||||||
|
loss: seriesSummary({ values: [0.5] }),
|
||||||
|
jitter: seriesSummary({ values: [0] }),
|
||||||
|
};
|
||||||
|
|
||||||
|
const r = await wanAppRtpMosCheck.run(ctx);
|
||||||
|
assert.equal(
|
||||||
|
r.details.detailsUrl,
|
||||||
|
'https://stratacloudmanager.paloaltonetworks.com/insights/operational/sdwan-applications/1708539371717015196/site/16190109915660160/details',
|
||||||
|
'detailsUrl must be present in the check result so the renderer can emit the link',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Missing URL on the container → check details have detailsUrl:null,
|
||||||
|
// so the renderer's conditional cleanly drops the link row.
|
||||||
|
delete ctx.sdwanData.appAudio.detailsUrl;
|
||||||
|
const r2 = await wanAppRtpMosCheck.run(ctx);
|
||||||
|
assert.equal(r2.details.detailsUrl, null,
|
||||||
|
'missing container URL propagates as null (renderer no-ops on that)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wanAppRtp*: details.unit is the humanized display unit (not raw Prisma "percentage")', async () => {
|
||||||
|
// Regression for the "11.83percentage" rendering bug — the raw
|
||||||
|
// API unit string ("percentage", "milliseconds") is unreadable
|
||||||
|
// when concatenated to a value. The check-args unit ('%', 'ms')
|
||||||
|
// MUST override the raw unit in the details payload so the
|
||||||
|
// renderer displays "11.83%" not "11.83percentage".
|
||||||
|
const ctx = mkAppCtx({
|
||||||
|
loss: seriesSummary({ values: [11.83], unit: 'percentage' }),
|
||||||
|
});
|
||||||
|
const r = await wanAppRtpLossCheck.run(ctx);
|
||||||
|
assert.equal(r.details.unit, '%', 'display unit humanized in details');
|
||||||
|
assert.equal(r.details.rawUnit, 'percentage',
|
||||||
|
'raw Prisma unit retained for debugging (as rawUnit)');
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -309,7 +309,7 @@ test('renderer: WAN window banner hidden when no wan* check runs', () => {
|
||||||
'no need to advertise a WAN window when no WAN check ran');
|
'no need to advertise a WAN window when no WAN check ran');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renderer: WAN window banner formats: 15m / 1h / 6h / 1d', () => {
|
test('renderer: WAN window banner formats: 15m / 1h / 6h / 1d / 7d', () => {
|
||||||
const wanResult = [
|
const wanResult = [
|
||||||
R('wanLatency', 'ok', 'ok', null, {
|
R('wanLatency', 'ok', 'ok', null, {
|
||||||
total: 1, ok: 1, warn: 0, error: 0,
|
total: 1, ok: 1, warn: 0, error: 0,
|
||||||
|
|
@ -321,6 +321,7 @@ test('renderer: WAN window banner formats: 15m / 1h / 6h / 1d', () => {
|
||||||
{ min: 60, expect: 'WAN window: 1h' },
|
{ min: 60, expect: 'WAN window: 1h' },
|
||||||
{ min: 360, expect: 'WAN window: 6h' },
|
{ min: 360, expect: 'WAN window: 6h' },
|
||||||
{ min: 1440, expect: 'WAN window: 1d' },
|
{ min: 1440, expect: 'WAN window: 1d' },
|
||||||
|
{ min: 10080, expect: 'WAN window: 7d' }, // new default
|
||||||
{ min: 45, expect: 'WAN window: 45m' },
|
{ min: 45, expect: 'WAN window: 45m' },
|
||||||
];
|
];
|
||||||
for (const c of cases) {
|
for (const c of cases) {
|
||||||
|
|
@ -328,3 +329,127 @@ test('renderer: WAN window banner formats: 15m / 1h / 6h / 1d', () => {
|
||||||
assert.ok(md.includes(c.expect), `${c.min} minutes → "${c.expect}", got:\n${md}`);
|
assert.ok(md.includes(c.expect), `${c.min} minutes → "${c.expect}", got:\n${md}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ─── Per-app audio details renderer ─────────────────────────────────
|
||||||
|
//
|
||||||
|
// renderAppAudioDetails is triggered by shape detection on
|
||||||
|
// {appName, worst, validSamples} — the fixture below carries all
|
||||||
|
// three so the shape-aware branch fires (rather than the JSON dump
|
||||||
|
// fallback which is what we're guarding against). appName is asserted
|
||||||
|
// verbatim from the fixture (not hardcoded to a specific app) — see
|
||||||
|
// wanAppRtpMos.js for the tenant-configurable contract.
|
||||||
|
|
||||||
|
test('renderer: per-app audio MOS details render worst-window + threshold + range (Webex_Calling_RTP)', () => {
|
||||||
|
const wanResult = R(
|
||||||
|
'wanAppRtpMos',
|
||||||
|
'error',
|
||||||
|
'Actual voice traffic (Webex_Calling_RTP) worst-window MOS: 1.60 < 3.5 (avg 4.27 over 33 valid samples)…',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
appName: 'Webex_Calling_RTP',
|
||||||
|
worst: 1.60, avg: 4.27, min: 1.60, max: 4.41, p95: 4.28,
|
||||||
|
samples: 55, validSamples: 33, interval: '5min',
|
||||||
|
warnThresh: 4.0, errorThresh: 3.5,
|
||||||
|
standardLabel: 'warn < 4, error < 3.5',
|
||||||
|
unit: '',
|
||||||
|
badSampleCount: 4, badSamplePct: 12,
|
||||||
|
},
|
||||||
|
'SD-WAN Voice Traffic MOS (worst window)',
|
||||||
|
);
|
||||||
|
const md = renderVoiceDiagMarkdown([wanResult], { storeNum: '127', detailed: true });
|
||||||
|
|
||||||
|
// The app name is threaded from the fixture (appAudio.appName) — the
|
||||||
|
// renderer must not hardcode "rtp-base" so tenants using
|
||||||
|
// Webex_Calling_RTP, MS_Teams_RTP, etc. render correctly.
|
||||||
|
assert.match(md, /App: Webex_Calling_RTP \(voice traffic, DPI\)/);
|
||||||
|
assert.match(md, /Threshold: warn < 4, error < 3\.5/);
|
||||||
|
assert.match(md, /Worst window: \*\*1\.6\*\*/);
|
||||||
|
assert.match(md, /Avg: 4\.27/);
|
||||||
|
assert.match(md, /p95: 4\.28/);
|
||||||
|
assert.match(md, /Range: 1\.6 – 4\.41 across 33\/55 samples @ 5min/);
|
||||||
|
assert.match(md, /Time in warn\/error: 4 samples/);
|
||||||
|
// Absence check — the fallback JSON key:value dump must NOT appear.
|
||||||
|
assert.doesNotMatch(md, /"values":/, 'shape-aware formatter must not dump raw JSON');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: per-app audio details fall back to "voice" when appName absent (defensive)', () => {
|
||||||
|
// If a fixture / integration test forgets to set appName (or a
|
||||||
|
// future refactor drops it), the renderer must not crash or render
|
||||||
|
// "undefined" — fall back to the generic "voice" label.
|
||||||
|
const wanResult = R(
|
||||||
|
'wanAppRtpMos', 'ok', 'ok',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
appName: null,
|
||||||
|
worst: 4.4, avg: 4.5, min: 4.4, max: 4.6, p95: 4.5,
|
||||||
|
samples: 12, validSamples: 12, interval: '5min',
|
||||||
|
warnThresh: 4.0, errorThresh: 3.5,
|
||||||
|
unit: '', badSampleCount: 0, badSamplePct: 0,
|
||||||
|
},
|
||||||
|
'SD-WAN Voice Traffic MOS (worst window)',
|
||||||
|
);
|
||||||
|
const md = renderVoiceDiagMarkdown([wanResult], { storeNum: '127', detailed: true });
|
||||||
|
assert.match(md, /App: voice \(voice traffic, DPI\)/,
|
||||||
|
'null appName must fall back to generic "voice" label');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: per-app audio details renders "View in Prisma UI" deep link when detailsUrl is set', () => {
|
||||||
|
const wanResult = R(
|
||||||
|
'wanAppRtpMos', 'error', 'msg',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
appName: 'Webex_Calling_RTP',
|
||||||
|
worst: 1.85, avg: 3.55, min: 1.85, max: 4.41, p95: 4.28,
|
||||||
|
samples: 288, validSamples: 288, interval: '5min',
|
||||||
|
warnThresh: 4.0, errorThresh: 3.5,
|
||||||
|
standardLabel: 'warn < 4, error < 3.5',
|
||||||
|
unit: '',
|
||||||
|
badSampleCount: 42, badSamplePct: 15,
|
||||||
|
detailsUrl: 'https://stratacloudmanager.paloaltonetworks.com/insights/operational/sdwan-applications/1708539371717015196/site/16190109915660160/details',
|
||||||
|
},
|
||||||
|
'SD-WAN Voice Traffic MOS (worst window)',
|
||||||
|
);
|
||||||
|
const md = renderVoiceDiagMarkdown([wanResult], { storeNum: '127', detailed: true });
|
||||||
|
assert.match(
|
||||||
|
md,
|
||||||
|
/\[View in Prisma UI\]\(https:\/\/stratacloudmanager\.paloaltonetworks\.com\/insights\/operational\/sdwan-applications\/1708539371717015196\/site\/16190109915660160\/details\)/,
|
||||||
|
'deep link must render as a markdown link so Webex chats it as a clickable URL',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: per-app audio details omits Prisma link when detailsUrl is not set', () => {
|
||||||
|
const wanResult = R(
|
||||||
|
'wanAppRtpMos', 'error', 'msg',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
appName: 'Webex_Calling_RTP',
|
||||||
|
worst: 1.85, avg: 3.55, min: 1.85, max: 4.41, p95: 4.28,
|
||||||
|
samples: 288, validSamples: 288, interval: '5min',
|
||||||
|
warnThresh: 4.0, errorThresh: 3.5,
|
||||||
|
unit: '',
|
||||||
|
badSampleCount: 42, badSamplePct: 15,
|
||||||
|
},
|
||||||
|
'SD-WAN Voice Traffic MOS (worst window)',
|
||||||
|
);
|
||||||
|
const md = renderVoiceDiagMarkdown([wanResult], { storeNum: '127', detailed: true });
|
||||||
|
assert.doesNotMatch(md, /View in Prisma UI/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer: per-app audio details — clean window says "0 samples (clean throughout window)"', () => {
|
||||||
|
const wanResult = R(
|
||||||
|
'wanAppRtpLoss', 'ok', 'ok',
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
appName: 'Webex_Calling_RTP',
|
||||||
|
worst: 0, avg: 0, min: 0, max: 0, p95: 0,
|
||||||
|
samples: 288, validSamples: 288, interval: '5min',
|
||||||
|
warnThresh: 5, errorThresh: 15,
|
||||||
|
standardLabel: 'warn > 5%, error > 15%',
|
||||||
|
unit: '%',
|
||||||
|
badSampleCount: 0, badSamplePct: 0,
|
||||||
|
},
|
||||||
|
'SD-WAN Voice Traffic Packet Loss (worst window)',
|
||||||
|
);
|
||||||
|
const md = renderVoiceDiagMarkdown([wanResult], { storeNum: '127', detailed: true });
|
||||||
|
assert.match(md, /Time in warn\/error: 0 samples \(clean throughout window\)/);
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue