Add store/email/phone filtering, richer call-line formatting, CDR feed pagination and queueing, and split Jira poller enrichment into testable modules. Co-authored-by: Cursor <cursoragent@cursor.com>
893 lines
37 KiB
JavaScript
893 lines
37 KiB
JavaScript
// src/services/enrichment/sdwanEnrichment.js
|
||
//
|
||
// One-stop composer for Prisma SD-WAN data used by /phonestatus's
|
||
// WAN follow-up and /voicediag's WAN check bucket. Same "fire the
|
||
// fetches in parallel + normalise into a stable shape + preserve
|
||
// per-metric failures in errors[]" pattern used by the Meraki
|
||
// enrichment layer.
|
||
//
|
||
// Contract (what `collectSdwanForStore(storeNum)` returns):
|
||
//
|
||
// {
|
||
// storeNum,
|
||
// site: { id, name, storeNum, description } | null,
|
||
// elements: [{ id, name, model, serial_number, connected }],
|
||
// healthscore: { value: 0-100, breakdown: {...} } | null,
|
||
// links: [
|
||
// {
|
||
// interfaceId, // Prisma waninterface id
|
||
// interfaceName, // human label
|
||
// elementId, // null in phase 1 — waninterfaces are site-scoped, not element-scoped
|
||
// transportType, // "primary" | "secondary" | "backup" | ... (from used_for)
|
||
// up: boolean | null, // waninterface admin_up (proxy until runtime status is wired)
|
||
// latencyMs: number | null,
|
||
// jitterMs: number | null,
|
||
// lossPct: number | null,
|
||
// mos: number | null,
|
||
// },
|
||
// ...
|
||
// ],
|
||
// alarms: {
|
||
// last1h: { critical: N, major: N, minor: N },
|
||
// samples: [{ code, message, severity, ts }] // top 5, most recent
|
||
// },
|
||
// errors: [{ scope, message }], // per-metric failure lines
|
||
// fetchedAt,
|
||
// }
|
||
//
|
||
// Design rules:
|
||
// - Never throws. A caller must be able to render "we tried, here's
|
||
// what we got, here's what failed" without a top-level try/catch.
|
||
// - `site: null` short-circuits everything else — the composer
|
||
// returns immediately with `errors: []` and empty collections.
|
||
// This is the "not a Prisma-managed store" happy path.
|
||
// - Each metric fetch is independent (`Promise.allSettled`). A
|
||
// failure adds an entry to `errors[]` and leaves the
|
||
// corresponding field null / empty; it does NOT propagate.
|
||
// - Response-shape assumptions are minimal and defensive: we
|
||
// read `res.metrics || res.data || res.items || []` and pick
|
||
// out the fields we recognise, tolerating extras/missing.
|
||
// - No caching here — each caller gets a fresh pull. Caching
|
||
// lives one layer down (sites + elements are cached; live
|
||
// metrics are always fresh).
|
||
|
||
import { logger } from '../../utils/logger.js';
|
||
import {
|
||
findSdwanSiteForStore,
|
||
getElementsForSite,
|
||
getWanInterfacesForSite,
|
||
} from '../../integrations/paloalto/sites.js';
|
||
import {
|
||
getHealthscore,
|
||
getLqmMetric,
|
||
getAppMetric,
|
||
getAlarms,
|
||
} from '../../integrations/paloalto/metrics.js';
|
||
import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js';
|
||
|
||
/**
|
||
* Compose everything /voicediag + /phonestatus care about for a
|
||
* store's Prisma SD-WAN posture. Never throws — always returns the
|
||
* shape documented at the top of this file.
|
||
*
|
||
* @param {string|number} storeNum
|
||
* @param {object} [opts]
|
||
* @param {number} [opts.windowMinutes] Look-back window for healthscore + LQM.
|
||
* Also used as the alarm window unless `alarmWindowMinutes` is set.
|
||
* 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
|
||
* independently (defaults to `max(60, windowMinutes)` since alarms
|
||
* at < 1h are usually too noisy to be actionable).
|
||
* @returns {Promise<object>} See file header for shape. Includes
|
||
* `window` reporting the effective look-back so callers/renderers
|
||
* can surface it.
|
||
*/
|
||
export async function collectSdwanForStore(storeNum, opts = {}) {
|
||
const startedAt = Date.now();
|
||
const emptyLinks = [];
|
||
const errors = [];
|
||
|
||
const windowMinutes = normalizeWindowMinutes(opts.windowMinutes);
|
||
const alarmWindowMinutes = normalizeWindowMinutes(
|
||
opts.alarmWindowMinutes ?? Math.max(60, windowMinutes),
|
||
);
|
||
|
||
const site = await findSdwanSiteForStore(storeNum).catch((err) => {
|
||
errors.push({ scope: 'sites', message: err.message });
|
||
return null;
|
||
});
|
||
|
||
if (!site) {
|
||
logger('sdwan:enrich', `No Prisma site for store ${storeNum} (not a Prisma-managed site)`, 'debug');
|
||
return {
|
||
storeNum: String(storeNum),
|
||
site: null,
|
||
elements: [],
|
||
healthscore: null,
|
||
links: emptyLinks,
|
||
alarms: emptyAlarms(),
|
||
appAudio: null,
|
||
errors,
|
||
fetchedAt: new Date().toISOString(),
|
||
window: { minutes: windowMinutes, alarmMinutes: alarmWindowMinutes },
|
||
};
|
||
}
|
||
|
||
// Fetch config (elements + waninterfaces) in parallel — both are
|
||
// 4h-cached at the integration layer so this is a hot path on
|
||
// steady state.
|
||
const [elementsRes, wanInterfacesRes] = await Promise.allSettled([
|
||
getElementsForSite(site.id),
|
||
getWanInterfacesForSite(site.id),
|
||
]);
|
||
|
||
recordFailure(errors, 'elements', elementsRes);
|
||
recordFailure(errors, 'waninterfaces', wanInterfacesRes);
|
||
|
||
const elements = valueOf(elementsRes) || [];
|
||
const wanInterfaces = valueOf(wanInterfacesRes) || [];
|
||
const waninterfaceIds = wanInterfaces.map((w) => w.id).filter(Boolean);
|
||
|
||
// Metric-endpoint dependency matrix (verified against a live SASE tenant):
|
||
// getHealthscore → site only (filter shape is empty; site match done locally)
|
||
// getLqmMetric → requires waninterfaceIds (per-path)
|
||
// 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 voiceAppCfg = resolveVoiceAppConfig();
|
||
const voiceAppId = voiceAppCfg.appId;
|
||
const voiceAppName = voiceAppCfg.appName;
|
||
const canFetchAppMetrics = Boolean(voiceAppId);
|
||
const [
|
||
healthscoreRes,
|
||
latencyRes,
|
||
jitterRes,
|
||
lossRes,
|
||
mosRes,
|
||
alarmsRes,
|
||
appMosRes,
|
||
appLossRes,
|
||
appJitterRes,
|
||
appBwRes,
|
||
] = await Promise.allSettled([
|
||
getHealthscore(site.id, windowMinutes),
|
||
canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'latency', windowMinutes) : Promise.resolve(null),
|
||
canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'jitter', windowMinutes) : Promise.resolve(null),
|
||
canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'loss', windowMinutes) : Promise.resolve(null),
|
||
canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'mos', windowMinutes) : Promise.resolve(null),
|
||
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, 'lqm.latency', latencyRes);
|
||
recordFailure(errors, 'lqm.jitter', jitterRes);
|
||
recordFailure(errors, 'lqm.loss', lossRes);
|
||
recordFailure(errors, 'lqm.mos', mosRes);
|
||
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
|
||
// note in metrics.js). Match the requested site locally.
|
||
const healthscore = parseHealthscore(valueOf(healthscoreRes), site.id);
|
||
const links = buildLinkRows({
|
||
wanInterfaces,
|
||
metricResponses: {
|
||
latency: valueOf(latencyRes),
|
||
jitter: valueOf(jitterRes),
|
||
loss: valueOf(lossRes),
|
||
mos: valueOf(mosRes),
|
||
},
|
||
});
|
||
// Second-line defense: if events/query silently ignored our
|
||
// query.site filter, parseAlarms filters locally by site_id.
|
||
const alarms = parseAlarms(valueOf(alarmsRes), site.id);
|
||
|
||
// Debug shape probes: on high error counts these give us the
|
||
// exact top-level keys Prisma returned, which is the fastest way
|
||
// to spot schema drift. Kept at `warn` level while the response
|
||
// shape is still under active reverse-engineering.
|
||
const hsRaw = valueOf(healthscoreRes);
|
||
if (hsRaw && healthscore?.value == null) {
|
||
// v2.6 metrics response is expected to shape as:
|
||
// metrics[0].sites[].healthscore (or .data.score, etc.)
|
||
const m0 = hsRaw.metrics?.[0];
|
||
const s0 = m0?.sites?.[0];
|
||
logger('sdwan:enrich',
|
||
`healthscore returned but parseHealthscore extracted no value — ` +
|
||
`top-level keys: [${Object.keys(hsRaw).join(',')}], ` +
|
||
`metrics[0] keys: [${m0 ? Object.keys(m0).join(',') : 'n/a'}], ` +
|
||
`sites count: ${m0?.sites?.length ?? 'n/a'}, ` +
|
||
`sites[0] keys: ${s0 ? '[' + Object.keys(s0).join(',') + ']' : 'n/a'}, ` +
|
||
`sites[0] preview: ${JSON.stringify(s0 || null).slice(0, 200)}`,
|
||
'warn');
|
||
}
|
||
const linksWithSamples = links.filter((l) => l.latencyMs != null || l.jitterMs != null || l.lossPct != null || l.mos != null);
|
||
if (waninterfaceIds.length > 0 && linksWithSamples.length === 0) {
|
||
const anyLqmRaw = valueOf(latencyRes) || valueOf(jitterRes) || valueOf(lossRes) || valueOf(mosRes);
|
||
if (anyLqmRaw) {
|
||
// Live shape: metrics[0].sites[0].paths[N].data.<key>
|
||
const m0 = anyLqmRaw.metrics?.[0];
|
||
const s0 = m0?.sites?.[0];
|
||
const p0 = s0?.paths?.[0];
|
||
logger('sdwan:enrich',
|
||
`LQM responses returned but 0 links have samples — ` +
|
||
`top-level keys: [${Object.keys(anyLqmRaw).join(',')}], ` +
|
||
`metrics[0] keys: [${m0 ? Object.keys(m0).join(',') : 'n/a'}], ` +
|
||
`sites count: ${m0?.sites?.length ?? 'n/a'}, ` +
|
||
`paths[0]: ${JSON.stringify(p0 || null).slice(0, 200)}`,
|
||
'warn');
|
||
}
|
||
}
|
||
|
||
// 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 alarmDiag = alarms?._diag
|
||
? ` 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(
|
||
'sdwan:enrich',
|
||
`store ${storeNum} → site ${site.name} (${site.id}): ${elements.length} elements, ` +
|
||
`${links.length} links, health=${healthscore?.value ?? 'n/a'},${alarmDiag}${appAudioDiag} ` +
|
||
`errors=${errors.length}, window=${windowMinutes}m/alarms=${alarmWindowMinutes}m, ${elapsed}ms`,
|
||
);
|
||
|
||
return {
|
||
storeNum: String(storeNum),
|
||
site: {
|
||
id: site.id,
|
||
name: site.name,
|
||
storeNum: String(storeNum),
|
||
description: site.description || '',
|
||
},
|
||
elements: elements.map((e) => ({
|
||
id: e.id,
|
||
name: e.name,
|
||
model: e.model,
|
||
serial_number: e.serial_number,
|
||
connected: e.connected,
|
||
})),
|
||
healthscore,
|
||
links,
|
||
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,
|
||
fetchedAt: new Date().toISOString(),
|
||
// Report the effective look-back so renderers can surface it and
|
||
// callers can verify the request they intended was honored.
|
||
window: { minutes: windowMinutes, alarmMinutes: alarmWindowMinutes },
|
||
};
|
||
}
|
||
|
||
// ─── App-metric time-series aggregator ──────────────────────────────
|
||
//
|
||
// App metrics come back as a real time series (usually 24h × 5min =
|
||
// 288 datapoints) rather than a single aggregated number like LQM.
|
||
// For voice quality, the worst-window matters more than the average:
|
||
// 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 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 timedPoints = datapoints.map((p) => ({
|
||
time: p?.time || null,
|
||
value: (typeof p?.value === 'number' && Number.isFinite(p.value) ? p.value : null),
|
||
}));
|
||
|
||
const values = timedPoints
|
||
.map((p) => p.value)
|
||
.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,
|
||
// Timestamp-aligned series for per-call WAN overlap (callreport).
|
||
timedPoints,
|
||
};
|
||
|
||
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.
|
||
* Falls back to the env default (WAN_STANDARD_WINDOW_MINUTES) or
|
||
* 10080 (7d). Prisma's own upper bound sits at ~30 days for aiops
|
||
* queries, but per-app DPI series past 7d hit interval-collapse
|
||
* (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) {
|
||
const raw = Number.isFinite(Number(m)) && Number(m) > 0
|
||
? Number(m)
|
||
: Number(process.env.WAN_STANDARD_WINDOW_MINUTES) || DEFAULT_WINDOW_MINUTES;
|
||
return Math.max(1, Math.min(MAX_WINDOW_MINUTES, Math.floor(raw)));
|
||
}
|
||
|
||
// ─── Response normalisers ─────────────────────────────────────────
|
||
|
||
function emptyAlarms() {
|
||
return { last1h: { critical: 0, major: 0, minor: 0 }, samples: [] };
|
||
}
|
||
|
||
function valueOf(settledResult) {
|
||
if (!settledResult) return null;
|
||
return settledResult.status === 'fulfilled' ? settledResult.value : null;
|
||
}
|
||
|
||
function recordFailure(errors, scope, settledResult) {
|
||
if (settledResult?.status === 'rejected') {
|
||
errors.push({ scope, message: settledResult.reason?.message || String(settledResult.reason) });
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Prisma's aiops/health response carries at least one
|
||
* `metrics[]` array with per-time-bucket values. We collapse to
|
||
* "the most recent site-level score" plus an optional breakdown
|
||
* of contributing sub-scores when present. Defensive on shape:
|
||
* an unrecognised payload just returns null.
|
||
*/
|
||
// The three parse* helpers below are exported for direct unit
|
||
// testing (tests/sdwanEnrichment.test.js). They're not part of the
|
||
// public composer surface — callers should use `collectSdwanForStore`.
|
||
/**
|
||
* Extract the last healthscore value from a v2.6 monitor/metrics
|
||
* response. Verified live on this tenant on 2026-07-09:
|
||
*
|
||
* metrics[0].series[0].data[0].datapoints[N].value
|
||
*
|
||
* The `data[0]` is a WRAPPER (`{statistics: 'max', datapoints: [...]}`)
|
||
* whose `datapoints` array holds the actual time-series. We take
|
||
* the most recent datapoint. That's what "healthscore right now"
|
||
* means from a user's perspective.
|
||
*
|
||
* Response is already server-side scoped to our site (via
|
||
* filter.site request), so we don't need to filter by siteId here —
|
||
* but we keep the parameter for backwards-compat with the other
|
||
* shapes we still tolerate as fallbacks.
|
||
*
|
||
* Shapes tolerated (in order of preference):
|
||
* 1. v2.6 metrics wrapper: metrics[].series[].data[].datapoints[].value ← LIVE WINNER
|
||
* 2. v2.6 per-site rollup: metrics[].sites[].{healthscore | score | data.score}
|
||
* 3. Legacy pan.dev: metrics[].series[].data[].value
|
||
*/
|
||
export function parseHealthscore(resp, siteId = null) {
|
||
if (!resp || typeof resp !== 'object') return null;
|
||
const metrics = resp.metrics || resp.data || [];
|
||
if (!Array.isArray(metrics) || metrics.length === 0) return null;
|
||
|
||
// ── Shape 1 (LIVE): metrics[].series[].data[].datapoints[].value ──
|
||
for (const m of metrics) {
|
||
const series0 = Array.isArray(m?.series) ? m.series[0] : null;
|
||
if (!series0) continue;
|
||
const dataWrapper0 = Array.isArray(series0.data) ? series0.data[0] : null;
|
||
if (!dataWrapper0 || !Array.isArray(dataWrapper0.datapoints)) continue;
|
||
const lastDp = [...dataWrapper0.datapoints].reverse().find(
|
||
(p) => Number.isFinite(Number(p?.value)),
|
||
);
|
||
if (lastDp) {
|
||
return { value: Math.round(Number(lastDp.value)), breakdown: {} };
|
||
}
|
||
}
|
||
|
||
// ── Shape 2 (defensive): metrics[].sites[].healthscore / .score ──
|
||
for (const m of metrics) {
|
||
if (!/health/i.test(m?.name || '') && m !== metrics[0]) continue;
|
||
const sites = m?.sites;
|
||
if (Array.isArray(sites)) {
|
||
const match = sites.find((s) => {
|
||
const sid = s?.site_id || s?.id || s?.site;
|
||
return sid && String(sid) === String(siteId);
|
||
}) || sites[0];
|
||
const v = extractHealthscoreValue(match);
|
||
if (v != null) return { value: Math.round(v), breakdown: {} };
|
||
}
|
||
}
|
||
|
||
// ── Shape 3 (legacy pan.dev): metrics[].series[].data[].value ──
|
||
const primary = metrics.find((m) => /health/i.test(m?.name || '')) || metrics[0];
|
||
const allSeries = primary?.series || primary?.view?.series || [];
|
||
let candidates = Array.isArray(allSeries) ? allSeries : [];
|
||
if (siteId) {
|
||
const scoped = candidates.filter((s) => {
|
||
const v = s?.view?.site || s?.view?.site_id || s?.site_id;
|
||
return v && String(v) === String(siteId);
|
||
});
|
||
if (scoped.length > 0) candidates = scoped;
|
||
}
|
||
const points = Array.isArray(candidates[0]?.data)
|
||
? candidates[0].data
|
||
: (Array.isArray(candidates) ? candidates : []);
|
||
const lastPoint = [...points].reverse().find((p) => Number.isFinite(Number(p?.value)));
|
||
if (lastPoint) {
|
||
const value = Math.round(Number(lastPoint.value));
|
||
const breakdown = {};
|
||
for (const m of metrics) {
|
||
if (m === primary) continue;
|
||
const s = m?.series || m?.view?.series || [];
|
||
const pts = Array.isArray(s[0]?.data) ? s[0].data : (Array.isArray(s) ? s : []);
|
||
const last = [...pts].reverse().find((p) => Number.isFinite(Number(p?.value)));
|
||
if (last && m?.name) {
|
||
breakdown[m.name] = Math.round(Number(last.value));
|
||
}
|
||
}
|
||
return { value, breakdown };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Extract a healthscore value from an unknown-shape object by
|
||
* looking for a numeric field with a healthscore-like key. Handles
|
||
* both the top-level case (obj.score) and one-level-of-nesting
|
||
* (obj.data.score). Falls through to the first plausible numeric
|
||
* field within the 0-100 range if no known key matches.
|
||
*/
|
||
function extractHealthscoreValue(obj) {
|
||
if (!obj || typeof obj !== 'object') return null;
|
||
const knownKeys = ['healthscore', 'health_score', 'score', 'value', 'health'];
|
||
const candidates = [obj, obj.data, obj.metrics, obj.summary].filter(Boolean);
|
||
for (const cand of candidates) {
|
||
for (const k of knownKeys) {
|
||
const v = cand[k];
|
||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||
}
|
||
}
|
||
// Last-resort: first 0-100 numeric field.
|
||
for (const cand of candidates) {
|
||
for (const [k, v] of Object.entries(cand)) {
|
||
if (typeof v !== 'number' || !Number.isFinite(v)) continue;
|
||
if (k === 'sample_completeness') continue;
|
||
if (v >= 0 && v <= 100) return v;
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
/**
|
||
* Per-metric field-name lookup inside the `data` object of the
|
||
* observed live response shape:
|
||
*
|
||
* metrics[].sites[].paths[].data = {
|
||
* sample_completeness: 100,
|
||
* rtt_latency: 22.0121 // ← metric-specific value key
|
||
* }
|
||
*
|
||
* Loss and MOS are directional — Prisma returns them as separate
|
||
* uplink/downlink keys. Voice quality degrades with loss in EITHER
|
||
* direction and MOS reflects the WORSE endpoint's experience, so
|
||
* DIRECTIONAL_LQM_KEYS below folds each pair via the appropriate
|
||
* combiner (max for loss, min for MOS since low-MOS is worse).
|
||
*
|
||
* Live shapes verified 2026-07-09 via prismaProbe try-shapes:
|
||
* latency → data.rtt_latency (RTT scalar)
|
||
* jitter → data.rtt_jitter (assumed via fallback) (RTT scalar)
|
||
* loss → data.{downlink,uplink}_pkt_loss_avg (directional)
|
||
* mos → data.{downlink,uplink}_mos_{avg,min,max} (directional × 3 stats)
|
||
*
|
||
* The `_fallback` scan below catches drift by picking the first
|
||
* numeric non-completeness key in the data object.
|
||
*/
|
||
const LQM_DATA_KEYS = {
|
||
latency: ['rtt_latency', 'latency', 'latency_ms'],
|
||
jitter: ['rtt_jitter', 'jitter', 'jitter_ms', 'one_way_jitter'],
|
||
// Loss & MOS: direct-hit keys only. Directional handling folds
|
||
// uplink/downlink pairs BEFORE this list is consulted.
|
||
loss: ['pkt_loss_pct', 'packet_loss', 'pkt_loss', 'loss', 'loss_pct'],
|
||
mos: ['mos', 'mos_score', 'mean_opinion_score'],
|
||
};
|
||
|
||
// Directional metric keys — Prisma reports these as two separate
|
||
// numbers (uplink/downlink). extractLqmValue folds them into a
|
||
// single value per the `combine` strategy: worst-case in each
|
||
// metric's own semantic (max for loss/latency-like where higher is
|
||
// worse; min for MOS where lower is worse).
|
||
const DIRECTIONAL_LQM_KEYS = {
|
||
loss: {
|
||
keys: ['downlink_pkt_loss_avg', 'uplink_pkt_loss_avg'],
|
||
combine: Math.max, // loss anywhere hurts voice quality
|
||
},
|
||
mos: {
|
||
// Average per direction. `_min` and `_max` variants also exist in
|
||
// the response (e.g. downlink_mos_min) — we pick avg because
|
||
// it's the typical experience over the window, not a transient
|
||
// dip. If we picked _min we'd get spuriously bad readings from
|
||
// single-sample glitches.
|
||
keys: ['downlink_mos_avg', 'uplink_mos_avg'],
|
||
combine: Math.min, // low MOS = worse audio, so take the worse direction
|
||
},
|
||
};
|
||
|
||
function extractLqmValue(dataObj, metricKey) {
|
||
if (!dataObj || typeof dataObj !== 'object') return null;
|
||
|
||
// Directional handling FIRST — if a metric has known directional
|
||
// split keys, fold them (e.g. loss = max(downlink, uplink)) so
|
||
// asymmetric loss patterns don't silently disappear when we pick
|
||
// whichever direction was 0.
|
||
const dir = DIRECTIONAL_LQM_KEYS[metricKey];
|
||
if (dir) {
|
||
const nums = dir.keys
|
||
.map((k) => dataObj[k])
|
||
.filter((v) => typeof v === 'number' && Number.isFinite(v));
|
||
if (nums.length > 0) return dir.combine(...nums);
|
||
}
|
||
|
||
const knownKeys = LQM_DATA_KEYS[metricKey] || [];
|
||
for (const k of knownKeys) {
|
||
if (typeof dataObj[k] === 'number' && Number.isFinite(dataObj[k])) {
|
||
return dataObj[k];
|
||
}
|
||
}
|
||
// Defensive fallback: first numeric key that isn't the
|
||
// sample-quality marker. This catches Prisma renaming a key
|
||
// between tenant versions without silently going to null.
|
||
for (const [k, v] of Object.entries(dataObj)) {
|
||
if (k === 'sample_completeness') continue;
|
||
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* Build per-path (per-waninterface) rows from the site's waninterface
|
||
* config + the four LQM metric responses.
|
||
*
|
||
* The waninterface config is the SOURCE OF TRUTH for the link list —
|
||
* every configured WAN interface at the site becomes a row here, even
|
||
* if Prisma has no recent LQM samples for it. LQM metric responses
|
||
* are layered in as overlays: last non-null sample per interface for
|
||
* each of the four metrics. Missing samples stay `null`.
|
||
*
|
||
* Prisma's LQM response shape on this tenant (verified live via
|
||
* /scripts/prismaProbe.js on 2026-07-09) — NOT the pan.dev shape:
|
||
*
|
||
* {
|
||
* metrics: [{
|
||
* name: 'LqmLatencyPointMetric',
|
||
* unit: 'milliseconds',
|
||
* sites: [{
|
||
* site_id: '<site-id>',
|
||
* paths: [{
|
||
* path_id: '<waninterface-id>',
|
||
* remote_site_id: '0',
|
||
* data: {
|
||
* sample_completeness: 100,
|
||
* rtt_latency: 22.0121 // ← key varies by metric
|
||
* }
|
||
* }, ...]
|
||
* }]
|
||
* }]
|
||
* }
|
||
*
|
||
* Note: `data` is a SINGLE OBJECT, NOT an array of time-series
|
||
* points. Whatever `interval` you request, this shape gives you
|
||
* one point per path (the aggregate for the window). The pan.dev
|
||
* docs describing series[].data[].value are for a different
|
||
* response variant.
|
||
*
|
||
* `up` is set from waninterface config's `admin_up` (the closest
|
||
* available proxy without a per-interface runtime status call). A
|
||
* more accurate "is this circuit currently carrying traffic" signal
|
||
* would need the /waninterfaces/{id}/status endpoint per element,
|
||
* which is deferred to a future phase.
|
||
*/
|
||
export function buildLinkRows({ wanInterfaces, metricResponses }) {
|
||
const rows = new Map();
|
||
for (const w of Array.isArray(wanInterfaces) ? wanInterfaces : []) {
|
||
rows.set(w.id, {
|
||
interfaceId: w.id,
|
||
interfaceName: w.name || w.id,
|
||
elementId: null,
|
||
// usedFor is 'primary' / 'secondary' / 'lte-backup' etc. —
|
||
// stand-in transport label until wan_networks config is wired
|
||
// in phase 2 for the real MPLS/BROADBAND/LTE labels.
|
||
transportType: w.usedFor || null,
|
||
up: w.adminUp, // admin state = closest available "is it up" until we wire runtime status
|
||
latencyMs: null,
|
||
jitterMs: null,
|
||
lossPct: null,
|
||
mos: null,
|
||
// Diagnostic side-channel — surfaced in details so the
|
||
// operator can see whether Prisma's sample was fresh (100)
|
||
// or partial (e.g. 20 = only 1 of 5 minute-buckets had data).
|
||
sampleCompleteness: null,
|
||
});
|
||
}
|
||
|
||
const stampFromResponse = (resp, metricKey, apply) => {
|
||
if (!resp?.metrics) return;
|
||
for (const metric of resp.metrics) {
|
||
// Preferred shape (verified live on this tenant):
|
||
// metrics[].sites[].paths[].data.<metricKey>
|
||
if (Array.isArray(metric?.sites)) {
|
||
for (const site of metric.sites) {
|
||
for (const p of site?.paths || []) {
|
||
const pathId = p?.path_id;
|
||
if (!pathId || !rows.has(pathId)) continue;
|
||
const value = extractLqmValue(p?.data, metricKey);
|
||
if (value != null) apply(rows.get(pathId), value);
|
||
if (typeof p?.data?.sample_completeness === 'number') {
|
||
rows.get(pathId).sampleCompleteness = p.data.sample_completeness;
|
||
}
|
||
}
|
||
}
|
||
continue;
|
||
}
|
||
// Fallback shape (pan.dev-documented, may exist on other
|
||
// tenants or API versions): metrics[].series[].data[].value
|
||
// keyed by view.path / view.waninterface.
|
||
const series = metric?.series || metric?.view?.series || [];
|
||
for (const s of series) {
|
||
const wiId = s?.view?.path
|
||
|| s?.view?.waninterface
|
||
|| s?.view?.wan_interface_id
|
||
|| s?.view?.interface_id;
|
||
if (!wiId || !rows.has(wiId)) continue;
|
||
const points = Array.isArray(s?.data) ? s.data : [];
|
||
const last = [...points].reverse().find((p) => p?.value !== null && p?.value !== undefined);
|
||
if (!last) continue;
|
||
apply(rows.get(wiId), Number(last.value));
|
||
}
|
||
}
|
||
};
|
||
|
||
stampFromResponse(metricResponses?.latency, 'latency', (row, v) => { row.latencyMs = round(v, 1); });
|
||
stampFromResponse(metricResponses?.jitter, 'jitter', (row, v) => { row.jitterMs = round(v, 1); });
|
||
stampFromResponse(metricResponses?.loss, 'loss', (row, v) => { row.lossPct = round(v, 2); });
|
||
stampFromResponse(metricResponses?.mos, 'mos', (row, v) => { row.mos = round(v, 2); });
|
||
|
||
// Derive up-state when admin_up is not exposed by the waninterface
|
||
// config API. If we have ANY LQM sample for a path, the path is
|
||
// provably carrying traffic — that's a stronger signal than admin
|
||
// state anyway. We only upgrade unknown (null) rows here; explicit
|
||
// adminUp=false (admin-disabled) stays down.
|
||
for (const row of rows.values()) {
|
||
if (row.up === null || row.up === undefined) {
|
||
const hasSample = row.latencyMs != null
|
||
|| row.jitterMs != null
|
||
|| row.lossPct != null
|
||
|| row.mos != null;
|
||
if (hasSample) row.up = true;
|
||
}
|
||
}
|
||
|
||
return [...rows.values()];
|
||
}
|
||
|
||
export function parseAlarms(resp, siteId = null) {
|
||
if (!resp || typeof resp !== 'object') return emptyAlarms();
|
||
const items = resp.items || resp.data || resp.alarms || [];
|
||
if (!Array.isArray(items)) return emptyAlarms();
|
||
|
||
const counts = { critical: 0, major: 0, minor: 0 };
|
||
const samples = [];
|
||
let droppedForSiteMismatch = 0;
|
||
for (const a of items) {
|
||
// Events/query returns both open AND cleared alarms in the same
|
||
// response — filter out already-resolved ones so "N alarms in
|
||
// the last hour" reflects still-active issues.
|
||
if (a?.cleared === true) continue;
|
||
|
||
// Defensive site scoping: if the tenant's events/query silently
|
||
// ignores our `query.site` filter (schema quirk we hit before),
|
||
// we'd otherwise report tenant-wide alarms as this-site alarms
|
||
// — a badly misleading number. Match locally by site_id when a
|
||
// siteId is provided AND the event carries one. Events with NO
|
||
// site_id are kept as-is (e.g. tenant-scoped events that legitimately
|
||
// don't belong to a specific site).
|
||
if (siteId) {
|
||
const eventSiteId = a?.site_id || a?.entity_ref;
|
||
if (eventSiteId && String(eventSiteId) !== String(siteId)) {
|
||
droppedForSiteMismatch += 1;
|
||
continue;
|
||
}
|
||
}
|
||
|
||
const sev = String(a?.severity || '').toLowerCase();
|
||
if (sev === 'critical' || sev === 'major' || sev === 'minor') {
|
||
counts[sev] += 1;
|
||
}
|
||
// `info` on Prisma events is often a NESTED OBJECT (e.g. `{ vpn_link_id: '...' }`),
|
||
// not a human-readable string — flatten defensively so the
|
||
// renderer doesn't dump `[object Object]` into chat.
|
||
const infoText = typeof a?.info === 'string'
|
||
? a.info
|
||
: (a?.info ? JSON.stringify(a.info).slice(0, 200) : '');
|
||
samples.push({
|
||
code: a?.code || a?.category || a?.type || 'UNKNOWN',
|
||
message: infoText || a?.description || a?.message || '',
|
||
severity: sev || 'unknown',
|
||
ts: a?.time || a?.timestamp || a?._created_on_utc || null,
|
||
});
|
||
}
|
||
samples.sort((a, b) => String(b.ts || '').localeCompare(String(a.ts || '')));
|
||
return {
|
||
last1h: counts,
|
||
samples: samples.slice(0, 5),
|
||
// Diagnostic surface for the caller — lets sdwanEnrichment log
|
||
// "raw N events → K after site filter" so we can see whether
|
||
// Prisma's server-side site filter is working or being ignored.
|
||
_diag: {
|
||
rawEventCount: items.length,
|
||
droppedForSiteMismatch,
|
||
},
|
||
};
|
||
}
|
||
|
||
function round(n, decimals) {
|
||
if (!Number.isFinite(n)) return null;
|
||
const p = Math.pow(10, decimals);
|
||
return Math.round(n * p) / p;
|
||
}
|