Fix Prisma overlay tunnel discovery with site-scoped query_params.

Use topology/links and anynetlinks filters with eq/in operators so
store WAN follow-ups return real peer tunnels instead of unscoped dumps.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jmcqueen 2026-07-28 09:01:40 -04:00
parent a25fc08fe2
commit 90a56c4640
27 changed files with 1986 additions and 60 deletions

View file

@ -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
# -----------------------------------------------------------------------------

View file

@ -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,

View file

@ -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<object|null>}
*/
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<Record<string, object|null>>}
*/
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)
// ──────────────────────────────────────────────

View file

@ -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<Map<string, object>>} 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.
*/

View file

@ -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];
}

View file

@ -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 <siteId> <wanInterfaceId>');
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 <siteId>');
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 <siteId> <appId> [--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 <siteId>');
@ -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 <siteId> <appId> <wanInterfaceId>');
}
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 <storeNum> Resolve store → site',
' elements <siteId> List site elements',
' waninterfaces <siteId> List site waninterfaces',
' wi-status <siteId> <wanInterfaceId> Runtime waninterface status',
' tunnels <siteId> Overlay / VPN tunnels for site',
' app-by-path-type <siteId> <appId> Voice DPI broken out by path_type',
' health <siteId> Fetch healthscore',
' lqm <siteId> <wiCsv> [--metric X] Fetch LQM metric (latency|jitter|loss|mos)',
' alarms <siteId> [--window minutes] Fetch alarms',
@ -1113,6 +1227,7 @@ function printHelp() {
' try-shapes app-audio-loss <siteId> [app=rtp-base] Sweep per-app audio loss shapes',
' try-shapes app-audio-jitter <siteId> [app=rtp-base] Sweep per-app audio jitter shapes',
' try-shapes app-audio-bandwidth <siteId> [app=rtp-base] Sweep per-app audio BW shapes',
' try-shapes app-by-path <siteId> <appId> <wiId> 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,

View file

@ -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 }) {

View file

@ -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<string, object>} 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;
}

View file

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

View file

@ -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));

View file

@ -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):

View file

@ -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 <siteId> <wanInterfaceId>
node scripts/prismaProbe.js tunnels <siteId>
node scripts/prismaProbe.js app-by-path-type <siteId> <appId> --metric loss
node scripts/prismaProbe.js try-shapes app-by-path <siteId> <appId> <wiId>
```
## 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 |

View file

@ -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,

View file

@ -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 <N> min ago). <optional physical-vs-overlay hint>.
*/
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(

View file

@ -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,
};
},
};

View file

@ -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" }
]
}

25
tests/fixtures/prisma/anynetlinks.json vendored Normal file
View file

@ -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
}
]
}

View file

@ -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"
}
]
}

View file

@ -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"
}

View file

@ -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
}
]
}

View file

@ -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"
}

View file

@ -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;

View file

@ -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']);
});

View file

@ -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:\*\*/);
});

View file

@ -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();

View file

@ -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);
});

View file

@ -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');
});