// 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, fetchedAt } // Per-site waninterfaces cache (Map). // 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=` 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=` // — 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>} */ 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>} */ 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`; } function waninterfaceStatusPath(siteId, wanInterfaceId) { return isSase() ? `/sdwan/v2.1/api/sites/${siteId}/waninterfaces/${wanInterfaceId}/status` : `/v2.1/api/sites/${siteId}/waninterfaces/${wanInterfaceId}/status`; } /** * Runtime status for one site WAN interface. * * GET /sdwan/v2.1/api/sites/{siteId}/waninterfaces/{wanInterfaceId}/status * * Returns a normalized object or null on failure. Never throws. * * @param {string} siteId * @param {string} wanInterfaceId * @returns {Promise<{operationalUp: boolean|null, adminUp: boolean|null, elementId: string|null, raw: object}|null>} */ export async function getWanInterfaceStatus(siteId, wanInterfaceId) { if (!siteId || !wanInterfaceId) return null; try { const res = await paloAltoAxios.get(waninterfaceStatusPath(siteId, wanInterfaceId)); const body = res.data || {}; return normalizeWanInterfaceStatus(body); } catch (err) { logger('paloalto:sites', `waninterface status ${siteId}/${wanInterfaceId} failed: ${err.message}`, 'debug'); return null; } } /** * Fan-out runtime status for every waninterface at a site. * * @param {string} siteId * @param {Array<{id: string}>|string[]} wanInterfaces * @param {object} [opts] * @param {number} [opts.concurrency=4] * @returns {Promise>} interfaceId → status row */ export async function getWanInterfaceStatusesForSite(siteId, wanInterfaces, opts = {}) { const map = new Map(); if (!siteId) return map; const ids = (Array.isArray(wanInterfaces) ? wanInterfaces : []) .map((w) => (typeof w === 'string' ? w : w?.id)) .filter(Boolean) .map(String); if (ids.length === 0) return map; const concurrency = Math.max(1, Number(opts.concurrency) || 4); let i = 0; async function worker() { while (i < ids.length) { const idx = i; i += 1; const id = ids[idx]; const status = await getWanInterfaceStatus(siteId, id); if (status) map.set(id, status); } } await Promise.all( Array.from({ length: Math.min(concurrency, ids.length) }, () => worker()), ); logger('paloalto:sites', `Site ${siteId}: runtime status for ${map.size}/${ids.length} waninterface(s)`, ); return map; } /** * @param {object} body * @returns {{operationalUp: boolean|null, adminUp: boolean|null, elementId: string|null, raw: object}} */ export function normalizeWanInterfaceStatus(body) { const raw = body && typeof body === 'object' ? body : {}; const operationalUp = pickStatusBool( raw.operational_up, raw.operationalUp, raw.up, raw.reachable, statusStringToBool(raw.status), statusStringToBool(raw.operational_state), statusStringToBool(raw.state), statusStringToBool(raw.link_state), ); const adminUp = pickStatusBool( raw.admin_up, raw.adminUp, statusStringToBool(raw.admin_state), statusStringToBool(raw.al_admin_state), ); const elementId = raw.element_id || raw.elementId || null; return { operationalUp, adminUp, elementId: elementId ? String(elementId) : null, raw, }; } function pickStatusBool(...candidates) { for (const c of candidates) { if (typeof c === 'boolean') return c; } return null; } function statusStringToBool(s) { if (typeof s !== 'string') return null; const v = s.toLowerCase().trim(); if (['up', 'active', 'connected', 'online', 'reachable'].includes(v)) return true; if (['down', 'inactive', 'disconnected', 'offline', 'unreachable'].includes(v)) return false; return null; } /** * Test-only — clears all caches so a fresh fetch happens next call. */ export function _resetSitesCache() { sitesCache = null; sitesCacheAt = 0; elementsCache = null; waninterfacesCache.clear(); }