Introduces a full Palo Alto Prisma SD-WAN integration (dual-mode SASE OAuth 2.0 / legacy CloudGenix auth, pagination, 429 backoff, session priming) that surfaces per-path latency/jitter/loss/MOS, site healthscore, link state, and alarm data for a store. Wired into the /phonestatus WAN follow-up and eight new /voicediag WAN checks graded against ITU-T G.114 / RFC 3550 defaults (env-overridable via WAN_STANDARD_*). Also adds a shape-aware detail renderer for /voicediag (per-link tables with verdict icons instead of a stringified JSON dump) and a --window flag (15m / 1h / 6h / 24h / 1d, env default via WAN_STANDARD_WINDOW_MINUTES) so operators can widen the look-back without redeploying. scripts/prismaProbe.js is bundled as a CLI for schema iteration against a live tenant. Co-authored-by: Cursor <cursoragent@cursor.com>
679 lines
27 KiB
JavaScript
679 lines
27 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,
|
||
getAlarms,
|
||
} from '../../integrations/paloalto/metrics.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 15.
|
||
* @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(),
|
||
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
|
||
const canFetchLqm = waninterfaceIds.length > 0;
|
||
const [
|
||
healthscoreRes,
|
||
latencyRes,
|
||
jitterRes,
|
||
lossRes,
|
||
mosRes,
|
||
alarmsRes,
|
||
] = 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),
|
||
]);
|
||
|
||
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);
|
||
|
||
// 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');
|
||
}
|
||
}
|
||
|
||
const elapsed = Date.now() - startedAt;
|
||
const alarmDiag = alarms?._diag
|
||
? ` alarms=${alarms._diag.rawEventCount}raw→${alarms._diag.rawEventCount - alarms._diag.droppedForSiteMismatch}scoped`
|
||
: '';
|
||
logger(
|
||
'sdwan:enrich',
|
||
`store ${storeNum} → site ${site.name} (${site.id}): ${elements.length} elements, ` +
|
||
`${links.length} links, health=${healthscore?.value ?? 'n/a'},${alarmDiag} 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,
|
||
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 },
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Snap an arbitrary window request to a reasonable, sane value.
|
||
* Falls back to the env default (WAN_STANDARD_WINDOW_MINUTES) or 15.
|
||
* Prisma's own upper bound seems to sit around 30 days for aiops
|
||
* queries, but voice-quality signal degrades past a day of averaging
|
||
* so we cap at 24h for now.
|
||
*/
|
||
function normalizeWindowMinutes(m) {
|
||
const raw = Number.isFinite(Number(m)) && Number(m) > 0
|
||
? Number(m)
|
||
: Number(process.env.WAN_STANDARD_WINDOW_MINUTES) || 15;
|
||
// Clamp to [1, 1440] (1 min .. 24 h).
|
||
return Math.max(1, Math.min(1440, 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;
|
||
}
|