collabSupport/scripts/prismaProbe.js
jmcqueen b802383441 Add Prisma SD-WAN voice-quality enrichment for /phonestatus + /voicediag
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>
2026-07-09 09:45:29 -04:00

802 lines
32 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 <subcommand> [args] [flags]
*
* Subcommands:
*
* discover <storeNum>
* Full store discovery. Resolves store → site, then lists
* elements + waninterfaces at the site. This is what the bot
* does before firing metrics.
*
* site <storeNum>
* Just resolve store number → Prisma site (no per-site drilldowns).
*
* elements <siteId>
* List elements at a site.
*
* waninterfaces <siteId>
* List waninterfaces (circuits) at a site.
*
* health <siteId>
* Fetch healthscore via the production `getHealthscore` wrapper.
* Shows what the bot would send; use `try-shapes health` to
* experiment with alternative body shapes.
*
* lqm <siteId> <wiId1[,wiId2,...]> [--metric latency|jitter|loss|mos]
* Fetch a single LQM metric via the production `getLqmMetric`
* wrapper. `--metric` defaults to `latency`.
*
* alarms <siteId> [--window <minutes>]
* Fetch alarms via the production `getAlarms` wrapper. Default
* window is 60 minutes.
*
* raw <METHOD> <path> [--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 <siteId>
* try-shapes lqm <siteId> <wiId1,wiId2>
* try-shapes lqm-latency <siteId> <wiId1,wiId2>
* try-shapes lqm-jitter <siteId> <wiId1,wiId2>
* try-shapes lqm-loss <siteId> <wiId1,wiId2>
* try-shapes lqm-mos <siteId> <wiId1,wiId2>
* 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 <storeNum>');
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 <storeNum>');
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 <siteId>');
const els = await getElementsForSite(siteId);
console.log(pretty(els));
}
async function cmdWaninterfaces(args) {
const siteId = args._[1];
if (!siteId) throw new Error('usage: waninterfaces <siteId>');
const wans = await getWanInterfacesForSite(siteId);
console.log(pretty(wans));
}
async function cmdHealth(args) {
const siteId = args._[1];
if (!siteId) throw new Error('usage: health <siteId>');
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 <siteId> <wiId1[,wiId2,...]> [--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 <siteId> [--window <minutes>]');
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 <METHOD> <path> [--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.<metricKey>.
// 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 <health|lqm> <siteId> [<wiIds>]');
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 <siteId> <wiId1[,wiId2,...]>');
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} <siteId> <wiId1[,wiId2,...]>`);
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.<key>}
if (Array.isArray(metric.sites) && metric.sites.length > 0) {
const s0 = metric.sites[0];
if (Array.isArray(s0.paths)) {
// LQM shape: paths[].data.<metricKey>
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 <subcommand> [args] [flags]',
'',
'Subcommands:',
' discover <storeNum> Full store discovery',
' site <storeNum> Resolve store → site',
' elements <siteId> List site elements',
' waninterfaces <siteId> List site waninterfaces',
' health <siteId> Fetch healthscore',
' lqm <siteId> <wiCsv> [--metric X] Fetch LQM metric (latency|jitter|loss|mos)',
' alarms <siteId> [--window minutes] Fetch alarms',
' raw <METHOD> <path> [--body JSON] Arbitrary authenticated request',
' try-shapes health <siteId> Test N healthscore body shapes',
' try-shapes lqm <siteId> <wiCsv> Test N LQM body shapes',
' try-shapes lqm-latency <siteId> <wiCsv> Sweep {name × unit} combos for latency',
' try-shapes lqm-jitter <siteId> <wiCsv> Sweep {name × unit} combos for jitter',
' try-shapes lqm-loss <siteId> <wiCsv> Sweep {name × unit} combos for packet loss',
' try-shapes lqm-mos <siteId> <wiCsv> 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();