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:
parent
a25fc08fe2
commit
90a56c4640
27 changed files with 1986 additions and 60 deletions
22
.env.example
22
.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
|
||||
# -----------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
// ──────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
570
integrations/paloalto/topology.js
Normal file
570
integrations/paloalto/topology.js
Normal 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];
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }) {
|
||||
|
|
|
|||
132
services/callReport/voicePathAttribution.js
Normal file
132
services/callReport/voicePathAttribution.js
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
64
services/voiceDiag/VOICE_PATH_ATTRIBUTION.md
Normal file
64
services/voiceDiag/VOICE_PATH_ATTRIBUTION.md
Normal 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 |
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
105
services/voiceDiag/checks/wan/wanTunnels.js
Normal file
105
services/voiceDiag/checks/wan/wanTunnels.js
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
6
tests/fixtures/prisma/alarm-info-vpn.json
vendored
Normal file
6
tests/fixtures/prisma/alarm-info-vpn.json
vendored
Normal 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
25
tests/fixtures/prisma/anynetlinks.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
38
tests/fixtures/prisma/topology-links.json
vendored
Normal file
38
tests/fixtures/prisma/topology-links.json
vendored
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
8
tests/fixtures/prisma/vpnlink-status.json
vendored
Normal file
8
tests/fixtures/prisma/vpnlink-status.json
vendored
Normal 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"
|
||||
}
|
||||
23
tests/fixtures/prisma/vpnlinks-by-al.json
vendored
Normal file
23
tests/fixtures/prisma/vpnlinks-by-al.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
7
tests/fixtures/prisma/waninterface-status.json
vendored
Normal file
7
tests/fixtures/prisma/waninterface-status.json
vendored
Normal 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"
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
97
tests/paloalto.topology.test.js
Normal file
97
tests/paloalto.topology.test.js
Normal 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']);
|
||||
});
|
||||
|
|
@ -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:\*\*/);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
60
tests/voicePathAttribution.test.js
Normal file
60
tests/voicePathAttribution.test.js
Normal 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);
|
||||
});
|
||||
41
tests/wanTunnels.check.test.js
Normal file
41
tests/wanTunnels.check.test.js
Normal 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');
|
||||
});
|
||||
Loading…
Reference in a new issue