#!/usr/bin/env node /** * Prisma SD-WAN API probe — a debugging harness that lets you fire * individual Prisma API calls against a live tenant without going * through `/phonestatus` or `/voicediag`. This is the tool for * discovering the tenant's actual schema when documented shapes * (pan.dev + LIVEcommunity examples) don't match. * * Design goals: * - Zero side effects. Never mutates Prisma state. * - Reuses the production axios client (same auth, retry, session * priming). So a shape that works here will work in-band. * - Prints BOTH the request body and the response body verbatim * on failure, which is the whole point — you need to see the * exact SCHEMA_CHECK_FAIL field to know what to change. * - `try-shapes` mode iterates a curated list of body variants * against a single endpoint and reports which pass — much * faster than one-shot testing when you don't know the tenant's * accepted shape. * * ─── Usage ───────────────────────────────────────────────────────── * * node scripts/prismaProbe.js [args] [flags] * * Subcommands: * * discover * Full store discovery. Resolves store → site, then lists * elements + waninterfaces at the site. This is what the bot * does before firing metrics. * * site * Just resolve store number → Prisma site (no per-site drilldowns). * * elements * List elements at a site. * * waninterfaces * List waninterfaces (circuits) at a site. * * health * Fetch healthscore via the production `getHealthscore` wrapper. * Shows what the bot would send; use `try-shapes health` to * experiment with alternative body shapes. * * lqm [--metric latency|jitter|loss|mos] * Fetch a single LQM metric via the production `getLqmMetric` * wrapper. `--metric` defaults to `latency`. * * alarms [--window ] * Fetch alarms via the production `getAlarms` wrapper. Default * window is 60 minutes. * * raw [--body '{"json":"body"}'] * Send an arbitrary request through the authenticated client. * Useful for testing endpoints we don't yet wrap (e.g. * `/sdwan/monitor/v2.5/api/monitor/metrics`). * * try-shapes health * try-shapes lqm * try-shapes lqm-latency * try-shapes lqm-jitter * try-shapes lqm-loss * try-shapes lqm-mos * Combinatorial shape testing: fire N candidate request bodies * against the given endpoint and print which pass, which fail, * and — for failures — the exact SCHEMA_CHECK_FAIL field. * `lqm-loss` / `lqm-mos` sweep {metric name × unit} combos when * the metric's identifier is wrong (400 METRIC_UNIT_NOT_SUPPORTED * or METRIC_NOT_FOUND). * * Global flags: * --json Emit JSON output instead of pretty-printed. * --show-body Print the request body on 2xx as well as 4xx. * --quiet Suppress the axios request log line. * --help, -h Show this help. * * Environment: * Reads the same `.env` as the bot (PRISMA_AUTH_MODE, PRISMA_CLIENT_ID, * PRISMA_CLIENT_SECRET, PRISMA_TSG_ID, PRISMA_SASE_BASE_URL, * PRISMA_AUTH_URL, and/or PRISMA_EMAIL / PRISMA_PASSWORD / * PRISMA_LEGACY_BASE_URL for legacy auth). * * Examples: * node scripts/prismaProbe.js discover 782 * node scripts/prismaProbe.js health 16158173173100144 * node scripts/prismaProbe.js lqm 16158173173100144 16158173176610209 --metric loss * node scripts/prismaProbe.js try-shapes health 16158173173100144 * node scripts/prismaProbe.js try-shapes lqm 16158173173100144 16158173176610209,1666974885552003096 * node scripts/prismaProbe.js raw POST /sdwan/v3.7/api/events/query --body '{"limit":{"count":5}}' */ import 'dotenv/config'; import { paloAltoAxios, findSdwanSiteForStore, getElementsForSite, getWanInterfacesForSite, getHealthscore, getLqmMetric, getAlarms, LQM_METRIC_NAMES, } from '../integrations/paloalto/index.js'; // ─── Arg parsing ──────────────────────────────────────────────────── function parseArgs(argv) { const args = { _: [], flags: {} }; const rest = argv.slice(2); for (let i = 0; i < rest.length; i += 1) { const a = rest[i]; if (a === '--help' || a === '-h') { args.flags.help = true; continue; } if (a === '--json') { args.flags.json = true; continue; } if (a === '--show-body') { args.flags.showBody = true; continue; } if (a === '--quiet') { args.flags.quiet = true; continue; } if (a.startsWith('--')) { const key = a.slice(2); const next = rest[i + 1]; if (next !== undefined && !next.startsWith('--')) { args.flags[key] = next; i += 1; } else { args.flags[key] = true; } continue; } args._.push(a); } return args; } // ─── Small output helpers ─────────────────────────────────────────── const ICON_OK = '✅'; const ICON_ERR = '❌'; const ICON_INFO = 'ℹ️ '; const ICON_WAIT = '⋯ '; function pretty(obj) { return JSON.stringify(obj, null, 2); } function shortJson(obj, max = 400) { const s = JSON.stringify(obj); return s.length > max ? `${s.slice(0, max)}…(+${s.length - max} chars)` : s; } function heading(text) { const bar = '─'.repeat(Math.max(4, text.length + 2)); console.log(`\n${bar}\n ${text}\n${bar}`); } function ok(msg) { console.log(`${ICON_OK} ${msg}`); } function bad(msg) { console.log(`${ICON_ERR} ${msg}`); } function info(msg) { console.log(`${ICON_INFO} ${msg}`); } /** * Fire a raw request through the authenticated client and return a * uniform verdict object. Never throws — all axios failures are * caught and translated so the caller can render them in one style. */ async function fire({ method, url, body }) { const startedAt = Date.now(); try { const res = await paloAltoAxios.request({ method, url, data: body, }); return { ok: true, status: res.status, elapsedMs: Date.now() - startedAt, request: { method, url, body }, response: res.data, }; } catch (err) { return { ok: false, status: err.response?.status || 0, elapsedMs: Date.now() - startedAt, request: { method, url, body }, response: err.response?.data || null, errorMessage: err.message, }; } } /** * Extract the first SCHEMA_CHECK_FAIL message from a Prisma 400 * body (`_error` array). Returns null on other error shapes so the * caller falls back to shortJson(). */ function extractSchemaError(body) { if (!body || typeof body !== 'object') return null; const errs = body._error; if (!Array.isArray(errs) || errs.length === 0) return null; const first = errs[0]; if (!first || typeof first !== 'object') return null; return `${first.code || 'ERROR'}: ${first.message || ''}`.trim(); } function printVerdict(verdict, { json, showBody }) { if (json) { console.log(pretty(verdict)); return; } const { ok: pass, status, elapsedMs, request, response, errorMessage } = verdict; const icon = pass ? ICON_OK : ICON_ERR; console.log(`${icon} ${request.method} ${request.url} → ${status || 'network fail'} (${elapsedMs}ms)`); const shouldShowRequest = !pass || showBody; if (shouldShowRequest && request.body !== undefined) { console.log(' request body:'); console.log(' ' + pretty(request.body).replaceAll('\n', '\n ')); } if (!pass) { const schemaMsg = extractSchemaError(response); if (schemaMsg) { console.log(` ${ICON_ERR} ${schemaMsg}`); } else if (errorMessage) { console.log(` ${ICON_ERR} ${errorMessage}`); } } if (response) { console.log(' response body:'); console.log(' ' + pretty(response).replaceAll('\n', '\n ')); } } // ─── Subcommand implementations ───────────────────────────────────── async function cmdDiscover(args) { const storeNum = args._[1]; if (!storeNum) throw new Error('usage: discover '); heading(`Discover store ${storeNum}`); const site = await findSdwanSiteForStore(storeNum); if (!site) { bad(`No Prisma site for store ${storeNum}`); return; } ok(`site: ${site.name} (${site.id})`); info(`description: ${site.description || '(none)'}`); heading('Elements at this site'); const elements = await getElementsForSite(site.id); if (!elements || elements.length === 0) { bad('no elements at this site'); } else { for (const e of elements) { console.log(` - ${e.name || e.id} (${e.id}) model=${e.model || '?'} connected=${e.connected}`); } } heading('WAN interfaces (circuits) at this site'); const wans = await getWanInterfacesForSite(site.id); if (!wans || wans.length === 0) { bad('no waninterfaces at this site'); } else { for (const w of wans) { const admin = w.adminUp === null ? '?' : (w.adminUp ? 'up' : 'down'); console.log(` - ${w.name || w.id} (${w.id}) usedFor=${w.usedFor || '?'} adminUp=${admin}`); } } info(`Use these ids for follow-up calls:`); console.log(` siteId = ${site.id}`); console.log(` waninterfaceIds = ${(wans || []).map((w) => w.id).join(',')}`); } async function cmdSite(args) { const storeNum = args._[1]; if (!storeNum) throw new Error('usage: site '); const site = await findSdwanSiteForStore(storeNum); if (!site) { bad(`No Prisma site for store ${storeNum}`); return; } ok(`store ${storeNum} → ${site.name} (${site.id})`); } async function cmdElements(args) { const siteId = args._[1]; if (!siteId) throw new Error('usage: elements '); const els = await getElementsForSite(siteId); console.log(pretty(els)); } async function cmdWaninterfaces(args) { const siteId = args._[1]; if (!siteId) throw new Error('usage: waninterfaces '); const wans = await getWanInterfacesForSite(siteId); console.log(pretty(wans)); } async function cmdHealth(args) { const siteId = args._[1]; if (!siteId) throw new Error('usage: health '); const startedAt = Date.now(); const resp = await getHealthscore(siteId); const elapsed = Date.now() - startedAt; if (!resp) { bad(`getHealthscore returned null in ${elapsed}ms — check the paloalto:metrics warning above for the 400 body`); return; } ok(`getHealthscore returned in ${elapsed}ms`); console.log(pretty(resp)); } async function cmdLqm(args) { const siteId = args._[1]; const wiCsv = args._[2]; const metric = args.flags.metric || 'latency'; if (!siteId || !wiCsv) throw new Error('usage: lqm [--metric latency|jitter|loss|mos]'); if (!LQM_METRIC_NAMES[metric]) { throw new Error(`unknown --metric "${metric}" (expected: ${Object.keys(LQM_METRIC_NAMES).join(', ')})`); } const wiIds = wiCsv.split(',').map((s) => s.trim()).filter(Boolean); const startedAt = Date.now(); const resp = await getLqmMetric(siteId, wiIds, metric); const elapsed = Date.now() - startedAt; if (!resp) { bad(`getLqmMetric(${metric}) returned null in ${elapsed}ms — check the paloalto:metrics warning above for the 400 body`); return; } ok(`getLqmMetric(${metric}) returned in ${elapsed}ms`); console.log(pretty(resp)); } async function cmdAlarms(args) { const siteId = args._[1]; const window = Number(args.flags.window) || 60; if (!siteId) throw new Error('usage: alarms [--window ]'); const startedAt = Date.now(); const resp = await getAlarms(siteId, window); const elapsed = Date.now() - startedAt; if (!resp) { bad(`getAlarms returned null in ${elapsed}ms — check the paloalto:metrics warning above`); return; } ok(`getAlarms returned in ${elapsed}ms`); console.log(pretty(resp)); } async function cmdRaw(args) { const method = (args._[1] || '').toUpperCase(); const url = args._[2]; if (!method || !url) throw new Error('usage: raw [--body \'{"json":"body"}\']'); let body; if (typeof args.flags.body === 'string') { try { body = JSON.parse(args.flags.body); } catch (e) { throw new Error(`--body is not valid JSON: ${e.message}`); } } const verdict = await fire({ method, url, body }); printVerdict(verdict, args.flags); } // ─── try-shapes: combinatorial schema testing ─────────────────────── function pickInterval5min() { return '5min'; } function windowIsoStart(minutes) { return new Date(Date.now() - minutes * 60 * 1000).toISOString(); } function nowIsoStart() { return new Date().toISOString(); } /** * Build the healthscore body candidates. Each entry has: * - `label`: short summary for the try-shapes table * - `url`: OPTIONAL per-candidate URL override (default is * `/sdwan/monitor/v2.0/api/monitor/aiops/health`). * Used to test alternative endpoints (v2.1 aggregates, * v2.6 unified metrics) in the same run. * - `body`: request body * * When you land on a working shape via try-shapes, update * `integrations/paloalto/metrics.js::getHealthscore` and add a * regression test in `tests/paloalto.metrics.test.js`. * * Candidate philosophy for aiops/health v2.0 (this tenant): * Confirmed via trip-wire on 2026-07-09: * - `metrics` array is REJECTED at top level ("not defined") * - `end_time` is REJECTED * - `view` is REQUIRED (must be present) AND is a STRING ENUM * - `filter.site`, `filter.elements` all REJECTED * So the winning shape should be a minimal one WITHOUT `metrics` * or `end_time`. We also try alternative endpoints in case v2.0 * is deprecated on this tenant. */ function healthscoreCandidates() { const startT = windowIsoStart(15); const startT60 = windowIsoStart(60); const interval = pickInterval5min(); return [ // ─── WINNING SHAPE (verified 2026-07-09) ─────────────────────── // v2.6 monitor/metrics is the ONLY endpoint that returns 200 on // the observed tenant. Kept first so it's the fast-path. { label: 'v2.6 metrics: Healthscore metric with filter={site:[X]} ← LIVE WINNER', url: '/sdwan/monitor/v2.6/api/monitor/metrics', body: { start_time: startT, interval, metrics: [{ name: 'Healthscore', statistics: ['max'], unit: 'gauge' }], view: {}, filter: { site: ['SITE_ID_PLACEHOLDER'] }, } }, // ─── v2.6 variants to probe response shape drift ─────────────── { label: 'v2.6 metrics: statistics:["average"]', url: '/sdwan/monitor/v2.6/api/monitor/metrics', body: { start_time: startT, interval, metrics: [{ name: 'Healthscore', statistics: ['average'], unit: 'gauge' }], view: {}, filter: { site: ['SITE_ID_PLACEHOLDER'] }, } }, { label: 'v2.6 metrics: view={individual:"site"}', url: '/sdwan/monitor/v2.6/api/monitor/metrics', body: { start_time: startT, interval, metrics: [{ name: 'Healthscore', statistics: ['max'], unit: 'gauge' }], view: { individual: 'site' }, filter: { site: ['SITE_ID_PLACEHOLDER'] }, } }, { label: 'v2.6 metrics: view={individual:"site"} + longer window (60min)', url: '/sdwan/monitor/v2.6/api/monitor/metrics', body: { start_time: startT60, interval, metrics: [{ name: 'Healthscore', statistics: ['max'], unit: 'gauge' }], view: { individual: 'site' }, filter: { site: ['SITE_ID_PLACEHOLDER'] }, } }, // ─── DEAD-END REGRESSION GUARDS (v2.0 aiops/health) ──────────── // Kept so we notice if Prisma ever re-enables these paths. All // currently return 400 SCHEMA_CHECK_FAIL on this tenant. { label: 'v2.0 aiops/health: minimal {start_time, interval, view:"summary", filter:{}} (regression)', body: { start_time: startT, interval, view: 'summary', filter: {} } }, { label: 'v2.0 aiops/health: + end_time added back', body: { start_time: startT, end_time: new Date().toISOString(), interval, view: 'summary', filter: {} } }, { label: 'v2.0 aiops/health: + metrics + end_time', body: { start_time: startT, end_time: new Date().toISOString(), interval, metrics: [{ name: 'Healthscore', statistics: ['max'], unit: 'gauge' }], view: 'summary', filter: {}, } }, ]; } /** * Substitute placeholders (SITE_ID_PLACEHOLDER etc.) with actual * ids before firing. Keeps the candidate list readable in code. */ function substitutePlaceholders(body, subs) { const s = JSON.stringify(body); let out = s; for (const [ph, val] of Object.entries(subs)) { out = out.replaceAll(`"${ph}"`, JSON.stringify(val)); } return JSON.parse(out); } function lqmCandidates() { // WINNING SHAPE confirmed live 2026-07-09: // filter={ site:[X], path:[WI,...] } with view:{}, NO end_time. // Returns metrics[].sites[].paths[].data.. // Below variants exist so any future schema drift shows up as a // clean try-shapes comparison rather than a silent regression. const base = { start_time: windowIsoStart(5), interval: pickInterval5min(), metrics: [{ name: 'LqmLatencyPointMetric', statistics: ['average'], unit: 'milliseconds' }], view: {}, }; return [ { label: 'filter={site:[X], path:[WI]} ← LIVE WINNER (2026-07-09)', body: { ...base, filter: { site: ['SITE_ID_PLACEHOLDER'], path: ['WI_ID_PLACEHOLDER'] } } }, // ─── Alternative filter-key variants (all 400 on this tenant) ── { label: 'filter={site:[X], waninterface:[WI]} (rejected)', body: { ...base, filter: { site: ['SITE_ID_PLACEHOLDER'], waninterface: ['WI_ID_PLACEHOLDER'] } } }, { label: 'filter={site:[X], wan_interfaces:[WI]} (rejected)', body: { ...base, filter: { site: ['SITE_ID_PLACEHOLDER'], wan_interfaces: ['WI_ID_PLACEHOLDER'] } } }, { label: 'filter={site:[X]} (no circuit filter)', body: { ...base, filter: { site: ['SITE_ID_PLACEHOLDER'] } } }, { label: 'filter={path:[WI]} (no site filter)', body: { ...base, filter: { path: ['WI_ID_PLACEHOLDER'] } } }, { label: 'filter={site:[X], path:[WI]} + end_time added back (regression check)', body: { ...base, end_time: new Date().toISOString(), filter: { site: ['SITE_ID_PLACEHOLDER'], path: ['WI_ID_PLACEHOLDER'] }, } }, { label: 'view={individual:"path"}, filter={site:[X], path:[WI]}', body: { ...base, view: { individual: 'path' }, filter: { site: ['SITE_ID_PLACEHOLDER'], path: ['WI_ID_PLACEHOLDER'] } } }, ]; } async function cmdTryShapes(args) { const which = args._[1]; const siteId = args._[2]; if (!which || !siteId) throw new Error('usage: try-shapes []'); if (which === 'health') { heading(`try-shapes health → siteId=${siteId}`); const els = await getElementsForSite(siteId); const elId = els?.[0]?.id; if (!elId) info('no elements at site — element-based candidates will be skipped'); const subs = { SITE_ID_PLACEHOLDER: siteId }; if (elId) subs.ELEMENT_ID_PLACEHOLDER = elId; const results = await runCandidates('POST', '/sdwan/monitor/v2.0/api/monitor/aiops/health', healthscoreCandidates(), subs, args.flags); printSummary('healthscore', results); return; } if (which === 'lqm') { const wiCsv = args._[3]; if (!wiCsv) throw new Error('usage: try-shapes lqm '); heading(`try-shapes lqm → siteId=${siteId} wiIds=${wiCsv}`); const els = await getElementsForSite(siteId); const elId = els?.[0]?.id; const firstWi = wiCsv.split(',')[0].trim(); const subs = { SITE_ID_PLACEHOLDER: siteId, WI_ID_PLACEHOLDER: firstWi, }; if (elId) subs.ELEMENT_ID_PLACEHOLDER = elId; const results = await runCandidates('POST', '/sdwan/monitor/v2.0/api/monitor/lqm_point_metrics', lqmCandidates(), subs, args.flags); printSummary('lqm_point_metrics', results); return; } // Metric name + unit matrix probe. Fires each {name, unit} combo // and reports which return 200. Use when a specific metric key // (loss, mos, etc.) is 400-ing with METRIC_UNIT_NOT_SUPPORTED // or METRIC_NOT_FOUND. Assumes filter + view shape is already // solved (uses the current winning filter={site:[X], path:[WI]}). const LQM_MATRIX_TARGETS = { 'lqm-loss': [ // Verified winner (2026-07-09): LqmPktLossPointMetric + percentage. { name: 'LqmPktLossPointMetric', unit: 'percentage' }, // ← LIVE WINNER { name: 'LqmPktLossPointMetric', unit: 'percent' }, { name: 'LqmPktLossPointMetric', unit: 'pct' }, { name: 'LqmPktLossPointMetric', unit: 'ratio' }, { name: 'LqmPktLossPointMetric', unit: 'count' }, { name: 'LqmPktLossPointMetric', unit: 'gauge' }, { name: 'LqmPktLossPointMetric', unit: 'Percentage' }, // regression check — was wrong pre-fix { name: 'LqmPacketLossPointMetric', unit: 'percentage' }, { name: 'LqmLossPointMetric', unit: 'percentage' }, { name: 'LqmPacketDropPointMetric', unit: 'percentage' }, { name: 'LqmPktLossPercentPointMetric', unit: 'percentage' }, { name: 'LqmPktLossPctPointMetric', unit: 'percentage' }, ], 'lqm-mos': [ // Verified winner (2026-07-09): LqmMosPointMetric + count. { name: 'LqmMosPointMetric', unit: 'count' }, // ← LIVE WINNER { name: 'LqmMosPointMetric', unit: 'score' }, { name: 'LqmMosPointMetric', unit: 'mos' }, { name: 'LqmMosPointMetric', unit: 'ratio' }, { name: 'LqmMosPointMetric', unit: 'gauge' }, { name: 'LqmMosScorePointMetric', unit: 'count' }, { name: 'LqmMeanOpinionScorePointMetric', unit: 'count' }, ], 'lqm-latency': [ // Verified working (2026-07-09) via `lqm --metric latency`. { name: 'LqmLatencyPointMetric', unit: 'milliseconds' }, // ← LIVE WINNER { name: 'LqmLatencyPointMetric', unit: 'ms' }, { name: 'LqmLatencyPointMetric', unit: 'Milliseconds' }, { name: 'LqmLatencyPointMetric', unit: 'count' }, { name: 'LqmRttLatencyPointMetric', unit: 'milliseconds' }, { name: 'LqmLatencyRttPointMetric', unit: 'milliseconds' }, ], 'lqm-jitter': [ // Currently working via fallback scanner; run this to confirm // the "true" name/unit and whether jitter is directional. { name: 'LqmJitterPointMetric', unit: 'milliseconds' }, // ← current default { name: 'LqmJitterPointMetric', unit: 'ms' }, { name: 'LqmJitterPointMetric', unit: 'count' }, { name: 'LqmRttJitterPointMetric', unit: 'milliseconds' }, { name: 'LqmJitterMsPointMetric', unit: 'milliseconds' }, ], }; if (LQM_MATRIX_TARGETS[which]) { const wiCsv = args._[3]; if (!wiCsv) throw new Error(`usage: try-shapes ${which} `); heading(`try-shapes ${which} → siteId=${siteId} wiIds=${wiCsv}`); const wiIds = wiCsv.split(',').map((s) => s.trim()).filter(Boolean); const nameUnitMatrix = LQM_MATRIX_TARGETS[which]; const candidates = nameUnitMatrix.map((mu) => ({ label: `name="${mu.name}", unit="${mu.unit}"`, body: { start_time: windowIsoStart(5), interval: pickInterval5min(), metrics: [{ name: mu.name, statistics: ['average'], unit: mu.unit }], view: {}, filter: { site: [siteId], path: wiIds }, }, })); const results = await runCandidates( 'POST', '/sdwan/monitor/v2.0/api/monitor/lqm_point_metrics', candidates, {}, args.flags, ); printSummary(`${which} name+unit matrix`, results); return; } throw new Error(`unknown try-shapes target "${which}" — expected "health", "lqm", "lqm-latency", "lqm-jitter", "lqm-loss", or "lqm-mos"`); } /** * Run a list of candidate bodies against a default URL (or the * candidate's own `url` override if provided). Each candidate is * fired serially so trip-wire ordering is deterministic. Skips * candidates that reference a placeholder we don't have (e.g. * ELEMENT_ID_PLACEHOLDER when the site has no elements). */ async function runCandidates(method, defaultUrl, candidates, subs, flags) { const results = []; for (let i = 0; i < candidates.length; i += 1) { const c = candidates[i]; // Skip candidates that rely on a placeholder we don't have. const needsEl = JSON.stringify(c.body).includes('ELEMENT_ID_PLACEHOLDER'); if (needsEl && !subs.ELEMENT_ID_PLACEHOLDER) { results.push({ ...c, status: 'skip', reason: 'no element id available' }); console.log(` [${i + 1}/${candidates.length}] ${c.label} → ⚠️ skipped (no element id)`); continue; } const body = substitutePlaceholders(c.body, subs); const targetUrl = c.url || defaultUrl; process.stdout.write(` [${i + 1}/${candidates.length}] ${c.label} → ${ICON_WAIT}`); const verdict = await fire({ method, url: targetUrl, body }); if (verdict.ok) { const shape = shapeSummary(verdict.response); console.log(`${ICON_OK} 200 (${verdict.elapsedMs}ms) ${shape}`); results.push({ ...c, verdict, shape }); } else { const schemaMsg = extractSchemaError(verdict.response) || `HTTP ${verdict.status}`; console.log(`${ICON_ERR} ${verdict.status || 'network'} (${verdict.elapsedMs}ms) ${schemaMsg}`); results.push({ ...c, verdict, schemaMsg }); } if (flags.showBody) { console.log(` url: ${targetUrl}`); console.log(` req: ${shortJson(body, 200)}`); } } return results; } /** * One-line summary of a 200 response for the try-shapes table. * Highlights whether the metric series has actual data points and * which keys are present under `series[0].view` — those are the * two things you always care about when comparing shapes. */ function shapeSummary(resp) { if (!resp || typeof resp !== 'object') return '(non-object response)'; const metric = resp?.metrics?.[0]; if (!metric) return `top-level=[${Object.keys(resp).join(',')}]`; // Preferred (live) shape: metrics[0].sites[0].{paths[] | healthscore | data.} if (Array.isArray(metric.sites) && metric.sites.length > 0) { const s0 = metric.sites[0]; if (Array.isArray(s0.paths)) { // LQM shape: paths[].data. const p0 = s0.paths[0]; const dataKeys = p0?.data ? Object.keys(p0.data).filter((k) => k !== 'sample_completeness').join(',') : '(none)'; const firstNumeric = p0?.data ? Object.entries(p0.data).find(([k, v]) => k !== 'sample_completeness' && typeof v === 'number') : null; const lastVal = firstNumeric ? `${firstNumeric[0]}=${firstNumeric[1]}` : '(no data)'; return `sites=${metric.sites.length} paths=${s0.paths.length} data.keys=[${dataKeys}] first=${lastVal}`; } // Healthscore v2.6 shape: sites[].healthscore or sites[].data.score const siteKeys = Object.keys(s0).join(','); const scoreKey = ['healthscore', 'health_score', 'score', 'value'].find((k) => typeof s0[k] === 'number'); const nestedScoreKey = s0.data ? ['healthscore', 'health_score', 'score', 'value'].find((k) => typeof s0.data[k] === 'number') : null; let val = '(none)'; if (scoreKey) val = `${scoreKey}=${s0[scoreKey]}`; else if (nestedScoreKey) val = `data.${nestedScoreKey}=${s0.data[nestedScoreKey]}`; return `sites=${metric.sites.length} site[0].keys=[${siteKeys}] score=${val}`; } // Legacy (pan.dev) shape: metrics[0].series[].data[].value const series = metric.series || []; const s0 = series[0]; const viewKeys = s0?.view ? Object.keys(s0.view).join(',') : '(none)'; const dataLen = Array.isArray(s0?.data) ? s0.data.length : 0; const lastVal = dataLen > 0 ? s0.data[dataLen - 1]?.value : '(no data)'; return `series=${series.length} view.keys=[${viewKeys}] data=${dataLen}pt lastVal=${lastVal}`; } function printSummary(label, results) { heading(`Summary: ${label}`); const winners = results.filter((r) => r.verdict?.ok); const losers = results.filter((r) => r.verdict && !r.verdict.ok); const skips = results.filter((r) => r.status === 'skip'); const urlSuffix = (r) => r.url ? ` [url override: ${r.url}]` : ''; if (winners.length === 0) { bad(`no candidate passed schema check`); } else { ok(`${winners.length} candidate(s) passed:`); for (const w of winners) { console.log(` • ${w.label} → ${w.shape}${urlSuffix(w)}`); } } if (losers.length > 0) { console.log(`\n ${losers.length} candidate(s) rejected:`); for (const l of losers) { console.log(` • ${l.label} → ${l.schemaMsg}${urlSuffix(l)}`); } } if (skips.length > 0) { console.log(`\n ${skips.length} skipped:`); for (const s of skips) console.log(` • ${s.label} → ${s.reason}${urlSuffix(s)}`); } console.log(''); info(`When you find a winner, update integrations/paloalto/metrics.js`); info(`and add a regression test in tests/paloalto.metrics.test.js.`); info(`Re-run this command with --show-body to see the exact request bodies.`); } // ─── Main ─────────────────────────────────────────────────────────── function printHelp() { // Extract the usage docblock from the top of this file so help stays // in sync with the docstring. Falls back to a short summary if the // file isn't readable (e.g. bundled). console.log([ 'Prisma SD-WAN API probe', '', 'Usage:', ' node scripts/prismaProbe.js [args] [flags]', '', 'Subcommands:', ' discover Full store discovery', ' site Resolve store → site', ' elements List site elements', ' waninterfaces List site waninterfaces', ' health Fetch healthscore', ' lqm [--metric X] Fetch LQM metric (latency|jitter|loss|mos)', ' alarms [--window minutes] Fetch alarms', ' raw [--body JSON] Arbitrary authenticated request', ' try-shapes health Test N healthscore body shapes', ' try-shapes lqm Test N LQM body shapes', ' try-shapes lqm-latency Sweep {name × unit} combos for latency', ' try-shapes lqm-jitter Sweep {name × unit} combos for jitter', ' try-shapes lqm-loss Sweep {name × unit} combos for packet loss', ' try-shapes lqm-mos Sweep {name × unit} combos for MOS', '', 'Global flags:', ' --json JSON output', ' --show-body Show request body on success too', ' --quiet Suppress axios request log line', ' --help, -h This help', '', 'Examples:', ' node scripts/prismaProbe.js discover 782', ' node scripts/prismaProbe.js try-shapes health 16158173173100144', ' node scripts/prismaProbe.js lqm 16158173173100144 16158173176610209 --metric loss', ' node scripts/prismaProbe.js try-shapes lqm-loss 16158173173100144 16158173176610209,1666974885552003096', ' node scripts/prismaProbe.js raw POST /sdwan/v3.7/api/events/query --body \'{"limit":{"count":5}}\'', ].join('\n')); } async function main() { const args = parseArgs(process.argv); if (args.flags.help || args._.length === 0) { printHelp(); process.exit(args.flags.help ? 0 : 1); return; } const sub = args._[0]; const dispatch = { discover: cmdDiscover, site: cmdSite, elements: cmdElements, waninterfaces: cmdWaninterfaces, health: cmdHealth, lqm: cmdLqm, alarms: cmdAlarms, raw: cmdRaw, 'try-shapes': cmdTryShapes, }; const fn = dispatch[sub]; if (!fn) { bad(`unknown subcommand "${sub}"`); printHelp(); process.exit(1); return; } try { await fn(args); } catch (err) { bad(err.message); process.exit(1); } } main();