collabSupport/services/enrichment/sdwanEnrichment.js
jmcqueen b802383441 Add Prisma SD-WAN voice-quality enrichment for /phonestatus + /voicediag
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>
2026-07-09 09:45:29 -04:00

679 lines
27 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
}