diff --git a/.env.example b/.env.example index 10380b6..ebceba0 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,10 @@ HTTP_API_TOKEN= # stay open so the bundled dashboards keep working without auth headers). HTTP_API_REQUIRE_AUTH=false +# Optional. Base URL for scripts that call the bot HTTP API (e.g. +# scripts/fetchDectStatusXml.js). Default: http://localhost:${SERVER_PORT} +# HTTP_API_BASE_URL=https://your-bot-host.example.com/CollabSupport + # ----------------------------------------------------------------------------- # Webex Bot (required) # ----------------------------------------------------------------------------- @@ -224,6 +228,20 @@ WEBEX_TOKENS_PATH=./config/webex-service-tokens.json # convenience — the old var will be removed in a future release. # PRISMA_APP_ID_RTP_BASE= +# --- Phase 2: per-waninterface voice DPI fan-out --- +# When true, collectSdwanForStore also fetches AppPerf* metrics filtered +# by filter.path=[waninterfaceId] (up to 4 circuits). Multiplies Prisma +# request count — leave off unless probing path attribution. +# Requires a tenant that accepts filter.path on AppPerfUDP* metrics +# (confirm with: npm run prisma:probe -- try-shapes app-by-path …). +# PRISMA_VOICE_PATH_ATTRIBUTION=false + +# --- Phase 1 optional: voice DPI broken out by path_type (VPN vs DIA) --- +# Off by default — runs AFTER the core metric batch and only fetches +# loss for VPN + DirectInternet. Enable when you want overlay-vs-DIA +# comparison without reintroducing the parallel 429 storm. +# PRISMA_VOICE_PATH_TYPE_BREAKOUT=false + # --- WAN bucket global kill-switch (mirror of VOICE_STANDARD_ENABLED for # the port bucket). Set to `false` to silence the entire WAN check # bucket while a Prisma cleanup is in progress. The /phonestatus @@ -477,6 +495,10 @@ DECT_RELAY_AGENT_TOKEN= # DECT_RELAY_BOT_URL on the agent side to match. # DECT_RELAY_PATH=/dect-relay/ws +# Capture raw status.xml from a live store (bot + relay must be running): +# node scripts/fetchDectStatusXml.js 782 --save-dir tests/fixtures/dect/ +# Uses GET /api/dect/raw-xml/:storeNumber (collect-raw via relay). + # ----------------------------------------------------------------------------- # Twilio /calltest — outbound voice path testing # ----------------------------------------------------------------------------- diff --git a/integrations/paloalto/index.js b/integrations/paloalto/index.js index 675fce5..b5dbf6f 100644 --- a/integrations/paloalto/index.js +++ b/integrations/paloalto/index.js @@ -16,18 +16,36 @@ export { getAllSites, getElementsForSite, getWanInterfacesForSite, + getWanInterfaceStatus, + getWanInterfaceStatusesForSite, + normalizeWanInterfaceStatus, _resetSitesCache, } from './sites.js'; export { LQM_METRIC_NAMES, APP_METRIC_NAMES, + ALL_PATH_TYPES, + VOICE_PATH_TYPE_SUBSET, getHealthscore, getLqmMetric, getAppMetric, + getAppMetricsByPathType, getAlarms, } from './metrics.js'; +export { + getVpnLinksForSite, + queryAnynetLinksForSite, + queryVpnLinksByAnynetId, + queryTopologyLinksForSite, + queryVpnLinksForSite, + fetchVpnLinkStatuses, + normalizeTunnelRow, + siteIdsFromTunnelRaw, + extractVpnLinkIdsFromAlarmInfo, +} from './topology.js'; + export { getPrismaUiBaseUrl, buildAppDetailsUrl, diff --git a/integrations/paloalto/metrics.js b/integrations/paloalto/metrics.js index 79d2763..f2789a9 100644 --- a/integrations/paloalto/metrics.js +++ b/integrations/paloalto/metrics.js @@ -350,10 +350,15 @@ export async function getLqmMetric(siteId, waninterfaceIds, metricKey, windowMin // (metrics[].series[].data[].datapoints[{time, value}]) so downstream // parsing can reuse the same helper. -const ALL_PATH_TYPES = Object.freeze( +export const ALL_PATH_TYPES = Object.freeze( ['DirectInternet', 'VPN', 'PrivateWAN', 'PrivateVPN', 'ServiceLink'], ); +/** Curated path_types for Phase 1 per-type voice DPI breakout. */ +export const VOICE_PATH_TYPE_SUBSET = Object.freeze( + ['VPN', 'DirectInternet', 'PrivateWAN'], +); + // eslint-disable-next-line no-restricted-syntax -- frozen shared registry. export const APP_METRIC_NAMES = Object.freeze({ mos: { @@ -409,8 +414,8 @@ function appMetricsPath() { * @param {string} siteId * @param {string} appId Prisma numeric app id (NOT display name) * @param {'mos'|'loss'|'jitter'|'bandwidth'} metricKey - * @param {number} windowMinutes default 1440 (24h) — matches the - * shipping default for WAN checks + * @param {number|object} windowOrOpts minutes, or + * `{ startTime, endTime }` / `{ windowMinutes, pathTypes?, pathIds? }` * @returns {Promise} */ export async function getAppMetric(siteId, appId, metricKey, windowOrOpts = 1440) { @@ -430,18 +435,26 @@ export async function getAppMetric(siteId, appId, metricKey, windowOrOpts = 1440 let startTime; let endTime; let windowMinutes; - if ( - windowOrOpts - && typeof windowOrOpts === 'object' - && windowOrOpts.startTime - && windowOrOpts.endTime - ) { - startTime = windowOrOpts.startTime; - endTime = windowOrOpts.endTime; - windowMinutes = Math.max( - 1, - Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 60_000), - ); + let pathTypes = null; + let pathIds = null; + + if (windowOrOpts && typeof windowOrOpts === 'object') { + pathTypes = Array.isArray(windowOrOpts.pathTypes) ? windowOrOpts.pathTypes : null; + pathIds = Array.isArray(windowOrOpts.pathIds) ? windowOrOpts.pathIds : null; + if (windowOrOpts.startTime && windowOrOpts.endTime) { + startTime = windowOrOpts.startTime; + endTime = windowOrOpts.endTime; + windowMinutes = Math.max( + 1, + Math.round((new Date(endTime).getTime() - new Date(startTime).getTime()) / 60_000), + ); + } else { + windowMinutes = typeof windowOrOpts.windowMinutes === 'number' + ? windowOrOpts.windowMinutes + : 1440; + startTime = windowStart(windowMinutes); + endTime = nowIso(); + } } else { windowMinutes = typeof windowOrOpts === 'number' ? windowOrOpts : 1440; startTime = windowStart(windowMinutes); @@ -452,8 +465,15 @@ export async function getAppMetric(siteId, appId, metricKey, windowOrOpts = 1440 site: [String(siteId)], app: [String(appId)], }; - if (spec.includePathType) filter.path_type = [...ALL_PATH_TYPES]; - if (spec.direction) filter.direction = spec.direction; + if (spec.includePathType) { + filter.path_type = pathTypes?.length ? [...pathTypes] : [...ALL_PATH_TYPES]; + } + if (spec.direction) filter.direction = spec.direction; + // Phase 2: optional per-waninterface path filter. Schema may reject + // on some tenants — caller must tolerate null. + if (pathIds?.length) { + filter.path = pathIds.map(String); + } try { const res = await paloAltoAxios.post(appMetricsPath(), { @@ -478,6 +498,48 @@ export async function getAppMetric(siteId, appId, metricKey, windowOrOpts = 1440 } } +/** + * Fan-out getAppMetric once per path_type. AppAudioMos does not take + * path_type — for MOS we return a single `__all__` entry from the + * unfiltered call so callers still get a MOS series. + * + * @param {string} siteId + * @param {string} appId + * @param {'mos'|'loss'|'jitter'|'bandwidth'} metricKey + * @param {number} [windowMinutes=1440] + * @param {string[]} [pathTypes=VOICE_PATH_TYPE_SUBSET] + * @returns {Promise>} + */ +export async function getAppMetricsByPathType( + siteId, + appId, + metricKey, + windowMinutes = 1440, + pathTypes = VOICE_PATH_TYPE_SUBSET, +) { + const spec = APP_METRIC_NAMES[metricKey]; + if (!spec) return {}; + + if (!spec.includePathType) { + const raw = await getAppMetric(siteId, appId, metricKey, windowMinutes); + return { __all__: raw }; + } + + const types = Array.isArray(pathTypes) && pathTypes.length + ? pathTypes + : [...VOICE_PATH_TYPE_SUBSET]; + const entries = await Promise.all( + types.map(async (pt) => { + const raw = await getAppMetric(siteId, appId, metricKey, { + windowMinutes, + pathTypes: [pt], + }); + return [pt, raw]; + }), + ); + return Object.fromEntries(entries); +} + // ────────────────────────────────────────────── // Alarms (via the events/query endpoint) // ────────────────────────────────────────────── diff --git a/integrations/paloalto/sites.js b/integrations/paloalto/sites.js index cdf34f9..f4f86f7 100644 --- a/integrations/paloalto/sites.js +++ b/integrations/paloalto/sites.js @@ -420,6 +420,123 @@ function waninterfacesPath(siteId) { : `/v2.10/api/sites/${siteId}/waninterfaces`; } +function waninterfaceStatusPath(siteId, wanInterfaceId) { + return isSase() + ? `/sdwan/v2.1/api/sites/${siteId}/waninterfaces/${wanInterfaceId}/status` + : `/v2.1/api/sites/${siteId}/waninterfaces/${wanInterfaceId}/status`; +} + +/** + * Runtime status for one site WAN interface. + * + * GET /sdwan/v2.1/api/sites/{siteId}/waninterfaces/{wanInterfaceId}/status + * + * Returns a normalized object or null on failure. Never throws. + * + * @param {string} siteId + * @param {string} wanInterfaceId + * @returns {Promise<{operationalUp: boolean|null, adminUp: boolean|null, elementId: string|null, raw: object}|null>} + */ +export async function getWanInterfaceStatus(siteId, wanInterfaceId) { + if (!siteId || !wanInterfaceId) return null; + try { + const res = await paloAltoAxios.get(waninterfaceStatusPath(siteId, wanInterfaceId)); + const body = res.data || {}; + return normalizeWanInterfaceStatus(body); + } catch (err) { + logger('paloalto:sites', + `waninterface status ${siteId}/${wanInterfaceId} failed: ${err.message}`, 'debug'); + return null; + } +} + +/** + * Fan-out runtime status for every waninterface at a site. + * + * @param {string} siteId + * @param {Array<{id: string}>|string[]} wanInterfaces + * @param {object} [opts] + * @param {number} [opts.concurrency=4] + * @returns {Promise>} interfaceId → status row + */ +export async function getWanInterfaceStatusesForSite(siteId, wanInterfaces, opts = {}) { + const map = new Map(); + if (!siteId) return map; + const ids = (Array.isArray(wanInterfaces) ? wanInterfaces : []) + .map((w) => (typeof w === 'string' ? w : w?.id)) + .filter(Boolean) + .map(String); + if (ids.length === 0) return map; + + const concurrency = Math.max(1, Number(opts.concurrency) || 4); + let i = 0; + + async function worker() { + while (i < ids.length) { + const idx = i; + i += 1; + const id = ids[idx]; + const status = await getWanInterfaceStatus(siteId, id); + if (status) map.set(id, status); + } + } + + await Promise.all( + Array.from({ length: Math.min(concurrency, ids.length) }, () => worker()), + ); + logger('paloalto:sites', + `Site ${siteId}: runtime status for ${map.size}/${ids.length} waninterface(s)`, + ); + return map; +} + +/** + * @param {object} body + * @returns {{operationalUp: boolean|null, adminUp: boolean|null, elementId: string|null, raw: object}} + */ +export function normalizeWanInterfaceStatus(body) { + const raw = body && typeof body === 'object' ? body : {}; + + const operationalUp = pickStatusBool( + raw.operational_up, + raw.operationalUp, + raw.up, + raw.reachable, + statusStringToBool(raw.status), + statusStringToBool(raw.operational_state), + statusStringToBool(raw.state), + statusStringToBool(raw.link_state), + ); + const adminUp = pickStatusBool( + raw.admin_up, + raw.adminUp, + statusStringToBool(raw.admin_state), + statusStringToBool(raw.al_admin_state), + ); + const elementId = raw.element_id || raw.elementId || null; + return { + operationalUp, + adminUp, + elementId: elementId ? String(elementId) : null, + raw, + }; +} + +function pickStatusBool(...candidates) { + for (const c of candidates) { + if (typeof c === 'boolean') return c; + } + return null; +} + +function statusStringToBool(s) { + if (typeof s !== 'string') return null; + const v = s.toLowerCase().trim(); + if (['up', 'active', 'connected', 'online', 'reachable'].includes(v)) return true; + if (['down', 'inactive', 'disconnected', 'offline', 'unreachable'].includes(v)) return false; + return null; +} + /** * Test-only — clears all caches so a fresh fetch happens next call. */ diff --git a/integrations/paloalto/topology.js b/integrations/paloalto/topology.js new file mode 100644 index 0000000..321f046 --- /dev/null +++ b/integrations/paloalto/topology.js @@ -0,0 +1,570 @@ +// integrations/paloalto/topology.js +// +// Overlay / VPN tunnel discovery for Prisma SD-WAN. +// +// Live tenant (2026-07-28, store 1005 / CG01005): +// • Query bodies MUST use `query_params` with operator objects +// ({ eq } / { in }). A plain `query: { field: [id] }` is silently +// ignored and returns an unscoped first page. +// • Prefer POST /sdwan/v2.0/api/topology/links/query — rows include +// status, peer site names, wan-if ids, and vpnlinks[]. +// • Fallback: POST /sdwan/v4.0/api/anynetlinks/query filtered by +// ep1_site_id / ep2_site_id (config rows; no runtime status). +// • vpnlinks themselves have no site fields (al_id / vep*_id only). +// +// Failures never throw — callers get [] + log. + +import { paloAltoAxios } from './client.js'; +import { logger } from '../../utils/logger.js'; + +/** Max overlay edges kept per site after ranking. */ +const MAX_TUNNELS = 12; +/** Max status GETs when topology status is missing. */ +const MAX_STATUS_FETCH = 8; + +/** Topology link types we surface as "overlay tunnels". */ +const OVERLAY_TYPES = new Set([ + 'public-anynet', + 'private-anynet', + 'vpn', + 'anynet', +]); + +function isSase() { + return String(process.env.PRISMA_AUTH_MODE || 'sase').toLowerCase().trim() === 'sase'; +} + +function anynetlinksQueryPath() { + return isSase() + ? '/sdwan/v4.0/api/anynetlinks/query' + : '/v4.0/api/anynetlinks/query'; +} + +function vpnlinksQueryPath() { + return isSase() + ? '/sdwan/v2.0/api/vpnlinks/query' + : '/v2.0/api/vpnlinks/query'; +} + +function vpnlinkStatusPath(vpnLinkId) { + return isSase() + ? `/sdwan/v2.2/api/vpnlinks/${vpnLinkId}/status` + : `/v2.2/api/vpnlinks/${vpnLinkId}/status`; +} + +function topologyLinksQueryPath() { + return isSase() + ? '/sdwan/v2.0/api/topology/links/query' + : '/v2.0/api/topology/links/query'; +} + +/** + * Collect every site-id-ish field we know Prisma has used across + * anynet / topology-link schemas (vpnlinks themselves do NOT carry these). + */ +export function siteIdsFromTunnelRaw(raw) { + if (!raw || typeof raw !== 'object') return []; + const ids = []; + const push = (v) => { + if (v == null || v === '') return; + if (Array.isArray(v)) { + for (const x of v) push(x); + return; + } + if (typeof v === 'object') { + push(v.id || v.site_id || v.siteId); + return; + } + ids.push(String(v)); + }; + + push(raw.source_site_id); + push(raw.target_site_id); + push(raw.dest_site_id); + push(raw.remote_site_id); + push(raw.peer_site_id); + push(raw.site_id); + push(raw.site1_id); + push(raw.site2_id); + push(raw.src_site_id); + push(raw.ep1_site_id); + push(raw.ep2_site_id); + push(raw.sites); + push(raw.site_ids); + if (raw.ep1) push(raw.ep1.site_id || raw.ep1.siteId); + if (raw.ep2) push(raw.ep2.site_id || raw.ep2.siteId); + if (raw.path) push(raw.path.site_id); + push(raw.source_site); + push(raw.target_site); + push(raw.site1); + push(raw.site2); + + return [...new Set(ids)]; +} + +function peerSiteIdForLocal(raw, localSiteId) { + const local = String(localSiteId); + if (raw.source_site_id && String(raw.source_site_id) === local) { + return raw.target_site_id || raw.dest_site_id || raw.ep2_site_id || null; + } + if (raw.target_site_id && String(raw.target_site_id) === local) { + return raw.source_site_id || raw.ep1_site_id || null; + } + if (raw.ep1_site_id && String(raw.ep1_site_id) === local) { + return raw.ep2_site_id || null; + } + if (raw.ep2_site_id && String(raw.ep2_site_id) === local) { + return raw.ep1_site_id || null; + } + const ids = siteIdsFromTunnelRaw(raw).filter((id) => id !== local); + return ids[0] || null; +} + +function peerNameFromRaw(raw, localSiteId = null) { + const local = localSiteId ? String(localSiteId) : null; + if (local && raw.source_site_id && String(raw.source_site_id) === local) { + return raw.target_site_name || raw.sep_name || raw.target_site?.name || null; + } + if (local && raw.target_site_id && String(raw.target_site_id) === local) { + return raw.source_site_name || raw.source_site?.name || null; + } + if (local && raw.ep2_site_id && String(raw.ep2_site_id) === local) { + return raw.ep1_site_name || null; + } + if (local && raw.ep1_site_id && String(raw.ep1_site_id) === local) { + return raw.ep2_site_name || null; + } + return raw.target_site_name + || raw.source_site_name + || raw.dest_site_name + || raw.remote_site_name + || raw.peer_site_name + || raw.ep2_site_name + || raw.ep1_site_name + || raw.sep_name + || raw.name + || null; +} + +function localWanIfId(raw, localSiteId) { + const local = String(localSiteId); + if (raw.source_site_id && String(raw.source_site_id) === local) { + return raw.source_wan_if_id || null; + } + if (raw.target_site_id && String(raw.target_site_id) === local) { + return raw.target_wan_if_id || null; + } + if (raw.ep1_site_id && String(raw.ep1_site_id) === local) { + return raw.ep1_wan_interface_id || null; + } + if (raw.ep2_site_id && String(raw.ep2_site_id) === local) { + return raw.ep2_wan_interface_id || null; + } + return raw.source_wan_if_id + || raw.target_wan_if_id + || raw.ep1_wan_interface_id + || raw.ep2_wan_interface_id + || null; +} + +/** + * Normalize a tunnel / anynet / topology-link / vpnlink row. + * + * @param {object} raw + * @param {object|null} [status] + * @param {object} [opts] + * @param {string} [opts.localSiteId] + * @param {string} [opts.peerLabelOverride] + * @param {string} [opts.peerSiteIdOverride] + */ +export function normalizeTunnelRow(raw, status = null, opts = {}) { + if (!raw || typeof raw !== 'object') return null; + const id = raw.id || raw.vpn_link_id || raw.link_id || raw.path_id || raw.al_id || null; + if (!id) return null; + + const localSiteId = opts.localSiteId || null; + const peerSiteId = opts.peerSiteIdOverride + || (localSiteId ? peerSiteIdForLocal(raw, localSiteId) : null) + || raw.target_site_id + || raw.dest_site_id + || raw.remote_site_id + || raw.peer_site_id + || null; + const peerSiteName = opts.peerLabelOverride + || peerNameFromRaw(raw, localSiteId); + const relatedInterfaceId = (localSiteId ? localWanIfId(raw, localSiteId) : null) + || raw.source_wan_if_id + || raw.wan_interface_id + || raw.src_wan_if_id + || raw.ep1_wan_interface_id + || status?.wan_interface_id + || null; + + const adminUp = pickBool( + raw.admin_up, + raw.al_admin_state === 'up' ? true : (raw.al_admin_state === 'down' ? false : null), + status?.admin_up, + status?.adminUp, + ); + const operational = pickBool( + status?.operational_up, + status?.operationalUp, + status?.up, + raw.operational_up, + raw.al_state === 'up' ? true : (raw.al_state === 'down' ? false : null), + typeof raw.active === 'boolean' ? raw.active : null, + typeof raw.usable === 'boolean' ? raw.usable : null, + status?.status === 'up' ? true : (status?.status === 'down' ? false : null), + normalizeStatusString(raw.status), + normalizeStatusString(raw.link_status), + normalizeStatusString(status?.link_status || status?.state), + ); + + const state = String( + status?.link_status + || status?.state + || raw.al_state + || raw.status + || raw.link_status + || (operational === true ? 'up' : (operational === false ? 'down' : 'unknown')), + ).toLowerCase(); + + const vpnLinkIds = Array.isArray(raw.vpnlinks) + ? raw.vpnlinks.map(String) + : (raw.vpn_link_id ? [String(raw.vpn_link_id)] : []); + + return { + id: String(id), + anynetId: raw.al_id ? String(raw.al_id) : (raw.anynet_id ? String(raw.anynet_id) : null), + peerSiteId: peerSiteId ? String(peerSiteId) : null, + peerLabel: peerSiteName || (peerSiteId ? String(peerSiteId) : 'peer'), + state, + up: operational, + adminUp, + relatedInterfaceId: relatedInterfaceId ? String(relatedInterfaceId) : null, + linkType: raw.type || raw.link_type || raw.sub_type || null, + circuitName: (() => { + if (!localSiteId) { + return raw.target_circuit_name || raw.source_circuit_name || null; + } + const local = String(localSiteId); + if (raw.target_site_id && String(raw.target_site_id) === local) { + return raw.target_circuit_name || null; + } + if (raw.source_site_id && String(raw.source_site_id) === local) { + return raw.source_circuit_name || null; + } + return raw.target_circuit_name || raw.source_circuit_name || null; + })(), + vpnLinkIds, + vep1Id: raw.vep1_id ? String(raw.vep1_id) : null, + vep2Id: raw.vep2_id ? String(raw.vep2_id) : null, + raw, + statusRaw: status, + }; +} + +function pickBool(...candidates) { + for (const c of candidates) { + if (typeof c === 'boolean') return c; + } + return null; +} + +function normalizeStatusString(s) { + if (typeof s !== 'string') return null; + const v = s.toLowerCase().trim(); + if (['up', 'active', 'connected', 'established'].includes(v)) return true; + if (['down', 'inactive', 'disconnected', 'failed'].includes(v)) return false; + return null; +} + +function tunnelInvolvesSite(raw, siteId) { + return siteIdsFromTunnelRaw(raw).includes(String(siteId)); +} + +function isOverlayTopologyLink(raw) { + const t = String(raw?.type || '').toLowerCase(); + if (!t) return true; + if (OVERLAY_TYPES.has(t)) return true; + // Keep unknown types that look like site-to-site fabric; drop + // Prisma service links (SEP) unless nothing else exists. + if (t === 'servicelink' || t === 'standard-vpn') return false; + return true; +} + +/** + * Site-scoped overlay discovery via topology/links (preferred) or + * anynetlinks. Uses CloudGenix `query_params` + operators. + */ +export async function getVpnLinksForSite(siteId, opts = {}) { + if (!siteId) return []; + const fetchStatus = opts.fetchStatus !== false; + const concurrency = Math.max(1, Number(opts.statusConcurrency) || 2); + const siteNames = opts.siteNames instanceof Map ? opts.siteNames : null; + + let items = []; + let source = 'topology/links'; + + const topo = await queryTopologyLinksForSite(siteId); + if (topo.length > 0) { + const overlay = topo.filter(isOverlayTopologyLink); + const use = overlay.length > 0 ? overlay : topo; + items = use.map((raw) => { + const peerSiteId = peerSiteIdForLocal(raw, siteId); + const peerLabel = (siteNames && peerSiteId && siteNames.get(String(peerSiteId))) + || peerNameFromRaw(raw, siteId) + || peerSiteId + || 'peer'; + return normalizeTunnelRow(raw, null, { + localSiteId: siteId, + peerSiteIdOverride: peerSiteId, + peerLabelOverride: peerLabel, + }); + }).filter(Boolean); + } + + if (items.length === 0) { + source = 'anynet'; + const anynets = await queryAnynetLinksForSite(siteId); + items = anynets.map((al) => { + const peerSiteId = peerSiteIdForLocal(al, siteId); + const peerLabel = (siteNames && peerSiteId && siteNames.get(String(peerSiteId))) + || peerNameFromRaw(al, siteId) + || peerSiteId + || 'peer'; + return normalizeTunnelRow(al, null, { + localSiteId: siteId, + peerSiteIdOverride: peerSiteId, + peerLabelOverride: peerLabel, + }); + }).filter(Boolean); + } + + if (items.length === 0) { + logger('paloalto:topology', `Site ${siteId}: 0 tunnels after site-scoped discovery`); + return []; + } + + // Rank: down / unknown first, then cap — never dump dozens of hub paths. + items = rankAndCapTunnels(items, MAX_TUNNELS); + + const needsStatus = fetchStatus + && items.some((t) => t.up == null); + if (needsStatus) { + const statusIds = []; + for (const t of items) { + if (t.up != null) continue; + if (t.vpnLinkIds?.length) statusIds.push(t.vpnLinkIds[0]); + else if (t.id) statusIds.push(t.id); + if (statusIds.length >= MAX_STATUS_FETCH) break; + } + const statusById = await fetchVpnLinkStatuses(statusIds, concurrency); + items = items.map((row) => { + const sid = row.vpnLinkIds?.[0] || row.id; + const status = statusById.get(String(sid)); + if (!status) return row; + return normalizeTunnelRow(row.raw || row, status, { + localSiteId: siteId, + peerSiteIdOverride: row.peerSiteId, + peerLabelOverride: row.peerLabel, + }) || row; + }); + } + + logger('paloalto:topology', + `Site ${siteId}: ${items.length} tunnel(s) via ${source}`); + return items; +} + +function rankAndCapTunnels(items, max) { + const ranked = [...items].sort((a, b) => scoreTunnel(b) - scoreTunnel(a)); + if (ranked.length <= max) return ranked; + logger('paloalto:topology', + `Capping tunnels ${ranked.length} → ${max} (down/unknown preferred)`, 'debug'); + return ranked.slice(0, max); +} + +function scoreTunnel(t) { + if (t.up === false) return 4; + if (t.recentAlarm) return 3; + if (t.up == null) return 2; + if (t.up === true) return 1; + return 0; +} + +/** + * POST topology/links/query with query_params operators. + * Queries both source_site_id and target_site_id (spoke may be either side). + */ +export async function queryTopologyLinksForSite(siteId) { + const path = topologyLinksQueryPath(); + const sid = String(siteId); + const bodies = [ + { query_params: { target_site_id: { in: [sid] } }, limit: 50 }, + { query_params: { source_site_id: { in: [sid] } }, limit: 50 }, + ]; + + const collected = []; + for (const body of bodies) { + try { + const res = await paloAltoAxios.post(path, body); + const items = res.data?.items || res.data?.data || res.data?.links || []; + if (!Array.isArray(items) || items.length === 0) continue; + const matched = items.filter((l) => tunnelInvolvesSite(l, siteId)); + // Server filtered via query_params — trust page even if site fields + // are oddly shaped, but prefer verified matches. + const keep = matched.length > 0 ? matched : items; + collected.push(...keep); + logger('paloalto:topology', + `Site ${siteId}: topology/links returned ${keep.length}` + + (res.data?.total_count != null ? ` (total_count=${res.data.total_count})` : ''), + 'debug'); + } catch (err) { + logger('paloalto:topology', + `topology/links/query failed: ${err.message}`, 'debug'); + } + } + return dedupeById(collected); +} + +/** + * POST anynetlinks/query with query_params { ep1|ep2_site_id: { eq } }. + */ +export async function queryAnynetLinksForSite(siteId) { + const path = anynetlinksQueryPath(); + const sid = String(siteId); + const bodies = [ + { query_params: { ep2_site_id: { eq: sid } }, limit: 50 }, + { query_params: { ep1_site_id: { eq: sid } }, limit: 50 }, + { query_params: { ep2_site_id: { in: [sid] } }, limit: 50 }, + { query_params: { ep1_site_id: { in: [sid] } }, limit: 50 }, + ]; + + const collected = []; + for (const body of bodies) { + try { + const res = await paloAltoAxios.post(path, body); + const items = res.data?.items || res.data?.data || []; + if (!Array.isArray(items) || items.length === 0) continue; + const matched = items.filter((l) => tunnelInvolvesSite(l, siteId)); + if (matched.length === 0) { + logger('paloalto:topology', + `anynetlinks/query returned ${items.length} but 0 matched site ${siteId}; ` + + `sample keys=[${Object.keys(items[0] || {}).join(',')}]`, + 'warn'); + continue; + } + collected.push(...matched); + logger('paloalto:topology', + `Site ${siteId}: ${matched.length} anynetlink(s)` + + (res.data?.total_count != null ? ` (total_count=${res.data.total_count})` : ''), + 'debug'); + // Spoke sites are usually ep2; stop once we have matches. + break; + } catch (err) { + logger('paloalto:topology', + `anynetlinks/query failed: ${err.message}`, 'debug'); + } + } + return dedupeById(collected); +} + +/** + * POST vpnlinks/query filtered by al_id. + * Note: vpnlinks/query also needs query_params on some tenants; try both. + */ +export async function queryVpnLinksByAnynetId(alId) { + if (!alId) return []; + const path = vpnlinksQueryPath(); + const id = String(alId); + const candidates = [ + { query_params: { al_id: { eq: id } }, limit: 20 }, + { query_params: { al_id: { in: [id] } }, limit: 20 }, + { query: { al_id: [id] }, limit: 20 }, + ]; + for (const body of candidates) { + try { + const res = await paloAltoAxios.post(path, body); + const items = res.data?.items || res.data?.data || []; + if (Array.isArray(items) && items.length > 0) return items; + } catch (err) { + logger('paloalto:topology', + `vpnlinks/query by al_id failed: ${err.message}`, 'debug'); + } + } + return []; +} + +/** @deprecated prefer getVpnLinksForSite */ +export async function queryVpnLinksForSite(siteId) { + const anynets = await queryAnynetLinksForSite(siteId); + if (anynets.length === 0) return []; + const out = []; + for (const al of anynets.slice(0, MAX_TUNNELS)) { + const alId = al.id || al.al_id; + if (!alId) continue; + out.push(...await queryVpnLinksByAnynetId(alId)); + } + return dedupeById(out); +} + +function dedupeById(items) { + const seen = new Set(); + const out = []; + for (const item of items) { + const id = String(item?.id || item?.vpn_link_id || item?.path_id || item?.al_id || ''); + if (!id || seen.has(id)) continue; + seen.add(id); + out.push(item); + } + return out; +} + +export async function fetchVpnLinkStatuses(ids, concurrency = 2) { + const map = new Map(); + const list = [...new Set((ids || []).map(String).filter(Boolean))]; + let i = 0; + + async function worker() { + while (i < list.length) { + const idx = i; + i += 1; + const id = list[idx]; + try { + const res = await paloAltoAxios.get(vpnlinkStatusPath(id)); + map.set(id, res.data || {}); + } catch (err) { + logger('paloalto:topology', + `vpnlink status ${id} failed: ${err.message}`, 'debug'); + } + } + } + + if (list.length === 0) return map; + await Promise.all( + Array.from({ length: Math.min(concurrency, list.length) }, () => worker()), + ); + return map; +} + +export function extractVpnLinkIdsFromAlarmInfo(info) { + if (!info) return []; + let obj = info; + if (typeof info === 'string') { + try { obj = JSON.parse(info); } catch { return []; } + } + if (typeof obj !== 'object') return []; + + const ids = new Set(); + if (obj.vpn_link_id) ids.add(String(obj.vpn_link_id)); + if (obj.vpnlink_id) ids.add(String(obj.vpnlink_id)); + const reasons = obj.vpn_reasons || obj.vpnReasons || []; + if (Array.isArray(reasons)) { + for (const r of reasons) { + if (r?.vpn_link_id) ids.add(String(r.vpn_link_id)); + if (r?.vpnlink_id) ids.add(String(r.vpnlink_id)); + } + } + return [...ids]; +} diff --git a/scripts/prismaProbe.js b/scripts/prismaProbe.js index bbe12c4..c85eb35 100644 --- a/scripts/prismaProbe.js +++ b/scripts/prismaProbe.js @@ -117,10 +117,15 @@ import { findSdwanSiteForStore, getElementsForSite, getWanInterfacesForSite, + getWanInterfaceStatus, + getVpnLinksForSite, getHealthscore, getLqmMetric, + getAppMetric, + getAppMetricsByPathType, getAlarms, LQM_METRIC_NAMES, + VOICE_PATH_TYPE_SUBSET, } from '../integrations/paloalto/index.js'; // ─── Arg parsing ──────────────────────────────────────────────────── @@ -311,6 +316,60 @@ async function cmdWaninterfaces(args) { console.log(pretty(wans)); } +async function cmdWiStatus(args) { + const siteId = args._[1]; + const wiId = args._[2]; + if (!siteId || !wiId) throw new Error('usage: wi-status '); + heading(`WAN interface status ${siteId}/${wiId}`); + const status = await getWanInterfaceStatus(siteId, wiId); + if (!status) { + bad('no status returned (endpoint failed or empty)'); + return; + } + ok(`operationalUp=${status.operationalUp} adminUp=${status.adminUp} elementId=${status.elementId || '?'}`); + console.log(pretty(status.raw)); +} + +async function cmdTunnels(args) { + const siteId = args._[1]; + if (!siteId) throw new Error('usage: tunnels '); + heading(`Overlay tunnels for site ${siteId}`); + const tunnels = await getVpnLinksForSite(siteId); + if (tunnels.length === 0) { + bad( + 'no tunnels — try: raw POST /sdwan/v2.0/api/topology/links/query ' + + `--body '{"query_params":{"target_site_id":{"in":["${siteId}"]}},"limit":50}' ` + + '(must use query_params + eq/in — plain query filters are ignored)', + ); + return; + } + ok(`${tunnels.length} tunnel(s)`); + for (const t of tunnels) { + const up = t.up == null ? '?' : (t.up ? 'up' : 'DOWN'); + const al = t.anynetId ? ` al=${t.anynetId}` : ''; + console.log(` - ${t.id} peer=${t.peerLabel} state=${t.state} up=${up} if=${t.relatedInterfaceId || '?'}${al}`); + } + if (args.flags.json) console.log(pretty(tunnels)); +} + +async function cmdAppByPathType(args) { + const siteId = args._[1]; + const appId = args._[2]; + const metric = args.flags.metric || 'loss'; + if (!siteId || !appId) { + throw new Error('usage: app-by-path-type [--metric loss|jitter|bandwidth|mos]'); + } + heading(`App ${metric} by path_type for site ${siteId} app ${appId}`); + const map = await getAppMetricsByPathType(siteId, appId, metric, 1440, [...VOICE_PATH_TYPE_SUBSET]); + for (const [pt, raw] of Object.entries(map)) { + const pts = raw?.metrics?.[0]?.series?.[0]?.data?.[0]?.datapoints?.length + ?? raw?.metrics?.[0]?.series?.[0]?.data?.length + ?? '?'; + console.log(` ${pt}: ${raw ? 'ok' : 'null'} (datapoints≈${pts})`); + } + if (args.flags.showBody || args.flags.json) console.log(pretty(map)); +} + async function cmdHealth(args) { const siteId = args._[1]; if (!siteId) throw new Error('usage: health '); @@ -961,10 +1020,62 @@ async function cmdTryShapes(args) { return; } + // Phase 2 discovery: does AppPerf* accept filter.path = waninterface id? + if (which === 'app-by-path') { + const appId = args._[3]; + const wiId = args._[4]; + if (!appId || !wiId) { + throw new Error('usage: try-shapes app-by-path '); + } + heading(`try-shapes app-by-path → site=${siteId} app=${appId} path=${wiId}`); + const candidates = [ + { + label: 'loss + path_type=all + path=[wi]', + body: null, + run: () => getAppMetric(siteId, appId, 'loss', { + windowMinutes: 60, + pathIds: [wiId], + }), + }, + { + label: 'loss + path_type=[VPN] + path=[wi]', + body: null, + run: () => getAppMetric(siteId, appId, 'loss', { + windowMinutes: 60, + pathTypes: ['VPN'], + pathIds: [wiId], + }), + }, + { + label: 'jitter + path=[wi]', + body: null, + run: () => getAppMetric(siteId, appId, 'jitter', { + windowMinutes: 60, + pathIds: [wiId], + }), + }, + ]; + for (const c of candidates) { + process.stdout.write(` ${c.label} → ${ICON_WAIT}`); + const started = Date.now(); + const raw = await c.run(); + const ms = Date.now() - started; + if (raw) { + console.log(`${ICON_OK} data (${ms}ms)`); + if (args.flags.showBody) console.log(pretty(raw).slice(0, 500)); + } else { + console.log(`${ICON_ERR} null/failed (${ms}ms)`); + } + } + info('If all null, filter.path is likely rejected — keep path_type-only attribution.'); + return; + } + throw new Error( `unknown try-shapes target "${which}" — expected "health", "lqm", ` + `"lqm-latency", "lqm-jitter", "lqm-loss", "lqm-mos", ` + - `"app-list", "app-audio-mos", "app-audio-loss", "app-audio-jitter", or "app-audio-bandwidth"`, + `"app-list", "app-audio-mos", "app-audio-loss", "app-audio-jitter", "app-audio-bandwidth", ` + + `or "app-by-path"`, ); } @@ -1097,6 +1208,9 @@ function printHelp() { ' site Resolve store → site', ' elements List site elements', ' waninterfaces List site waninterfaces', + ' wi-status Runtime waninterface status', + ' tunnels Overlay / VPN tunnels for site', + ' app-by-path-type Voice DPI broken out by path_type', ' health Fetch healthscore', ' lqm [--metric X] Fetch LQM metric (latency|jitter|loss|mos)', ' alarms [--window minutes] Fetch alarms', @@ -1113,6 +1227,7 @@ function printHelp() { ' try-shapes app-audio-loss [app=rtp-base] Sweep per-app audio loss shapes', ' try-shapes app-audio-jitter [app=rtp-base] Sweep per-app audio jitter shapes', ' try-shapes app-audio-bandwidth [app=rtp-base] Sweep per-app audio BW shapes', + ' try-shapes app-by-path Probe filter.path for voice DPI', '', 'Global flags:', ' --json JSON output', @@ -1148,6 +1263,9 @@ async function main() { site: cmdSite, elements: cmdElements, waninterfaces: cmdWaninterfaces, + 'wi-status': cmdWiStatus, + tunnels: cmdTunnels, + 'app-by-path-type': cmdAppByPathType, health: cmdHealth, lqm: cmdLqm, alarms: cmdAlarms, diff --git a/services/callReport/joinCallQuality.js b/services/callReport/joinCallQuality.js index 36bb1c6..ffbd21e 100644 --- a/services/callReport/joinCallQuality.js +++ b/services/callReport/joinCallQuality.js @@ -10,6 +10,7 @@ import { summarizeCalls, } from './groupCdrCalls.js'; import { filterCallsByTarget, rebucketCalls } from './filterCallsByTarget.js'; +import { annotateWanWithPath } from './voicePathAttribution.js'; const DEFAULT_INTERVAL_MS = 5 * 60 * 1000; @@ -97,10 +98,17 @@ function worstFromTimedOverlap(appAudio, startMs, endMs, winStartMs) { } /** - * Find worst Prisma 5-min bucket overlapping [start, end]. + * Find worst Prisma 5-min bucket overlapping [start, end], then + * annotate with likely path_type / pathId when available. */ export function worstPrismaBucketForCall(call, appAudio, window) { - if (!appAudio?.mos?.values?.length && !appAudio?.mos?.timedPoints?.length) return null; + if (!appAudio?.mos?.values?.length && !appAudio?.mos?.timedPoints?.length) { + // Still allow path_type-only annotation when MOS series is empty + // but byPathType has data (AppPerf* without MOS). + if (!appAudio?.byPathType && !appAudio?.byPath) return null; + const empty = { siteLevelApprox: true, mos: null, loss: null, jitter: null }; + return annotateWanWithPath(empty, appAudio); + } const startMs = call.start ? new Date(call.start).getTime() : NaN; const endMs = Number.isFinite(startMs) ? startMs + (call.duration || 0) * 1000 @@ -108,7 +116,8 @@ export function worstPrismaBucketForCall(call, appAudio, window) { if (!Number.isFinite(startMs)) return null; const winStartMs = window?.startTime ? new Date(window.startTime).getTime() : startMs; - return worstFromTimedOverlap(appAudio, startMs, endMs, winStartMs); + const bucket = worstFromTimedOverlap(appAudio, startMs, endMs, winStartMs); + return annotateWanWithPath(bucket, appAudio); } export function joinCallQuality({ cdrItems, appAudio, window, filter = null }) { diff --git a/services/callReport/voicePathAttribution.js b/services/callReport/voicePathAttribution.js new file mode 100644 index 0000000..330c68b --- /dev/null +++ b/services/callReport/voicePathAttribution.js @@ -0,0 +1,132 @@ +// services/callReport/voicePathAttribution.js +// +// Phase 2 helpers: annotate a call's WAN quality with the *likely* +// path_type and/or waninterface when Prisma DPI is broken out. +// +// Confidence levels: +// - site — only site-wide appAudio (legacy siteLevelApprox) +// - path_type — byPathType uniquely implicates one path_type +// - path — byPath uniquely implicates one waninterface id +// - endpoint — reserved; requires flow/session join keys +// (not available on this tenant yet — see +// services/voiceDiag/README.md § Voice path attribution) +// +// Never claims "confirmed" without flow records. + +const PATH_TYPE_ORDER = ['VPN', 'PrivateWAN', 'PrivateVPN', 'DirectInternet', 'ServiceLink']; + +/** + * Score a path-type / path summary for "how bad during this call". + * Higher = worse. Uses loss max and jitter max when present. + */ +function badnessScore(summary) { + if (!summary) return null; + const loss = summary.loss?.max ?? summary.loss?.avg; + const jitter = summary.jitter?.max ?? summary.jitter?.avg; + const mos = summary.mos?.min ?? summary.mos?.avg; + let score = 0; + let parts = 0; + if (typeof loss === 'number') { score += loss; parts += 1; } + if (typeof jitter === 'number') { score += jitter / 10; parts += 1; } + if (typeof mos === 'number') { score += Math.max(0, 5 - mos) * 5; parts += 1; } + return parts > 0 ? score : null; +} + +/** + * Pick the uniquely-worst entry from a map of summaries. + * Returns null when tie / empty / insufficient data. + * + * @param {Record} map + * @returns {{ key: string, score: number, margin: number }|null} + */ +export function pickUniqueWorst(map) { + if (!map || typeof map !== 'object') return null; + const scored = Object.entries(map) + .map(([key, summary]) => ({ key, score: badnessScore(summary) })) + .filter((e) => e.score != null) + .sort((a, b) => b.score - a.score); + if (scored.length === 0) return null; + if (scored.length === 1) { + return { key: scored[0].key, score: scored[0].score, margin: scored[0].score }; + } + const margin = scored[0].score - scored[1].score; + // Require a meaningful gap so we don't flip-flop on noise. + if (margin < 0.5) return null; + return { key: scored[0].key, score: scored[0].score, margin }; +} + +/** + * Annotate a site-level wan bucket with likely path attribution. + * + * @param {object|null} wanBucket from worstPrismaBucketForCall + * @param {object|null} appAudio collectSdwanForStore().appAudio + * @returns {object|null} + */ +export function annotateWanWithPath(wanBucket, appAudio) { + if (!wanBucket) return null; + + const base = { + ...wanBucket, + siteLevelApprox: wanBucket.siteLevelApprox !== false, + confidence: 'site', + pathType: null, + pathId: null, + attributionNote: null, + }; + + // Prefer per-circuit when present (Phase 2 path filter). + const byPath = appAudio?.byPath; + if (byPath && Object.keys(byPath).length > 0) { + const pick = pickUniqueWorst(byPath); + if (pick) { + return { + ...base, + siteLevelApprox: false, + confidence: 'path', + pathId: pick.key, + pathType: null, + attributionNote: + `Likely on WAN circuit ${pick.key} ` + + `(worst path DPI during call window; confidence=path).`, + }; + } + } + + // Fall back to path_type breakout (Phase 1). + const byPt = appAudio?.byPathType; + if (byPt && Object.keys(byPt).length > 0) { + // Prefer known order for stable ties that somehow pass margin. + const ordered = {}; + for (const k of PATH_TYPE_ORDER) { + if (byPt[k]) ordered[k] = byPt[k]; + } + for (const k of Object.keys(byPt)) { + if (!ordered[k]) ordered[k] = byPt[k]; + } + const pick = pickUniqueWorst(ordered); + if (pick) { + return { + ...base, + siteLevelApprox: true, + confidence: 'path_type', + pathType: pick.key, + pathId: null, + attributionNote: + `Likely path type ${pick.key} ` + + `(worst path_type DPI in window; confidence=path_type, still site-level).`, + }; + } + } + + return base; +} + +/** + * Phase 2c stub: per-handset / per-extension attribution. + * Returns null until Prisma exposes a join key (client IP / flow). + * + * @returns {null} + */ +export function attributeVoicePathToEndpoint(/* call, inventory */) { + return null; +} diff --git a/services/enrichment/sdwanEnrichment.js b/services/enrichment/sdwanEnrichment.js index acc432f..86d2d8d 100644 --- a/services/enrichment/sdwanEnrichment.js +++ b/services/enrichment/sdwanEnrichment.js @@ -17,9 +17,11 @@ // { // interfaceId, // Prisma waninterface id // interfaceName, // human label -// elementId, // null in phase 1 — waninterfaces are site-scoped, not element-scoped +// elementId, // from runtime status when available +// networkName, // wan_network_name when present // transportType, // "primary" | "secondary" | "backup" | ... (from used_for) -// up: boolean | null, // waninterface admin_up (proxy until runtime status is wired) +// up: boolean | null, // prefers operationalUp, then adminUp, then LQM samples +// adminUp, operationalUp, // latencyMs: number | null, // jitterMs: number | null, // lossPct: number | null, @@ -27,9 +29,15 @@ // }, // ... // ], +// tunnels: [{ id, peerLabel, state, up, relatedInterfaceId, recentAlarm }], // alarms: { // last1h: { critical: N, major: N, minor: N }, -// samples: [{ code, message, severity, ts }] // top 5, most recent +// samples: [{ code, message, severity, ts, vpnLinkIds }] +// }, +// appAudio: { +// ..., +// byPathType: { VPN: { loss, jitter, bandwidth }, ... }, +// byPath: { [wanInterfaceId]: { loss, jitter } } | null, // gated // }, // errors: [{ scope, message }], // per-metric failure lines // fetchedAt, @@ -56,13 +64,18 @@ import { findSdwanSiteForStore, getElementsForSite, getWanInterfacesForSite, + getWanInterfaceStatusesForSite, + getAllSites, } from '../../integrations/paloalto/sites.js'; import { getHealthscore, getLqmMetric, getAppMetric, + getAppMetricsByPathType, getAlarms, + VOICE_PATH_TYPE_SUBSET, } from '../../integrations/paloalto/metrics.js'; +import { getVpnLinksForSite, extractVpnLinkIdsFromAlarmInfo } from '../../integrations/paloalto/topology.js'; import { buildAppDetailsUrl } from '../../integrations/paloalto/urls.js'; /** @@ -108,6 +121,7 @@ export async function collectSdwanForStore(storeNum, opts = {}) { elements: [], healthscore: null, links: emptyLinks, + tunnels: [], alarms: emptyAlarms(), appAudio: null, errors, @@ -131,19 +145,15 @@ export async function collectSdwanForStore(storeNum, opts = {}) { 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. + // Runtime circuit status + overlay tunnels in parallel with metrics. + // Both are best-effort — failures land in errors[] without aborting. const canFetchLqm = waninterfaceIds.length > 0; const voiceAppCfg = resolveVoiceAppConfig(); const voiceAppId = voiceAppCfg.appId; const voiceAppName = voiceAppCfg.appName; const canFetchAppMetrics = Boolean(voiceAppId); + const pathAttributionEnabled = isPathAttributionEnabled(); + const [ healthscoreRes, latencyRes, @@ -155,6 +165,8 @@ export async function collectSdwanForStore(storeNum, opts = {}) { appLossRes, appJitterRes, appBwRes, + statusMapRes, + tunnelsRes, ] = await Promise.allSettled([ getHealthscore(site.id, windowMinutes), canFetchLqm ? getLqmMetric(site.id, waninterfaceIds, 'latency', windowMinutes) : Promise.resolve(null), @@ -166,6 +178,10 @@ export async function collectSdwanForStore(storeNum, opts = {}) { 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), + canFetchLqm + ? getWanInterfaceStatusesForSite(site.id, wanInterfaces) + : Promise.resolve(new Map()), + collectTunnelsForSite(site.id), ]); recordFailure(errors, 'healthscore', healthscoreRes); @@ -174,22 +190,41 @@ export async function collectSdwanForStore(storeNum, opts = {}) { recordFailure(errors, 'lqm.loss', lossRes); recordFailure(errors, 'lqm.mos', mosRes); recordFailure(errors, 'alarms', alarmsRes); + recordFailure(errors, 'waninterfaces.status', statusMapRes); + recordFailure(errors, 'tunnels', tunnelsRes); 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); } + // Path-type breakout runs AFTER the core batch so it doesn't compete + // with LQM/app aggregate calls for the PRISMA_MAX_INFLIGHT=3 budget + // (parallel fan-out previously caused cascading 429s). Only loss for + // VPN + DirectInternet by default — enough to spot overlay vs DIA. + let appLossByPt = {}; + let appJitterByPt = {}; + let appBwByPt = {}; + if (canFetchAppMetrics && isPathTypeBreakoutEnabled()) { + try { + appLossByPt = await getAppMetricsByPathType( + site.id, voiceAppId, 'loss', windowMinutes, ['VPN', 'DirectInternet'], + ); + } catch (err) { + errors.push({ scope: 'app.voice.loss.byPathType', message: err.message }); + } + } + // 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 statusByInterfaceId = valueOf(statusMapRes) instanceof Map + ? valueOf(statusMapRes) + : new Map(); const links = buildLinkRows({ wanInterfaces, + statusByInterfaceId, metricResponses: { latency: valueOf(latencyRes), jitter: valueOf(jitterRes), @@ -200,6 +235,23 @@ export async function collectSdwanForStore(storeNum, opts = {}) { // 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); + const tunnels = annotateTunnelsWithAlarms( + Array.isArray(valueOf(tunnelsRes)) ? valueOf(tunnelsRes) : [], + alarms, + ); + + // Phase 2 optional: per-waninterface app DPI (filter.path). Gated + // because it multiplies request count. Failures stay in errors[]. + let appAudioByPath = null; + if (canFetchAppMetrics && pathAttributionEnabled && waninterfaceIds.length > 0) { + appAudioByPath = await collectAppAudioByPath( + site.id, + voiceAppId, + waninterfaceIds, + windowMinutes, + errors, + ); + } // Debug shape probes: on high error counts these give us the // exact top-level keys Prisma returned, which is the fastest way @@ -256,6 +308,12 @@ export async function collectSdwanForStore(storeNum, opts = {}) { loss: summarizeAppSeries(valueOf(appLossRes), 'AppPerfUDPAudioPacketLoss'), jitter: summarizeAppSeries(valueOf(appJitterRes), 'AppPerfUDPAudioJitter'), bandwidth: summarizeAppSeries(valueOf(appBwRes), 'AppPerfUDPAudioBandwidth'), + byPathType: buildAppAudioByPathType({ + lossByType: appLossByPt, + jitterByType: appJitterByPt, + bandwidthByType: appBwByPt, + }), + byPath: appAudioByPath, } : null; @@ -266,10 +324,11 @@ export async function collectSdwanForStore(storeNum, opts = {}) { const appAudioDiag = appAudio ? ` app=${appAudio.appName}(${appAudio.appId}) app.mos=[${appAudio.mos?.samples || 0}pts,min=${appAudio.mos?.min ?? 'n/a'}]` : ''; + const tunnelDiag = ` tunnels=${tunnels.length}`; logger( 'sdwan:enrich', `store ${storeNum} → site ${site.name} (${site.id}): ${elements.length} elements, ` + - `${links.length} links, health=${healthscore?.value ?? 'n/a'},${alarmDiag}${appAudioDiag} ` + + `${links.length} links,${tunnelDiag} health=${healthscore?.value ?? 'n/a'},${alarmDiag}${appAudioDiag} ` + `errors=${errors.length}, window=${windowMinutes}m/alarms=${alarmWindowMinutes}m, ${elapsed}ms`, ); @@ -290,6 +349,7 @@ export async function collectSdwanForStore(storeNum, opts = {}) { })), healthscore, links, + tunnels, alarms, // Per-application "Application Path Details" style metrics — real // voice-quality signal from DPI on actual RTP traffic. Null when @@ -737,31 +797,37 @@ function extractLqmValue(dataObj, metricKey) { * 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. + * `up` prefers runtime operational status when available, then + * waninterface config `admin_up`, then "has LQM samples" as a last + * resort for unknown admin state. */ -export function buildLinkRows({ wanInterfaces, metricResponses }) { +export function buildLinkRows({ wanInterfaces, metricResponses, statusByInterfaceId = null }) { + const statusMap = statusByInterfaceId instanceof Map ? statusByInterfaceId : null; const rows = new Map(); for (const w of Array.isArray(wanInterfaces) ? wanInterfaces : []) { + const status = statusMap?.get(String(w.id)) || null; + const adminUp = typeof status?.adminUp === 'boolean' ? status.adminUp : w.adminUp; + const operationalUp = typeof status?.operationalUp === 'boolean' + ? status.operationalUp + : null; + // Prefer operational; fall back to admin; leave null if both unknown. + let up = operationalUp; + if (up == null) up = adminUp; + 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. + elementId: status?.elementId || null, + networkName: w.wanNetworkName || null, + // usedFor is 'primary' / 'secondary' / 'lte-backup' etc. transportType: w.usedFor || null, - up: w.adminUp, // admin state = closest available "is it up" until we wire runtime status + adminUp, + operationalUp, + up, 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, }); } @@ -865,11 +931,13 @@ export function parseAlarms(resp, siteId = null) { const infoText = typeof a?.info === 'string' ? a.info : (a?.info ? JSON.stringify(a.info).slice(0, 200) : ''); + const vpnLinkIds = extractVpnLinkIdsFromAlarmInfo(a?.info); 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, + vpnLinkIds, }); } samples.sort((a, b) => String(b.ts || '').localeCompare(String(a.ts || ''))); @@ -891,3 +959,114 @@ function round(n, decimals) { const p = Math.pow(10, decimals); return Math.round(n * p) / p; } + +function isPathAttributionEnabled() { + const raw = String(process.env.PRISMA_VOICE_PATH_ATTRIBUTION || '').toLowerCase().trim(); + return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; +} + +/** Path-type DPI breakout — off by default (extra Prisma calls / 429 risk). */ +function isPathTypeBreakoutEnabled() { + const raw = String(process.env.PRISMA_VOICE_PATH_TYPE_BREAKOUT || '').toLowerCase().trim(); + return raw === '1' || raw === 'true' || raw === 'yes' || raw === 'on'; +} + +async function collectTunnelsForSite(siteId) { + let siteNames = null; + try { + const sites = await getAllSites(); + siteNames = new Map( + (sites || []).filter((s) => s?.id).map((s) => [String(s.id), s.name || String(s.id)]), + ); + } catch { + siteNames = null; + } + return getVpnLinksForSite(siteId, { + // Status GETs are capped inside getVpnLinksForSite; keep enabled + // for the small filtered set so we get real up/down when possible. + fetchStatus: true, + statusConcurrency: 2, + siteNames, + }); +} + +/** + * Build byPathType map from parallel getAppMetricsByPathType results. + * Keys are path_type strings (VPN, DirectInternet, …). + */ +export function buildAppAudioByPathType({ lossByType, jitterByType, bandwidthByType }) { + const keys = new Set([ + ...Object.keys(lossByType || {}), + ...Object.keys(jitterByType || {}), + ...Object.keys(bandwidthByType || {}), + ...VOICE_PATH_TYPE_SUBSET, + ]); + keys.delete('__all__'); + const out = {}; + for (const pt of keys) { + const loss = summarizeAppSeries(lossByType?.[pt], 'AppPerfUDPAudioPacketLoss'); + const jitter = summarizeAppSeries(jitterByType?.[pt], 'AppPerfUDPAudioJitter'); + const bandwidth = summarizeAppSeries(bandwidthByType?.[pt], 'AppPerfUDPAudioBandwidth'); + const any = [loss, jitter, bandwidth].some((s) => s && s.validSamples > 0); + if (!any) continue; + out[pt] = { loss, jitter, bandwidth, mos: null }; + } + return out; +} + +/** + * Mark tunnels that appear in recent alarm vpn_link_ids. + */ +export function annotateTunnelsWithAlarms(tunnels, alarms) { + const list = Array.isArray(tunnels) ? tunnels : []; + const alarmed = new Set(); + for (const s of alarms?.samples || []) { + for (const id of s.vpnLinkIds || []) alarmed.add(String(id)); + } + return list.map((t) => ({ + ...t, + recentAlarm: alarmed.has(String(t.id)), + })); +} + +/** + * Phase 2: per-waninterface app DPI. One loss+jitter fetch per path + * (bandwidth optional to limit fan-out). Returns Map-like object + * keyed by interface id, or null if nothing useful came back. + */ +async function collectAppAudioByPath(siteId, appId, waninterfaceIds, windowMinutes, errors) { + const byPath = {}; + const ids = (waninterfaceIds || []).map(String).filter(Boolean); + // Cap fan-out — stores with many circuits would otherwise explode + // request count (2 metrics × N paths). + const capped = ids.slice(0, 4); + + await Promise.all(capped.map(async (pathId) => { + try { + const [lossRaw, jitterRaw] = await Promise.all([ + getAppMetric(siteId, appId, 'loss', { + windowMinutes, + pathIds: [pathId], + pathTypes: [...VOICE_PATH_TYPE_SUBSET], + }), + getAppMetric(siteId, appId, 'jitter', { + windowMinutes, + pathIds: [pathId], + pathTypes: [...VOICE_PATH_TYPE_SUBSET], + }), + ]); + const loss = summarizeAppSeries(lossRaw, 'AppPerfUDPAudioPacketLoss'); + const jitter = summarizeAppSeries(jitterRaw, 'AppPerfUDPAudioJitter'); + if ((loss?.validSamples || 0) + (jitter?.validSamples || 0) === 0) return; + byPath[pathId] = { loss, jitter, mos: null, bandwidth: null }; + } catch (err) { + errors.push({ + scope: `app.voice.path.${pathId}`, + message: err?.message || String(err), + }); + } + })); + + return Object.keys(byPath).length > 0 ? byPath : null; +} + diff --git a/services/renderers/wanDiagnosticsRenderer.js b/services/renderers/wanDiagnosticsRenderer.js index 588fe80..fa007ea 100644 --- a/services/renderers/wanDiagnosticsRenderer.js +++ b/services/renderers/wanDiagnosticsRenderer.js @@ -93,6 +93,9 @@ export function renderWanDiagnosticsMarkdown(data, opts = {}) { out += `_No WAN path metrics available for this site._\n`; } + // Overlay / VPN tunnels (Phase 1). + out += renderTunnelsSection(data.tunnels, data.links); + // Per-app "Application Path Details" section — real voice-quality // signal from DPI on actual RTP traffic. Which app is measured // depends on the tenant's configured voice app (Webex_Calling_RTP, @@ -221,6 +224,90 @@ function renderAppAudioSection(appAudio, window) { out += `- ${line}\n`; } + // Phase 1: per-path_type breakout (VPN vs DirectInternet vs …). + const byPt = appAudio.byPathType || {}; + const ptKeys = Object.keys(byPt); + if (ptKeys.length > 0) { + out += `_By path type:_\n`; + for (const pt of ptKeys) { + const row = byPt[pt]; + const bits = []; + if (row.loss?.validSamples > 0) { + bits.push(`loss worst ${row.loss.max}%`); + } + if (row.jitter?.validSamples > 0) { + bits.push(`jitter worst ${row.jitter.max}ms`); + } + if (row.bandwidth?.validSamples > 0) { + bits.push(`bw avg ${row.bandwidth.avg}Mbps`); + } + if (bits.length) out += `- **${pt}:** ${bits.join(' · ')}\n`; + } + } + + // Phase 2: per-circuit attribution when enabled. + const byPath = appAudio.byPath || {}; + const pathKeys = Object.keys(byPath); + if (pathKeys.length > 0) { + out += `_By WAN circuit (path attribution):_\n`; + for (const pathId of pathKeys) { + const row = byPath[pathId]; + const bits = []; + if (row.loss?.validSamples > 0) bits.push(`loss worst ${row.loss.max}%`); + if (row.jitter?.validSamples > 0) bits.push(`jitter worst ${row.jitter.max}ms`); + if (bits.length) out += `- \`${pathId}\`: ${bits.join(' · ')}\n`; + } + } + + return out; +} + +function renderTunnelsSection(tunnels, links) { + const list = Array.isArray(tunnels) ? tunnels : []; + if (list.length === 0) return ''; + + const physicalUp = Array.isArray(links) + && links.length > 0 + && links.every((l) => l.up !== false); + + const actionable = list.filter((t) => t.up === false || t.recentAlarm || t.up === true); + const unknownOnly = list.length > 0 && actionable.every((t) => t.up == null) + && list.every((t) => t.up == null); + + let out = `\n**Overlay tunnels** (${list.length})\n`; + + // If every tunnel is unparseable unknown, don't spam "? peer — unknown". + if (unknownOnly) { + out += `- ❓ Tunnel inventory returned ${list.length} link(s) but peer/status fields were empty — check Prisma UI topology for this site.\n`; + return out; + } + + const ranked = [...list].sort((a, b) => { + const score = (t) => (t.up === false ? 3 : (t.recentAlarm ? 2 : (t.up === true ? 1 : 0))); + return score(b) - score(a); + }); + // Prefer showing down / alarmed first; skip pure-unknown fillers when + // we already have actionable rows. Cap hard so we never dump dozens. + const toShow = ranked.filter((t) => t.up !== null || t.recentAlarm || t.peerLabel !== 'peer'); + const display = (toShow.length > 0 ? toShow : ranked).slice(0, 6); + for (const t of display) { + const icon = t.up === null ? '❓' : (t.up ? '✅' : '❌'); + const state = t.up === null ? (t.state || 'unknown') : (t.up ? 'up' : 'DOWN'); + const peer = (t.peerLabel && t.peerLabel !== 'peer') + ? t.peerLabel + : (t.peerSiteId || 'peer'); + const ifBit = t.relatedInterfaceId ? ` · if ${t.relatedInterfaceId}` : ''; + const circuitBit = t.circuitName ? ` · ${t.circuitName}` : ''; + const alarmBit = t.recentAlarm ? ' · ⚠️ recent alarm' : ''; + out += `- ${icon} **${peer}** — ${state}${circuitBit}${ifBit}${alarmBit}\n`; + } + if (ranked.length > display.length) { + out += `- _+${ranked.length - display.length} more tunnel(s)._\n`; + } + const downCount = list.filter((t) => t.up === false).length; + if (downCount > 0 && physicalUp) { + out += `_Note: ${downCount} overlay tunnel(s) down while physical WAN paths look up — voice may still be impacted._\n`; + } return out; } @@ -257,10 +344,16 @@ function fmtAppMetricLine(label, summary, unit, errThresh, warnThresh, lowIsBad) function renderOneLink(link, t) { const nameLabel = link.interfaceName || link.interfaceId; const transport = link.transportType ? ` [${link.transportType}]` : ''; + const net = link.networkName ? ` · ${link.networkName}` : ''; const upIcon = link.up === null ? '❓' : (link.up ? '✅' : '❌'); - const upText = link.up === null ? 'unknown' : (link.up ? 'up' : 'DOWN'); + let upText = link.up === null ? 'unknown' : (link.up ? 'up' : 'DOWN'); + if (link.operationalUp != null || link.adminUp != null) { + const op = link.operationalUp == null ? '?' : (link.operationalUp ? 'op-up' : 'op-down'); + const ad = link.adminUp == null ? '?' : (link.adminUp ? 'admin-up' : 'admin-down'); + upText = `${upText} (${op}/${ad})`; + } - let out = `- ${upIcon} **${nameLabel}**${transport} — ${upText}`; + let out = `- ${upIcon} **${nameLabel}**${transport}${net} — ${upText}`; const parts = []; parts.push(fmtMetric('latency', link.latencyMs, 'ms', t.latencyError, t.latencyWarn)); diff --git a/services/voiceDiag/README.md b/services/voiceDiag/README.md index f766795..f50ddd8 100644 --- a/services/voiceDiag/README.md +++ b/services/voiceDiag/README.md @@ -35,6 +35,18 @@ globally). HTTP callers get the markdown snapshot only — remediation cards are chat-only. +## Voice path attribution (WAN tunnels / which pipe) + +See [VOICE_PATH_ATTRIBUTION.md](./VOICE_PATH_ATTRIBUTION.md) for Phase 1 +surfaces (runtime circuit status, overlay tunnels, DPI by `path_type`) +and Phase 2 discovery status for per-circuit / per-handset attribution. + +Enable optional per-waninterface app DPI fan-out with: + +``` +PRISMA_VOICE_PATH_ATTRIBUTION=1 +``` + ## Prerequisites All checks call `/v1/people/{personId}/features/{feature}` (with a @@ -156,6 +168,7 @@ in parallel, and normalises to a stable shape the checks + the | `wanAppRtpLoss` | Worst 5-min window <= `WAN_STANDARD_APP_LOSS_WARN_PCT` (default 5%) | warn > 5%, **error** > 15% | DPI packet-loss for the same voice app. Same feature gate. | | `wanAppRtpJitter` | Worst 5-min window <= `WAN_STANDARD_APP_JITTER_WARN_MS` (default 30ms) | warn > 30ms, **error** > 50ms | DPI jitter for the same voice app. Same feature gate. | | `wanAlarms` | Zero critical + zero major alarms in the last hour | warn on major, **error** on critical | Minor alarms are info-only. Pass-through of Prisma severity. | +| `wanTunnels` | All overlay / VPN tunnels up | **error** if any down; warn if recent alarms | Distinguishes overlay-down vs physical-up. | Environment overrides (all optional — defaults match the standards above): diff --git a/services/voiceDiag/VOICE_PATH_ATTRIBUTION.md b/services/voiceDiag/VOICE_PATH_ATTRIBUTION.md new file mode 100644 index 0000000..fae0af7 --- /dev/null +++ b/services/voiceDiag/VOICE_PATH_ATTRIBUTION.md @@ -0,0 +1,64 @@ +# Prisma Phase 2 — Voice Path Attribution Discovery + +Time-boxed discovery notes for "voice is on this tunnel" / per-handset +attribution. Phase 1 (runtime waninterface status, overlay tunnels, +app DPI by `path_type`) ships without these. + +## Confirmed Phase 1 surfaces (pan.dev + code) + +| Need | Endpoint | Notes | +|------|----------|-------| +| Circuit runtime status | `GET /sdwan/v2.1/api/sites/{siteId}/waninterfaces/{wiId}/status` | Wired in `getWanInterfaceStatus` | +| Overlay tunnels | `POST /sdwan/v2.0/api/topology/links/query` with `query_params.{source\|target}_site_id.{in\|eq}` | Preferred — includes status, peer names, vpnlinks[] | +| Anynet fallback | `POST /sdwan/v4.0/api/anynetlinks/query` with `query_params.ep{1\|2}_site_id.{eq\|in}` | Config rows; no runtime status | +| Voice DPI by path_type | `getAppMetric` with `filter.path_type=[VPN]` etc. | AppPerf* only; AppAudioMos omits path_type | +| Alarm → tunnel id | `info.vpn_reasons[].vpn_link_id` | `extractVpnLinkIdsFromAlarmInfo` | + +## Live-tenant corrections (2026-07-28 / store 1005) + +| Issue | Fix | +|-------|-----| +| `limit: { count: N }` → 400 "expected int" | Topology/anynet `limit` is a bare int | +| `query: { site_id: [...] }` silently ignored | Use `query_params: { field: { eq\|in: … } }` (array under query_params → 400) | +| 100 tunnels all `peer — unknown` | Site-scoped topology query; resolve peer opposite local site; renderer collapses all-unknown | +| `vpnlinks` have no site fields | Prefer topology links; anynet via ep1/ep2; never unscoped vpnlinks dump | +| Pairing down volume | Prefer public-anynet; rank down/unknown first; cap ≤12; renderer shows ≤6 | +| Cascading 429s | Path-type breakout gated by `PRISMA_VOICE_PATH_TYPE_BREAKOUT`; status GETs capped | + +Probe recipes: + +```bash +node scripts/prismaProbe.js wi-status +node scripts/prismaProbe.js tunnels +node scripts/prismaProbe.js app-by-path-type --metric loss +node scripts/prismaProbe.js try-shapes app-by-path +``` + +## Phase 2a candidates (probe before implementing further) + +1. **App metrics + `filter.path`** (waninterface id) — already supported in + `getAppMetric({ pathIds })` behind `PRISMA_VOICE_PATH_ATTRIBUTION=1`. + Confirm live tenant accepts the filter (SCHEMA_CHECK_FAIL risk). + +2. **Flow / session / app-path details** — SCM UI "Application Path Details" + HAR may reveal monitor endpoints that bind RTP to a specific path. + Candidates: topology query v3.6, object_stats, app/wan contexts. + +3. **Join keys to Webex** — need client IP, subnet, or device id in Prisma + flow records to map to Meraki/Webex handset inventory. **Not confirmed.** + +## Phase 2c status + +`attributeVoicePathToEndpoint()` in +`services/callReport/voicePathAttribution.js` returns `null` until a +join key is confirmed. Path-level / path_type-level annotation is live +via `annotateWanWithPath()`. + +## Confidence language + +| Level | Meaning | +|-------|---------| +| `site` | Site-wide DPI only (`siteLevelApprox: true`) | +| `path_type` | One path_type uniquely worse in window | +| `path` | One waninterface uniquely worse (`byPath`) | +| `endpoint` | Per-handset — **not available** without flow join keys | diff --git a/services/voiceDiag/checks/index.js b/services/voiceDiag/checks/index.js index a9241be..e6ece00 100644 --- a/services/voiceDiag/checks/index.js +++ b/services/voiceDiag/checks/index.js @@ -54,6 +54,7 @@ import { wanAppRtpMosCheck } from './wan/wanAppRtpMos.js'; import { wanAppRtpLossCheck } from './wan/wanAppRtpLoss.js'; import { wanAppRtpJitterCheck } from './wan/wanAppRtpJitter.js'; import { wanAlarmsCheck } from './wan/wanAlarms.js'; +import { wanTunnelsCheck } from './wan/wanTunnels.js'; // Order: user-facing feature signals first (things an operator can // see from the phone UI), then LAN-side port hygiene, then the WAN @@ -76,6 +77,7 @@ export const CHECKS = [ wanSiteCheck, wanHealthscoreCheck, wanLinkStateCheck, + wanTunnelsCheck, wanLatencyCheck, wanJitterCheck, wanLossCheck, diff --git a/services/voiceDiag/checks/wan/wanAlarms.js b/services/voiceDiag/checks/wan/wanAlarms.js index b94b964..a501429 100644 --- a/services/voiceDiag/checks/wan/wanAlarms.js +++ b/services/voiceDiag/checks/wan/wanAlarms.js @@ -79,15 +79,17 @@ export const wanAlarmsCheck = { const rollups = rollupAlarms(samples); const byCategory = countByCategory(samples); - // Physical vs overlay heuristic. Only ever a HINT — a store where - // Link State also reports paths down doesn't need us telling them - // "physical is up". + // Physical vs overlay heuristic. Prefer structured tunnels when + // present; fall back to link-state-only. const linksAllUp = allPhysicalLinksUp(ctx?.sdwanData?.links); + const tunnels = Array.isArray(ctx?.sdwanData?.tunnels) ? ctx.sdwanData.tunnels : []; + const tunnelsDown = tunnels.filter((t) => t.up === false).length; const details = { critical, major, minor, window: windowLabel, byCategory, + tunnelsDown, recentSamples: rollups.slice(0, 3).map((r) => ({ code: r.code, humanized: humanizeAlarmCode(r.code), @@ -104,7 +106,7 @@ export const wanAlarmsCheck = { status: 'error', message: buildMessage({ count: critical, severityLabel: 'critical', - windowLabel, rollups, byCategory, linksAllUp, + windowLabel, rollups, byCategory, linksAllUp, tunnelsDown, }), details, remediation: null, @@ -116,7 +118,7 @@ export const wanAlarmsCheck = { status: 'warn', message: buildMessage({ count: major, severityLabel: 'major', - windowLabel, rollups, byCategory, linksAllUp, + windowLabel, rollups, byCategory, linksAllUp, tunnelsDown, }), details, remediation: null, @@ -149,7 +151,7 @@ export const wanAlarmsCheck = { * (×N, latest min ago). . */ function buildMessage({ - count, severityLabel, windowLabel, rollups, byCategory, linksAllUp, + count, severityLabel, windowLabel, rollups, byCategory, linksAllUp, tunnelsDown = 0, }) { // Top rollup entry drives the human summary. Preserves severity // ordering (critical > major > minor) so the "most notable" alarm @@ -174,9 +176,13 @@ function buildMessage({ const overlayHeavy = byCategory.overlay > 0 && byCategory.overlay >= byCategory.physical; if (overlayHeavy && linksAllUp === true) { + const tunnelBit = tunnelsDown > 0 + ? ` ${tunnelsDown} overlay tunnel(s) currently report DOWN.` + : ''; parts.push( '. These affect SD-WAN overlay/VPN tunnels between sites; ' + - 'physical WAN paths are all up per the Link State check.', + 'physical WAN paths are all up per the Link State check.' + + tunnelBit, ); } else if (byCategory.physical > 0 && linksAllUp === false) { parts.push( diff --git a/services/voiceDiag/checks/wan/wanTunnels.js b/services/voiceDiag/checks/wan/wanTunnels.js new file mode 100644 index 0000000..7c0e32c --- /dev/null +++ b/services/voiceDiag/checks/wan/wanTunnels.js @@ -0,0 +1,105 @@ +// src/services/voiceDiag/checks/wan/wanTunnels.js +// +// Overlay / VPN tunnel health. Complements wanLinkState (physical +// circuits): a store can have all WAN paths up while the SD-WAN +// mesh to the hub is down — voice then fails even though LQM looks +// fine. +// +// Data comes from collectSdwanForStore().tunnels (Prisma vpnlinks / +// topology links). When the tunnels fetch failed or returned empty, +// we skip rather than false-alarming. + +import { maybeSkippedByKillSwitch } from './_helpers.js'; + +export const WAN_TUNNELS_STANDARDS = Object.freeze({ + allUp: true, +}); + +export const wanTunnelsCheck = { + id: 'wanTunnels', + label: 'SD-WAN Overlay Tunnels', + requires: ['sdwanSite'], + scope: null, + standards: WAN_TUNNELS_STANDARDS, + + async run(ctx) { + const skip = maybeSkippedByKillSwitch(wanTunnelsCheck); + if (skip) return skip; + + const tunnels = Array.isArray(ctx.sdwanData?.tunnels) ? ctx.sdwanData.tunnels : []; + const tunnelFetchFailed = (ctx.sdwanData?.errors || []) + .some((e) => e.scope === 'tunnels'); + + if (tunnels.length === 0) { + return { + status: 'skipped', + message: tunnelFetchFailed + ? 'Overlay tunnel status unavailable (Prisma fetch failed).' + : 'No overlay tunnels reported for this site.', + details: { total: 0, tunnelFetchFailed }, + remediation: null, + }; + } + + const down = tunnels.filter((t) => t.up === false); + const unknown = tunnels.filter((t) => t.up == null); + const up = tunnels.filter((t) => t.up === true); + const alarmed = tunnels.filter((t) => t.recentAlarm); + + const links = Array.isArray(ctx.sdwanData?.links) ? ctx.sdwanData.links : []; + const physicalAllUp = links.length > 0 && links.every((l) => l.up !== false); + + const details = { + total: tunnels.length, + up: up.length, + down: down.length, + unknown: unknown.length, + alarmed: alarmed.length, + offenders: down.map((t) => t.peerLabel || t.id), + physicalAllUp, + }; + + if (down.length > 0) { + const hint = physicalAllUp + ? ' Physical WAN paths look up — this is an overlay/VPN issue.' + : ''; + return { + status: 'error', + message: + `${down.length} of ${tunnels.length} overlay tunnel(s) DOWN: ` + + down.map((t) => t.peerLabel || t.id).join(', ') + + `.${hint}`, + details, + remediation: null, + }; + } + + if (alarmed.length > 0) { + return { + status: 'warn', + message: + `${alarmed.length} overlay tunnel(s) have recent Prisma alarms ` + + `while currently reporting up.`, + details, + remediation: null, + }; + } + + if (unknown.length > 0 && up.length === 0) { + return { + status: 'warn', + message: `Overlay tunnel state unknown for all ${unknown.length} tunnel(s).`, + details, + remediation: null, + }; + } + + return { + status: 'ok', + message: `All ${up.length} overlay tunnel(s) up.` + + (unknown.length > 0 ? ` (${unknown.length} with unknown state.)` : ''), + details, + remediation: null, + }; + }, +}; diff --git a/tests/fixtures/prisma/alarm-info-vpn.json b/tests/fixtures/prisma/alarm-info-vpn.json new file mode 100644 index 0000000..ef11768 --- /dev/null +++ b/tests/fixtures/prisma/alarm-info-vpn.json @@ -0,0 +1,6 @@ +{ + "_comment": "Alarm info nested shape with vpn_link_id for correlation tests", + "vpn_reasons": [ + { "code": "NETWORK_VPNLINK_DOWN", "vpn_link_id": "vpn-link-2" } + ] +} diff --git a/tests/fixtures/prisma/anynetlinks.json b/tests/fixtures/prisma/anynetlinks.json new file mode 100644 index 0000000..0820c51 --- /dev/null +++ b/tests/fixtures/prisma/anynetlinks.json @@ -0,0 +1,25 @@ +{ + "_comment": "Anonymized fixture — POST /sdwan/v4.0/api/anynetlinks/query with query_params ep2_site_id {eq}", + "items": [ + { + "id": "al-hub-1", + "ep1_site_id": "site-HUB", + "ep1_wan_interface_id": "wi-hub-1", + "ep2_site_id": "16239359004050019", + "ep2_wan_interface_id": "16239359007870072", + "type": "AUTO-PUBLIC", + "forced": false, + "admin_up": true + }, + { + "id": "al-hub-2", + "ep1_site_id": "site-HUB", + "ep1_wan_interface_id": "wi-hub-2", + "ep2_site_id": "16239359004050019", + "ep2_wan_interface_id": "16239359010020112", + "type": "AUTO-PUBLIC", + "forced": false, + "admin_up": true + } + ] +} diff --git a/tests/fixtures/prisma/topology-links.json b/tests/fixtures/prisma/topology-links.json new file mode 100644 index 0000000..0c0188b --- /dev/null +++ b/tests/fixtures/prisma/topology-links.json @@ -0,0 +1,38 @@ +{ + "_comment": "Anonymized fixture — POST /sdwan/v2.0/api/topology/links/query with query_params target_site_id {in}", + "items": [ + { + "id": "topo-link-1", + "path_id": "path-1", + "type": "public-anynet", + "sub_type": "auto", + "status": "up", + "admin_up": true, + "vpnlinks": ["vpn-link-1"], + "source_site_id": "site-HUB", + "source_site_name": "CG00001-HUB", + "source_wan_if_id": "wi-hub-mpls", + "source_circuit_name": "MPLS-HUB", + "target_site_id": "site-A", + "target_site_name": "CG01005", + "target_wan_if_id": "wi-mpls", + "target_circuit_name": "Inet1-01005" + }, + { + "id": "topo-link-2", + "path_id": "path-2", + "type": "public-anynet", + "sub_type": "auto", + "status": "down", + "admin_up": true, + "vpnlinks": ["vpn-link-2"], + "source_site_id": "site-HUB", + "source_site_name": "CG00001-HUB", + "source_wan_if_id": "wi-hub-bb", + "target_site_id": "site-A", + "target_site_name": "CG01005", + "target_wan_if_id": "wi-bb", + "target_circuit_name": "LTE-01005" + } + ] +} diff --git a/tests/fixtures/prisma/vpnlink-status.json b/tests/fixtures/prisma/vpnlink-status.json new file mode 100644 index 0000000..65dd0ca --- /dev/null +++ b/tests/fixtures/prisma/vpnlink-status.json @@ -0,0 +1,8 @@ +{ + "_comment": "Anonymized fixture — GET /sdwan/v2.2/api/vpnlinks/{id}/status", + "vpn_link_id": "vpn-link-1", + "operational_up": true, + "admin_up": true, + "link_status": "up", + "wan_interface_id": "wi-mpls" +} diff --git a/tests/fixtures/prisma/vpnlinks-by-al.json b/tests/fixtures/prisma/vpnlinks-by-al.json new file mode 100644 index 0000000..a82b77e --- /dev/null +++ b/tests/fixtures/prisma/vpnlinks-by-al.json @@ -0,0 +1,23 @@ +{ + "_comment": "Live-shaped vpnlinks (no site_id) — join to site via al_id only", + "items": [ + { + "id": "vpn-inet-1", + "al_id": "al-hub-1", + "vep1_id": "vep-local-1", + "vep2_id": "vep-hub-1", + "vep1_shim_ipv4": "169.254.1.1", + "vep2_shim_ipv4": "169.254.1.2", + "rekey_interval_minutes": 480 + }, + { + "id": "vpn-lte-1", + "al_id": "al-hub-1", + "vep1_id": "vep-local-2", + "vep2_id": "vep-hub-2", + "vep1_shim_ipv4": "169.254.2.1", + "vep2_shim_ipv4": "169.254.2.2", + "rekey_interval_minutes": 480 + } + ] +} diff --git a/tests/fixtures/prisma/waninterface-status.json b/tests/fixtures/prisma/waninterface-status.json new file mode 100644 index 0000000..028e2ee --- /dev/null +++ b/tests/fixtures/prisma/waninterface-status.json @@ -0,0 +1,7 @@ +{ + "_comment": "Anonymized fixture — GET /sdwan/v2.1/api/sites/{siteId}/waninterfaces/{wiId}/status", + "operational_up": true, + "admin_up": true, + "element_id": "el-fixture-1", + "status": "up" +} diff --git a/tests/paloalto.metrics.test.js b/tests/paloalto.metrics.test.js index c5a499a..3694d6a 100644 --- a/tests/paloalto.metrics.test.js +++ b/tests/paloalto.metrics.test.js @@ -396,6 +396,48 @@ test('getAppMetric(mos): AppAudioMos requires direction="Ingress" and OMITS filt } }); +test('getAppMetric(loss): pathTypes option narrows filter.path_type', async () => { + _resetPrismaAuthCache(); + let seenBody = null; + const fake = await makeFakePrisma({ + 'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => { + seenBody = body; + return { body: { metrics: [{ series: [] }] } }; + }, + }); + setSaseEnv(fake.baseUrl); + try { + await getAppMetric('site-A', '15932', 'loss', { + windowMinutes: 60, + pathTypes: ['VPN'], + }); + assert.deepEqual(seenBody.filter.path_type, ['VPN']); + } finally { + await fake.close(); _resetPrismaAuthCache(); clearEnv(); + } +}); + +test('getAppMetric(loss): pathIds option sets filter.path', async () => { + _resetPrismaAuthCache(); + let seenBody = null; + const fake = await makeFakePrisma({ + 'POST /sdwan/monitor/v2.6/api/monitor/metrics': ({ body }) => { + seenBody = body; + return { body: { metrics: [{ series: [] }] } }; + }, + }); + setSaseEnv(fake.baseUrl); + try { + await getAppMetric('site-A', '15932', 'loss', { + windowMinutes: 60, + pathIds: ['wi-1'], + }); + assert.deepEqual(seenBody.filter.path, ['wi-1']); + } finally { + await fake.close(); _resetPrismaAuthCache(); clearEnv(); + } +}); + test('getAppMetric(loss): AppPerfUDPAudioPacketLoss includes ALL 5 path_types', async () => { _resetPrismaAuthCache(); let seenBody = null; diff --git a/tests/paloalto.topology.test.js b/tests/paloalto.topology.test.js new file mode 100644 index 0000000..db2105a --- /dev/null +++ b/tests/paloalto.topology.test.js @@ -0,0 +1,97 @@ +// tests/paloalto.topology.test.js +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import path from 'node:path'; + +import { + normalizeTunnelRow, + extractVpnLinkIdsFromAlarmInfo, + siteIdsFromTunnelRaw, +} from '../integrations/paloalto/topology.js'; +import { normalizeWanInterfaceStatus } from '../integrations/paloalto/sites.js'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const FIX = path.join(__dirname, 'fixtures/prisma'); + +test('normalizeWanInterfaceStatus: fixture operational/admin up', () => { + const raw = JSON.parse(readFileSync(path.join(FIX, 'waninterface-status.json'), 'utf8')); + const s = normalizeWanInterfaceStatus(raw); + assert.equal(s.operationalUp, true); + assert.equal(s.adminUp, true); + assert.equal(s.elementId, 'el-fixture-1'); +}); + +test('normalizeTunnelRow: topology link fixture', () => { + const topo = JSON.parse(readFileSync(path.join(FIX, 'topology-links.json'), 'utf8')); + const up = normalizeTunnelRow(topo.items[0], null, { localSiteId: 'site-A' }); + assert.equal(up.id, 'topo-link-1'); + assert.equal(up.up, true); + assert.equal(up.relatedInterfaceId, 'wi-mpls'); + assert.equal(up.peerSiteId, 'site-HUB'); + assert.match(up.peerLabel, /HUB/); + assert.deepEqual(up.vpnLinkIds, ['vpn-link-1']); + + const down = normalizeTunnelRow(topo.items[1], null, { localSiteId: 'site-A' }); + assert.equal(down.up, false); + assert.equal(down.relatedInterfaceId, 'wi-bb'); +}); + +test('normalizeTunnelRow: merges vpnlink status', () => { + const status = JSON.parse(readFileSync(path.join(FIX, 'vpnlink-status.json'), 'utf8')); + const row = normalizeTunnelRow({ id: 'vpn-link-1', target_site_name: 'Hub' }, status); + assert.equal(row.up, true); + assert.equal(row.relatedInterfaceId, 'wi-mpls'); +}); + +test('siteIdsFromTunnelRaw: ep1/ep2 style', () => { + const ids = siteIdsFromTunnelRaw({ + id: 'x', + ep1_site_id: 'site-A', + ep2_site_id: 'site-HUB', + }); + assert.deepEqual(ids.sort(), ['site-A', 'site-HUB'].sort()); +}); + +test('normalizeTunnelRow: picks peer opposite local site', () => { + const row = normalizeTunnelRow({ + id: 'vpn-1', + ep1_site_id: 'site-A', + ep2_site_id: 'site-HUB', + admin_up: true, + al_state: 'up', + }, null, { localSiteId: 'site-A' }); + assert.equal(row.peerSiteId, 'site-HUB'); + assert.equal(row.up, true); +}); + +test('normalizeTunnelRow: live vpnlink shape (al_id/vep, no site fields)', () => { + const vpn = JSON.parse(readFileSync(path.join(FIX, 'vpnlinks-by-al.json'), 'utf8')).items[0]; + const row = normalizeTunnelRow(vpn, null, { + localSiteId: '16239359004050019', + peerSiteIdOverride: 'site-HUB', + peerLabelOverride: 'CG00001-HUB', + }); + assert.equal(row.id, 'vpn-inet-1'); + assert.equal(row.anynetId, 'al-hub-1'); + assert.equal(row.peerLabel, 'CG00001-HUB'); + assert.equal(row.vep1Id, 'vep-local-1'); + assert.equal(siteIdsFromTunnelRaw(vpn).length, 0, 'live vpnlinks carry no site ids'); +}); + +test('normalizeTunnelRow: anynetlink fixture has ep1/ep2 site endpoints', () => { + const al = JSON.parse(readFileSync(path.join(FIX, 'anynetlinks.json'), 'utf8')).items[0]; + const ids = siteIdsFromTunnelRaw(al); + assert.ok(ids.includes('16239359004050019')); + assert.ok(ids.includes('site-HUB')); + const row = normalizeTunnelRow(al, null, { localSiteId: '16239359004050019' }); + assert.equal(row.peerSiteId, 'site-HUB'); + assert.equal(row.relatedInterfaceId, '16239359007870072'); + assert.equal(row.adminUp, true); +}); + +test('extractVpnLinkIdsFromAlarmInfo: nested vpn_reasons', () => { + const info = JSON.parse(readFileSync(path.join(FIX, 'alarm-info-vpn.json'), 'utf8')); + assert.deepEqual(extractVpnLinkIdsFromAlarmInfo(info), ['vpn-link-2']); +}); diff --git a/tests/renderers.wan.test.js b/tests/renderers.wan.test.js index 5ac137a..21cc965 100644 --- a/tests/renderers.wan.test.js +++ b/tests/renderers.wan.test.js @@ -279,3 +279,45 @@ test('renderer: footer mentions the per-app --only shortcut', () => { assert.match(md, /wanAppRtpMos,wanAppRtpLoss,wanAppRtpJitter/, 'footer should point operators at the per-app checks by id'); }); + +test('renderer: overlay tunnels section shows down + physical-up note', () => { + const md = renderWanDiagnosticsMarkdown(baseData({ + tunnels: [ + { id: 't1', peerLabel: 'CG00001-HUB', up: false, state: 'down', recentAlarm: true }, + { id: 't2', peerLabel: 'CG00002-HUB', up: true, state: 'up' }, + ], + }), { storeNum: '782', footer: false }); + assert.match(md, /\*\*Overlay tunnels\*\*/); + assert.match(md, /CG00001-HUB/); + assert.match(md, /overlay tunnel\(s\) down while physical/); +}); + +test('renderer: all-unknown tunnels collapse to one inventory note', () => { + const md = renderWanDiagnosticsMarkdown(baseData({ + tunnels: Array.from({ length: 5 }, (_, i) => ({ + id: `t${i}`, peerLabel: 'peer', up: null, state: 'unknown', + })), + }), { storeNum: '782', footer: false }); + assert.match(md, /Tunnel inventory returned 5/); + assert.doesNotMatch(md, /\? peer — unknown/); +}); + +test('renderer: voice DPI byPathType lines', () => { + const md = renderWanDiagnosticsMarkdown(baseData({ + appAudio: { + appName: 'Webex_Calling_RTP', + mos: { avg: 4.2, min: 3.8, max: 4.5, samples: 10, validSamples: 10, interval: '5min' }, + loss: { avg: 1, min: 0, max: 3, samples: 10, validSamples: 10, interval: '5min' }, + jitter: { avg: 10, min: 5, max: 20, samples: 10, validSamples: 10, interval: '5min' }, + bandwidth: { avg: 0.5, min: 0.1, max: 1, samples: 10, validSamples: 10, interval: '5min' }, + byPathType: { + VPN: { + loss: { avg: 5, min: 1, max: 12, samples: 5, validSamples: 5 }, + jitter: { avg: 30, min: 10, max: 45, samples: 5, validSamples: 5 }, + }, + }, + }, + }), { storeNum: '782', footer: false }); + assert.match(md, /By path type/); + assert.match(md, /\*\*VPN:\*\*/); +}); diff --git a/tests/sdwanEnrichment.test.js b/tests/sdwanEnrichment.test.js index e89e9e7..07068e3 100644 --- a/tests/sdwanEnrichment.test.js +++ b/tests/sdwanEnrichment.test.js @@ -19,6 +19,7 @@ import { parseAlarms, summarizeAppSeries, resolveVoiceAppConfig, + annotateTunnelsWithAlarms, _resetVoiceAppDeprecationFlag, } from '../services/enrichment/sdwanEnrichment.js'; import { _resetSitesCache } from '../integrations/paloalto/sites.js'; @@ -423,6 +424,31 @@ test('buildLinkRows: no waninterfaces → empty list even with LQM data', () => assert.deepEqual(rows, []); }); +test('buildLinkRows: runtime operationalUp preferred over adminUp', () => { + const rows = buildLinkRows({ + wanInterfaces: [ + { id: 'wi-1', name: 'Primary', adminUp: true, usedFor: 'primary' }, + ], + statusByInterfaceId: new Map([ + ['wi-1', { operationalUp: false, adminUp: true, elementId: 'el-1' }], + ]), + metricResponses: {}, + }); + assert.equal(rows[0].up, false); + assert.equal(rows[0].operationalUp, false); + assert.equal(rows[0].adminUp, true); + assert.equal(rows[0].elementId, 'el-1'); +}); + +test('annotateTunnelsWithAlarms: marks tunnels cited in alarm vpnLinkIds', () => { + const tunnels = annotateTunnelsWithAlarms( + [{ id: 'vpn-link-2', up: true }, { id: 'vpn-link-1', up: true }], + { samples: [{ vpnLinkIds: ['vpn-link-2'] }] }, + ); + assert.equal(tunnels[0].recentAlarm, true); + assert.equal(tunnels[1].recentAlarm, false); +}); + test('buildLinkRows: admin-down waninterface reports up:false', () => { const rows = buildLinkRows({ wanInterfaces: [{ id: 'wi-lte', name: 'LTE', adminUp: false, usedFor: 'backup' }], @@ -741,6 +767,7 @@ test('collectSdwanForStore: happy path composes site+elements+waninterfaces+LQM' const bb = data.links.find((l) => l.interfaceId === 'wi-bb'); assert.equal(bb.up, true); assert.equal(bb.latencyMs, null, 'broadband has no LQM sample in the fixture'); + assert.ok(Array.isArray(data.tunnels), 'tunnels array always present'); assert.equal(data.errors.length, 0); } finally { await fake.close(); _resetSitesCache(); _resetPrismaAuthCache(); clearEnv(); diff --git a/tests/voicePathAttribution.test.js b/tests/voicePathAttribution.test.js new file mode 100644 index 0000000..04cdb71 --- /dev/null +++ b/tests/voicePathAttribution.test.js @@ -0,0 +1,60 @@ +// tests/voicePathAttribution.test.js +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + pickUniqueWorst, + annotateWanWithPath, + attributeVoicePathToEndpoint, +} from '../services/callReport/voicePathAttribution.js'; + +function series(max, avg = max) { + return { max, avg, min: avg, validSamples: 3, samples: 3 }; +} + +test('pickUniqueWorst: picks clearly worse path_type', () => { + const pick = pickUniqueWorst({ + VPN: { loss: series(12), jitter: series(40) }, + DirectInternet: { loss: series(0.5), jitter: series(5) }, + }); + assert.equal(pick.key, 'VPN'); + assert.ok(pick.margin >= 0.5); +}); + +test('pickUniqueWorst: returns null on close tie', () => { + const pick = pickUniqueWorst({ + VPN: { loss: series(1.0) }, + DirectInternet: { loss: series(1.1) }, + }); + assert.equal(pick, null); +}); + +test('annotateWanWithPath: path confidence when byPath unique', () => { + const wan = { siteLevelApprox: true, mos: 3.2, loss: 10, jitter: 40 }; + const annotated = annotateWanWithPath(wan, { + byPath: { + 'wi-bad': { loss: series(20), jitter: series(50) }, + 'wi-ok': { loss: series(0.1), jitter: series(2) }, + }, + }); + assert.equal(annotated.confidence, 'path'); + assert.equal(annotated.pathId, 'wi-bad'); + assert.equal(annotated.siteLevelApprox, false); +}); + +test('annotateWanWithPath: path_type confidence from byPathType', () => { + const wan = { siteLevelApprox: true, mos: 4.0, loss: 1, jitter: 10 }; + const annotated = annotateWanWithPath(wan, { + byPathType: { + VPN: { loss: series(15), jitter: series(40) }, + DirectInternet: { loss: series(0.2), jitter: series(3) }, + }, + }); + assert.equal(annotated.confidence, 'path_type'); + assert.equal(annotated.pathType, 'VPN'); + assert.equal(annotated.siteLevelApprox, true); +}); + +test('attributeVoicePathToEndpoint: stub returns null (no join key)', () => { + assert.equal(attributeVoicePathToEndpoint({}, {}), null); +}); diff --git a/tests/wanTunnels.check.test.js b/tests/wanTunnels.check.test.js new file mode 100644 index 0000000..daefae6 --- /dev/null +++ b/tests/wanTunnels.check.test.js @@ -0,0 +1,41 @@ +// tests/wanTunnels.check.test.js +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { wanTunnelsCheck } from '../services/voiceDiag/checks/wan/wanTunnels.js'; + +test('wanTunnels: all up → ok', async () => { + const result = await wanTunnelsCheck.run({ + sdwanData: { + tunnels: [ + { id: '1', peerLabel: 'Hub', up: true }, + { id: '2', peerLabel: 'Hub2', up: true }, + ], + links: [{ up: true }], + errors: [], + }, + }); + assert.equal(result.status, 'ok'); +}); + +test('wanTunnels: down while physical up → error with overlay hint', async () => { + const result = await wanTunnelsCheck.run({ + sdwanData: { + tunnels: [ + { id: '1', peerLabel: 'Hub', up: false }, + { id: '2', peerLabel: 'Hub2', up: true }, + ], + links: [{ up: true }, { up: true }], + errors: [], + }, + }); + assert.equal(result.status, 'error'); + assert.match(result.message, /overlay/i); +}); + +test('wanTunnels: empty → skipped', async () => { + const result = await wanTunnelsCheck.run({ + sdwanData: { tunnels: [], links: [], errors: [] }, + }); + assert.equal(result.status, 'skipped'); +});