// 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} 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. 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: '', * paths: [{ * path_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. 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; }