Introduces a full Palo Alto Prisma SD-WAN integration (dual-mode SASE OAuth 2.0 / legacy CloudGenix auth, pagination, 429 backoff, session priming) that surfaces per-path latency/jitter/loss/MOS, site healthscore, link state, and alarm data for a store. Wired into the /phonestatus WAN follow-up and eight new /voicediag WAN checks graded against ITU-T G.114 / RFC 3550 defaults (env-overridable via WAN_STANDARD_*). Also adds a shape-aware detail renderer for /voicediag (per-link tables with verdict icons instead of a stringified JSON dump) and a --window flag (15m / 1h / 6h / 24h / 1d, env default via WAN_STANDARD_WINDOW_MINUTES) so operators can widen the look-back without redeploying. scripts/prismaProbe.js is bundled as a CLI for schema iteration against a live tenant. Co-authored-by: Cursor <cursoragent@cursor.com>
431 lines
16 KiB
JavaScript
431 lines
16 KiB
JavaScript
// src/integrations/paloalto/sites.js
|
|
//
|
|
// Site + element discovery for Prisma SD-WAN. Prisma's data model
|
|
// is: tenant → site → element(s) → interface(s)/path(s). A "site" is
|
|
// what maps 1:1 to a store; an "element" is a physical/virtual
|
|
// Prisma appliance at that site.
|
|
//
|
|
// AE convention (confirmed by operator): site names are
|
|
// `CG${storeNum.padStart(5, '0')}` — e.g. store 782 → `CG00782`,
|
|
// store 2477 → `CG02477`, store 305 → `CG00305`. Exact match, no
|
|
// fuzzy scoring needed. This is materially simpler than the Meraki
|
|
// resolver (which has to substring-search).
|
|
//
|
|
// Cache strategy:
|
|
// - Sites list: 4-hour TTL (same as Meraki networks cache). Sites
|
|
// don't come and go often; a store add/remove is a Prisma-side
|
|
// lifecycle event that we tolerate up to 4 hours of staleness
|
|
// on. `refreshSitesCache()` re-pulls on demand.
|
|
// - Elements per site: cached alongside the site the first time
|
|
// they're asked for. Same 4-hour TTL. Elements at a site can
|
|
// change (RMA replacement), but this is rare and the same
|
|
// staleness bound applies.
|
|
//
|
|
// Read-only. The client only calls GET here; there's no mutation
|
|
// surface exposed.
|
|
|
|
import { paloAltoAxios } from './client.js';
|
|
import { logger } from '../../utils/logger.js';
|
|
|
|
const CACHE_TTL_MS = 4 * 60 * 60 * 1000;
|
|
|
|
// Sites list cache (one entry for the whole tenant).
|
|
let sitesCache = null;
|
|
let sitesCacheAt = 0;
|
|
|
|
// Tenant-wide elements cache. Rationale (verified against live
|
|
// tenant): the `?site_id=X` GET filter on /elements is NOT honored
|
|
// server-side — Prisma returns the full inventory (1229 elements in
|
|
// the observed tenant) regardless of the query param, and we filter
|
|
// client-side. Making one tenant-wide fetch and indexing by site_id
|
|
// in-memory eliminates 1200+ redundant records per subsequent call
|
|
// and cuts /voicediag's Prisma call count by ~90%.
|
|
let elementsCache = null; // { elementsBySite: Map<siteId, elements[]>, fetchedAt }
|
|
|
|
// Per-site waninterfaces cache (Map<siteId, { waninterfaces, fetchedAt }>).
|
|
// This one stays per-site because /sites/{siteId}/waninterfaces IS
|
|
// properly scoped server-side — no wasteful over-fetch.
|
|
const waninterfacesCache = new Map();
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Site resolution
|
|
// ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Convert a 2-4 digit store number into its expected Prisma site
|
|
* name using the `CG${pad5}` convention.
|
|
*
|
|
* @param {string|number} storeNum
|
|
* @returns {string} e.g. 'CG00782'
|
|
*/
|
|
export function siteNameForStore(storeNum) {
|
|
const digits = String(storeNum ?? '').match(/\d+/)?.[0] || '';
|
|
if (!digits) {
|
|
throw new Error(`siteNameForStore: no digits in "${storeNum}"`);
|
|
}
|
|
const padded = digits.padStart(5, '0').slice(-5);
|
|
return `CG${padded}`;
|
|
}
|
|
|
|
/**
|
|
* Return all Prisma SD-WAN sites in the tenant, refreshing the
|
|
* cache when stale. Uses the GET-based list endpoint:
|
|
*
|
|
* GET /sdwan/v4.13/api/sites (SASE unified — "Get Sites Of Tenant")
|
|
* GET /v4.13/api/sites (legacy)
|
|
*
|
|
* Why GET instead of the sibling `POST /sites/query`:
|
|
*
|
|
* The observed SASE tenant (2026-07-08) runs a schema variant of
|
|
* `POST /sites/query` where the request-body validator rejects
|
|
* fields with cryptic type-mismatch errors — first `getDeleted`
|
|
* ("expected boolean, got object"), then `total_count` ("expected
|
|
* long, got object"), and likely more if we probed further. The
|
|
* SASE proxy appears to auto-inject fields with `{}` defaults into
|
|
* the body before schema validation, so ANY minimal body triggers
|
|
* a different validation failure on some auto-injected field.
|
|
*
|
|
* `GET /sites` sidesteps the entire body-schema minefield: no body
|
|
* = no auto-injection = no drifting field types to guess. The
|
|
* endpoint returns the same `{ items: [...] }` shape and is
|
|
* documented on pan.dev as "Get Sites Of Tenant (v4.13)".
|
|
*
|
|
* Pagination: the GET endpoint supports `?limit=` and cursor
|
|
* follow-up if Prisma paginates its response. We handle both
|
|
* `items[]` on a single page and any `next_query` echo (defensively).
|
|
*/
|
|
export async function getAllSites(forceRefresh = false) {
|
|
const now = Date.now();
|
|
if (!forceRefresh && sitesCache && now - sitesCacheAt < CACHE_TTL_MS) {
|
|
return sitesCache;
|
|
}
|
|
|
|
const path = sitesListPath();
|
|
logger('paloalto:sites', `Refreshing sites cache from GET ${path}`, 'debug');
|
|
|
|
try {
|
|
const allItems = await fetchAllSitesGet(path);
|
|
|
|
sitesCache = allItems.map((s) => ({
|
|
id: s.id,
|
|
name: s.name,
|
|
description: s.description || '',
|
|
tenant_id: s.tenant_id || null,
|
|
raw: s,
|
|
}));
|
|
sitesCacheAt = now;
|
|
|
|
if (sitesCache.length === 0) {
|
|
logger('paloalto:sites', `Cached 0 sites (empty tenant or response-shape drift — enable LOG_LEVEL=debug to see the raw first-page shape).`, 'warn');
|
|
} else {
|
|
// Sample the first 3 site names so an operator scanning the
|
|
// logs can immediately eyeball whether their target site name
|
|
// matches the naming convention Prisma is actually using.
|
|
const sample = sitesCache.slice(0, 3).map((s) => s.name).join(', ');
|
|
logger(
|
|
'paloalto:sites',
|
|
`Cached ${sitesCache.length} sites (sample: ${sample}${sitesCache.length > 3 ? ', …' : ''})`,
|
|
);
|
|
}
|
|
return sitesCache;
|
|
} catch (err) {
|
|
logger('paloalto:sites', `Failed to fetch sites: ${err.message}`, 'error');
|
|
// On failure, keep any stale cache (better than nothing) but
|
|
// return an empty array if we've never fetched successfully.
|
|
return sitesCache || [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Fetch every site via `GET /sdwan/v4.13/api/sites`, following any
|
|
* cursor pagination Prisma returns.
|
|
*
|
|
* The GET endpoint's response shape mirrors POST /query:
|
|
* { items: [...], next_query?: {...} }
|
|
*
|
|
* Some tenants return a single-shot list (all sites in one response,
|
|
* no `next_query`). Others paginate — in which case we send the
|
|
* `next_query` object back as a `?query=<encoded-json>` query
|
|
* param (Prisma's GET-side pagination convention) or via a subsequent
|
|
* POST hit. We try the "single response" path first and only
|
|
* paginate if `next_query` is populated.
|
|
*
|
|
* Defenses:
|
|
* - Request `?limit=1000` up-front to maximise the first-page size
|
|
* (some tenants default to 100).
|
|
* - Follow `next_query` when present. Empty / null / undefined /
|
|
* empty-object all count as "no more pages" (same rules as the
|
|
* POST paginator).
|
|
* - MAX_PAGES safety cap against a runaway cursor.
|
|
*/
|
|
async function fetchAllSitesGet(basePath) {
|
|
const PAGE_LIMIT = 1000;
|
|
const MAX_PAGES = 50;
|
|
const all = [];
|
|
|
|
let requestParams = { limit: PAGE_LIMIT };
|
|
let page = 0;
|
|
|
|
while (page < MAX_PAGES) {
|
|
page += 1;
|
|
logger(
|
|
'paloalto:sites',
|
|
`Requesting page ${page}: GET ${basePath} params=${JSON.stringify(requestParams).slice(0, 200)}`,
|
|
'debug',
|
|
);
|
|
const res = await paloAltoAxios.get(basePath, { params: requestParams });
|
|
|
|
let items = res.data?.items || res.data?.data;
|
|
if (!items && Array.isArray(res.data)) items = res.data;
|
|
if (!items) items = [];
|
|
if (!Array.isArray(items)) {
|
|
const shapeHint = res.data && typeof res.data === 'object'
|
|
? `keys=[${Object.keys(res.data).join(', ')}]`
|
|
: `type=${typeof res.data}`;
|
|
throw new Error(`Unexpected sites response shape on page ${page} (${shapeHint})`);
|
|
}
|
|
|
|
all.push(...items);
|
|
logger('paloalto:sites', `Page ${page}: got ${items.length} sites (running total ${all.length})`, 'debug');
|
|
|
|
// Termination: no cursor → done. GET endpoints without cursor
|
|
// return the full list in one response for most tenants.
|
|
const nextQuery = res.data?.next_query;
|
|
const hasCursor = nextQuery
|
|
&& typeof nextQuery === 'object'
|
|
&& Object.keys(nextQuery).length > 0;
|
|
|
|
if (!hasCursor) break;
|
|
|
|
// Pass Prisma's own cursor state onto the next GET as query
|
|
// params. The most portable representation is `?query=<json>`
|
|
// — Prisma's GET-side pagination convention — but some
|
|
// tenants also honor spreading the cursor fields directly.
|
|
// We spread first (more common); if that ever breaks, fall
|
|
// back to the JSON-encoded param.
|
|
requestParams = { ...nextQuery, limit: PAGE_LIMIT };
|
|
}
|
|
|
|
if (page >= MAX_PAGES) {
|
|
logger(
|
|
'paloalto:sites',
|
|
`Pagination hit MAX_PAGES=${MAX_PAGES} at ${all.length} sites — cursor may be looping. Investigate.`,
|
|
'warn',
|
|
);
|
|
}
|
|
|
|
return all;
|
|
}
|
|
|
|
/**
|
|
* Given a store number (2-4 digits, or a string containing them),
|
|
* return the corresponding Prisma site or null if not found.
|
|
* Exact match on `CG${pad5}` — the only convention AE uses.
|
|
*
|
|
* @param {string|number} storeNum
|
|
* @returns {Promise<{id:string,name:string,description:string,tenant_id:string|null}|null>}
|
|
*/
|
|
export async function findSdwanSiteForStore(storeNum) {
|
|
let expectedName;
|
|
try {
|
|
expectedName = siteNameForStore(storeNum);
|
|
} catch (err) {
|
|
logger('paloalto:sites', `Bad store number "${storeNum}": ${err.message}`, 'warn');
|
|
return null;
|
|
}
|
|
|
|
const sites = await getAllSites();
|
|
const match = sites.find((s) => s.name === expectedName);
|
|
if (!match) {
|
|
// Warn (not debug) so an operator can see mismatches in
|
|
// default-level logs. Include the nearest names in the tenant so
|
|
// a naming-convention drift ("CG782" vs "CG00782") is spotted
|
|
// without hunting through the full site list.
|
|
const nearby = sites
|
|
.filter((s) => (s.name || '').includes(String(storeNum)))
|
|
.slice(0, 5)
|
|
.map((s) => s.name);
|
|
const nearbyHint = nearby.length > 0 ? ` — closest matches: ${nearby.join(', ')}` : '';
|
|
logger(
|
|
'paloalto:sites',
|
|
`No Prisma site matches "${expectedName}" (store ${storeNum}) among ${sites.length} sites${nearbyHint}`,
|
|
'warn',
|
|
);
|
|
return null;
|
|
}
|
|
logger('paloalto:sites', `Store ${storeNum} → site ${match.name} (${match.id})`);
|
|
return match;
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Elements (appliances) per site
|
|
// ──────────────────────────────────────────────
|
|
|
|
/**
|
|
* Return the list of Prisma appliances (elements) at a site.
|
|
*
|
|
* Fetches the ENTIRE tenant inventory once (cached 4h) and indexes
|
|
* by `site_id` so per-site lookups are O(1) with zero network cost
|
|
* on cache hit. This is the correct pattern for Prisma because the
|
|
* `?site_id=X` server-side filter on /elements is silently ignored
|
|
* (verified live: a filtered request returned 1229 elements for a
|
|
* site that owns 1).
|
|
*
|
|
* GET /sdwan/v3.1/api/elements (SASE unified — tenant-wide)
|
|
* GET /v3.1/api/elements (legacy)
|
|
*
|
|
* @param {string} siteId
|
|
* @returns {Promise<Array<{id, name, model, serial_number, connected, site_id}>>}
|
|
*/
|
|
export async function getElementsForSite(siteId, forceRefresh = false) {
|
|
if (!siteId) return [];
|
|
|
|
const map = await getElementsIndex(forceRefresh);
|
|
const rows = map.get(siteId) || [];
|
|
logger('paloalto:sites', `Site ${siteId}: ${rows.length} element(s) from tenant-wide cache`, 'debug');
|
|
return rows;
|
|
}
|
|
|
|
/**
|
|
* Return (fetching if stale) the tenant-wide index of elements
|
|
* keyed by site_id. First caller pays the network cost + build; all
|
|
* subsequent callers within the 4h TTL get instant Map lookup.
|
|
*/
|
|
async function getElementsIndex(forceRefresh = false) {
|
|
const now = Date.now();
|
|
if (!forceRefresh && elementsCache && now - elementsCache.fetchedAt < CACHE_TTL_MS) {
|
|
return elementsCache.elementsBySite;
|
|
}
|
|
|
|
const path = elementsPath();
|
|
logger('paloalto:sites', `Refreshing tenant-wide elements from ${path}`, 'debug');
|
|
|
|
try {
|
|
const res = await paloAltoAxios.get(path);
|
|
const items = res.data?.items || res.data?.data || [];
|
|
if (!Array.isArray(items)) {
|
|
throw new Error(`Unexpected elements response shape: ${JSON.stringify(res.data)?.slice(0, 200)}`);
|
|
}
|
|
const elementsBySite = new Map();
|
|
for (const e of items) {
|
|
if (!e.site_id) continue;
|
|
const row = {
|
|
id: e.id,
|
|
name: e.name || null,
|
|
model: e.model_name || e.model || null,
|
|
serial_number: e.serial_number || null,
|
|
connected: e.connected === true,
|
|
site_id: e.site_id,
|
|
raw: e,
|
|
};
|
|
const bucket = elementsBySite.get(e.site_id) || [];
|
|
bucket.push(row);
|
|
elementsBySite.set(e.site_id, bucket);
|
|
}
|
|
elementsCache = { elementsBySite, fetchedAt: now };
|
|
logger(
|
|
'paloalto:sites',
|
|
`Cached ${items.length} elements across ${elementsBySite.size} site(s)`,
|
|
);
|
|
return elementsBySite;
|
|
} catch (err) {
|
|
logger('paloalto:sites', `Failed to fetch tenant elements: ${err.message}`, 'error');
|
|
return elementsCache?.elementsBySite || new Map();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Return the list of WAN interfaces (per-site circuits) for a site.
|
|
* Cached per site for 4h. WAN interfaces are the "path" identifiers
|
|
* that LQM metrics filter by — without this list, per-path latency /
|
|
* jitter / loss / MOS queries have no waninterface ids to feed into
|
|
* the metric filter and come back empty.
|
|
*
|
|
* GET /sdwan/v2.10/api/sites/{siteId}/waninterfaces (SASE unified)
|
|
* GET /v2.10/api/sites/{siteId}/waninterfaces (legacy)
|
|
*
|
|
* @param {string} siteId
|
|
* @returns {Promise<Array<{id, name, adminUp, wanNetworkId, wanNetworkName, usedFor, bwConfigMode}>>}
|
|
*/
|
|
export async function getWanInterfacesForSite(siteId, forceRefresh = false) {
|
|
if (!siteId) return [];
|
|
const now = Date.now();
|
|
const cached = waninterfacesCache.get(siteId);
|
|
if (!forceRefresh && cached && now - cached.fetchedAt < CACHE_TTL_MS) {
|
|
return cached.waninterfaces;
|
|
}
|
|
|
|
const path = waninterfacesPath(siteId);
|
|
logger('paloalto:sites', `Refreshing waninterfaces for site ${siteId} from ${path}`, 'debug');
|
|
|
|
try {
|
|
const res = await paloAltoAxios.get(path);
|
|
const items = res.data?.items || res.data?.data || [];
|
|
if (!Array.isArray(items)) {
|
|
throw new Error(`Unexpected waninterfaces response shape: ${JSON.stringify(res.data)?.slice(0, 200)}`);
|
|
}
|
|
const waninterfaces = items.map((w) => ({
|
|
id: w.id,
|
|
name: w.name || w.description || null,
|
|
// Defensive parsing: on some tenant schema variants the
|
|
// waninterface config API doesn't return `admin_up` at all,
|
|
// in which case a strict `=== true` check silently reports
|
|
// every circuit as DOWN — a misleading false positive
|
|
// (verified live: 3 circuits all showed ❌ DOWN when the
|
|
// API returned no admin_up field). Treat missing / non-bool
|
|
// as null (unknown) so the check + renderer show "?" rather
|
|
// than red-crossing a healthy circuit.
|
|
adminUp: typeof w.admin_up === 'boolean' ? w.admin_up : null,
|
|
wanNetworkId: w.wan_network_id || null,
|
|
wanNetworkName: w.wan_network_name || null,
|
|
// `used_for`: 'primary' | 'secondary' | ... Also useful as a
|
|
// rough "transport type" label until we wire wan_networks
|
|
// config in a phase 2.
|
|
usedFor: w.used_for || null,
|
|
bwConfigMode: w.bw_config_mode || null,
|
|
raw: w,
|
|
}));
|
|
waninterfacesCache.set(siteId, { waninterfaces, fetchedAt: now });
|
|
logger('paloalto:sites', `Site ${siteId}: ${waninterfaces.length} waninterface(s)`);
|
|
return waninterfaces;
|
|
} catch (err) {
|
|
logger('paloalto:sites', `Failed to fetch waninterfaces for site ${siteId}: ${err.message}`, 'error');
|
|
return cached?.waninterfaces || [];
|
|
}
|
|
}
|
|
|
|
// ──────────────────────────────────────────────
|
|
// Path helpers — SASE vs legacy have different URL prefixes
|
|
// ──────────────────────────────────────────────
|
|
|
|
function isSase() {
|
|
return String(process.env.PRISMA_AUTH_MODE || 'sase').toLowerCase().trim() === 'sase';
|
|
}
|
|
|
|
function sitesListPath() {
|
|
return isSase()
|
|
? '/sdwan/v4.13/api/sites'
|
|
: '/v4.13/api/sites';
|
|
}
|
|
|
|
function elementsPath() {
|
|
return isSase()
|
|
? '/sdwan/v3.1/api/elements'
|
|
: '/v3.1/api/elements';
|
|
}
|
|
|
|
function waninterfacesPath(siteId) {
|
|
return isSase()
|
|
? `/sdwan/v2.10/api/sites/${siteId}/waninterfaces`
|
|
: `/v2.10/api/sites/${siteId}/waninterfaces`;
|
|
}
|
|
|
|
/**
|
|
* Test-only — clears all caches so a fresh fetch happens next call.
|
|
*/
|
|
export function _resetSitesCache() {
|
|
sitesCache = null;
|
|
sitesCacheAt = 0;
|
|
elementsCache = null;
|
|
waninterfacesCache.clear();
|
|
}
|