collabSupport/scripts/prismaProbe.js
jmcqueen 90a56c4640 Fix Prisma overlay tunnel discovery with site-scoped query_params.
Use topology/links and anynetlinks filters with eq/in operators so
store WAN follow-ups return real peer tunnels instead of unscoped dumps.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-28 09:01:40 -04:00

1292 lines
54 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).
*
* try-shapes app-list <siteId>
* Discover which per-app metrics Prisma exposes for the site
* (Webex_Calling_RTP, rtp-base, MS_Teams_RTP, voice_rtp, ...).
* Run this FIRST before any of the app-audio-* probes so you
* know the right app id for your tenant.
*
* try-shapes app-audio-mos <siteId> <appId>
* try-shapes app-audio-loss <siteId> <appId>
* try-shapes app-audio-jitter <siteId> <appId>
* try-shapes app-audio-bandwidth <siteId> <appId>
* Sweep candidate metric name × unit combinations for the
* "Application Path Details" per-app DPI metrics. These are
* what actual voice traffic (e.g. Webex_Calling_RTP, rtp-base)
* experiences, which is often materially worse than the LQM
* link-probe signal. Uses
* the HAR-confirmed shape: filter.app=[<id>] (numeric id, NOT
* display name), view.individual="app", no filter.site. Run
* `appdefs <needle>` first to look up the app id.
* Once a shape lands, feed the winner back into
* integrations/paloalto/metrics.js as a getAppMetric().
*
* 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,
getWanInterfaceStatus,
getVpnLinksForSite,
getHealthscore,
getLqmMetric,
getAppMetric,
getAppMetricsByPathType,
getAlarms,
LQM_METRIC_NAMES,
VOICE_PATH_TYPE_SUBSET,
} 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 cmdWiStatus(args) {
const siteId = args._[1];
const wiId = args._[2];
if (!siteId || !wiId) throw new Error('usage: wi-status <siteId> <wanInterfaceId>');
heading(`WAN interface status ${siteId}/${wiId}`);
const status = await getWanInterfaceStatus(siteId, wiId);
if (!status) {
bad('no status returned (endpoint failed or empty)');
return;
}
ok(`operationalUp=${status.operationalUp} adminUp=${status.adminUp} elementId=${status.elementId || '?'}`);
console.log(pretty(status.raw));
}
async function cmdTunnels(args) {
const siteId = args._[1];
if (!siteId) throw new Error('usage: tunnels <siteId>');
heading(`Overlay tunnels for site ${siteId}`);
const tunnels = await getVpnLinksForSite(siteId);
if (tunnels.length === 0) {
bad(
'no tunnels — try: raw POST /sdwan/v2.0/api/topology/links/query ' +
`--body '{"query_params":{"target_site_id":{"in":["${siteId}"]}},"limit":50}' ` +
'(must use query_params + eq/in — plain query filters are ignored)',
);
return;
}
ok(`${tunnels.length} tunnel(s)`);
for (const t of tunnels) {
const up = t.up == null ? '?' : (t.up ? 'up' : 'DOWN');
const al = t.anynetId ? ` al=${t.anynetId}` : '';
console.log(` - ${t.id} peer=${t.peerLabel} state=${t.state} up=${up} if=${t.relatedInterfaceId || '?'}${al}`);
}
if (args.flags.json) console.log(pretty(tunnels));
}
async function cmdAppByPathType(args) {
const siteId = args._[1];
const appId = args._[2];
const metric = args.flags.metric || 'loss';
if (!siteId || !appId) {
throw new Error('usage: app-by-path-type <siteId> <appId> [--metric loss|jitter|bandwidth|mos]');
}
heading(`App ${metric} by path_type for site ${siteId} app ${appId}`);
const map = await getAppMetricsByPathType(siteId, appId, metric, 1440, [...VOICE_PATH_TYPE_SUBSET]);
for (const [pt, raw] of Object.entries(map)) {
const pts = raw?.metrics?.[0]?.series?.[0]?.data?.[0]?.datapoints?.length
?? raw?.metrics?.[0]?.series?.[0]?.data?.length
?? '?';
console.log(` ${pt}: ${raw ? 'ok' : 'null'} (datapoints≈${pts})`);
}
if (args.flags.showBody || args.flags.json) console.log(pretty(map));
}
async function cmdHealth(args) {
const siteId = args._[1];
if (!siteId) throw new Error('usage: health <siteId>');
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);
}
/**
* appdefs — resolve the tenant's app catalog. Optional filter arg:
* - `appdefs` → dump all apps (first `--limit` rows,
* default 500)
* - `appdefs <substring>` → grep the `display_name` and `name`
* fields for a case-insensitive match
*
* Uses the endpoint confirmed working on this tenant via app-list probe:
* POST /sdwan/v2.5/api/appdefs/query
*
* Response items each have `id`, `name`, `display_name`, `category`.
* The `id` is what per-app metric calls need in `filter.app`.
*/
async function cmdAppdefs(args) {
const needle = args._[1] || '';
const limit = Number(args.flags.limit) || 500;
heading(`appdefs — limit=${limit}${needle ? `, needle="${needle}"` : ''}`);
const verdict = await fire({
method: 'POST',
url: '/sdwan/v2.5/api/appdefs/query',
body: { limit },
});
if (!verdict.ok) {
printVerdict(verdict, args.flags);
return;
}
const items = verdict.data?.items || [];
info(`fetched ${items.length} appdefs (total_count=${verdict.data?.total_count || '?'})`);
const rows = needle
? items.filter((a) => {
const hay = [
a?.name, a?.display_name, a?.category, a?.id,
].filter(Boolean).join(' ').toLowerCase();
return hay.includes(needle.toLowerCase());
})
: items;
ok(`${rows.length} match${rows.length === 1 ? '' : 'es'}`);
console.log('id name display_name');
console.log('──────────────────────────── ─────────────────────────── ─────────────────────');
for (const a of rows.slice(0, 50)) {
const id = String(a?.id || '').padEnd(28);
const name = String(a?.name || '').padEnd(27);
const disp = String(a?.display_name || '');
console.log(`${id} ${name} ${disp}`);
}
if (rows.length > 50) info(`… +${rows.length - 50} more (use --limit to widen the initial fetch, or narrow the needle)`);
}
// ─── 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;
}
// ── App-metrics matrix ────────────────────────────────────────────
// Explore Application Path Details signals — the "rtp-base" style
// per-app DPI metrics that the Prisma UI's Application Path Details
// page renders. These are what actual voice traffic experiences,
// as opposed to LQM which measures synthetic link probes.
//
// Unlike LQM, we don't yet know the metric name/unit or the exact
// filter shape (specifically, whether Prisma expects `app`, `apps`,
// `app_name`, or `application`). So each candidate combines:
// - endpoint (v2.6 monitor/metrics is the modern path; v2.0 has
// an appstats + aggregate_flows variant we also try)
// - metric name variants Prisma might use
// - unit variants
// - filter-key variants for the app selector
//
// Discovery flow: run `try-shapes app-list <siteId>` first to see
// what app names Prisma actually has for the site (rtp-base vs
// rtp vs voice_rtp vs ...). Then feed the right one into
// `try-shapes app-audio-mos|loss|jitter`.
// NOTE: HAR capture 2026-07-09 confirmed Prisma's v2.6 per-app family
// uses plain Title-case names (NO `PointMetric` suffix). Real names
// observed: ApplicationHealthscore/gauge, BandwidthUsage/Mbps,
// TCPFlowCount/count. The audio metric names below all follow the
// same convention — no `PointMetric`, no `Point`, just words.
//
// Also: filter uses `filter.app` with the APP ID (e.g. "15932...")
// NOT the display name ("rtp-base"). Look up the id with the
// `appdefs` subcommand first.
// Metric name × unit sweeps for the "Application Path Details"
// per-app metrics. Winners (confirmed via HAR 2026-07-09) are marked.
// The rejected guesses stay in as regression guards — if Prisma
// ever renames them we'll see it here.
const APP_METRIC_MATRIX = {
'app-audio-mos': {
label: 'per-app audio MOS score',
// WINNER: AppAudioMos + count. NOTE: this metric requires
// filter.direction="Ingress" (audio quality is what you
// RECEIVE) and does NOT use filter.path_type.
metricNames: [
'AppAudioMos', // ← LIVE WINNER (2026-07-09)
'AudioMOSScore', 'AudioMosScore', 'AudioMOS', 'AudioMos',
'ApplicationAudioMOS', 'MOSScore', 'VoiceMOS', 'VoiceMOSScore',
],
units: ['count', 'gauge', 'score'],
direction: 'Ingress',
includePathType: false,
},
'app-audio-loss': {
label: 'per-app audio packet loss',
// WINNER: AppPerfUDPAudioPacketLoss + percentage. Uses
// filter.direction="Ingress" AND filter.path_type=[all].
metricNames: [
'AppPerfUDPAudioPacketLoss', // ← LIVE WINNER (2026-07-09)
'AudioPacketLoss', 'AudioPktLoss', 'AudioLoss',
'ApplicationAudioPacketLoss', 'PacketLoss', 'VoicePacketLoss',
],
units: ['percentage', 'percent', 'gauge', 'count'],
direction: 'Ingress',
includePathType: true,
},
'app-audio-jitter': {
label: 'per-app audio jitter',
// WINNER: AppPerfUDPAudioJitter + milliseconds. Uses
// filter.direction="Ingress" AND filter.path_type=[all].
metricNames: [
'AppPerfUDPAudioJitter', // ← LIVE WINNER (2026-07-09)
'AudioJitter', 'ApplicationAudioJitter', 'Jitter', 'VoiceJitter',
],
units: ['milliseconds', 'gauge', 'count'],
direction: 'Ingress',
includePathType: true,
},
'app-audio-bandwidth': {
label: 'per-app audio bandwidth',
// WINNER: AppPerfUDPAudioBandwidth + Mbps. Uses filter.path_type
// but does NOT need filter.direction (bandwidth is bidirectional
// aggregate on this endpoint).
metricNames: [
'AppPerfUDPAudioBandwidth', // ← LIVE WINNER (2026-07-09)
'AudioBandwidth', 'AudioBandwidthUsage', 'ApplicationAudioBandwidth',
'BandwidthUsage', // Tenant-wide bandwidth (also works,
// useful control that filter.app narrows it)
],
units: ['Mbps', 'kbps', 'gauge'],
direction: null,
includePathType: true,
},
};
if (APP_METRIC_MATRIX[which]) {
// Second positional arg is the APP ID (not the name) — Prisma's
// filter.app expects the numeric id. Run `appdefs <needle>` to
// discover an app id (or grab one from the browser dev tools on
// the Prisma UI's Application Path Details page).
const appId = args._[3];
if (!appId) {
throw new Error(
`usage: try-shapes ${which} <siteId> <appId>\n` +
` Use \`node scripts/prismaProbe.js appdefs <needle>\` to find the app id.`,
);
}
heading(`try-shapes ${which} → siteId=${siteId} appId=${appId}`);
const target = APP_METRIC_MATRIX[which];
// HAR-confirmed request shape (rtp-base-metricCG00127.har,
// 2026-07-09): start_time+end_time required, filter.site works,
// audio-quality metrics need filter.direction="Ingress",
// AppPerf* metrics take filter.path_type, AppAudioMos does not,
// view is empty `{}` for these single-metric queries.
const endTime = new Date().toISOString();
const startTime24h = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
const ALL_PATH_TYPES = ['DirectInternet', 'VPN', 'PrivateWAN', 'PrivateVPN', 'ServiceLink'];
const candidates = target.metricNames.flatMap((name) =>
target.units.map((unit) => {
const filter = { site: [String(siteId)], app: [String(appId)] };
if (target.includePathType) filter.path_type = ALL_PATH_TYPES;
if (target.direction) filter.direction = target.direction;
const filterHint = [
`filter.app=[${appId}]`,
`filter.site=[${siteId}]`,
target.includePathType ? 'filter.path_type=[…]' : '',
target.direction ? `filter.direction="${target.direction}"` : '',
].filter(Boolean).join(', ');
return {
label: `name="${name}", unit="${unit}", ${filterHint}`,
body: {
start_time: startTime24h,
end_time: endTime,
interval: pickInterval5min(),
metrics: [{ name, statistics: ['average'], unit }],
filter,
view: {},
},
};
}),
);
const results = await runCandidates(
'POST',
'/sdwan/monitor/v2.6/api/monitor/metrics',
candidates, {}, args.flags,
);
printSummary(`${which} (${target.label})`, results);
return;
}
// ── App discovery — list which app names Prisma sees at the site
//
// Two-pronged approach:
// 1. Probe several plausible appdefs / appdef-query endpoints so
// we can dump a global catalog of app names Prisma knows.
// 2. Sweep a large-ish list of candidate metric names against the
// v2.6 monitor/metrics endpoint with `view.individual="app"`.
// Any metric that returns 200 + a data payload will surface
// the app names as a side-effect (the response includes an
// `app` dimension per row).
//
// The correct v2.6 metric names for per-app / per-app-path signals
// aren't documented consistently — Prisma renames them across
// versions. This sweep should reveal at least one that works so we
// can then use it as the base for per-app-audio-* probes.
//
// As a fast alternative to blind guessing, the operator can also
// open the "Application Path Details" page in the Prisma UI with
// browser dev tools open (Network tab, filter for /monitor/) — the
// page fires the exact API call we need to mimic. Copy the request
// body from there into the raw subcommand for an instant answer.
if (which === 'app-list') {
heading(`try-shapes app-list → siteId=${siteId}`);
// 1. Appdef catalog endpoints — different Prisma versions expose
// the list of known apps at different paths. All are read-only.
const appdefEndpoints = [
{ method: 'POST', url: '/sdwan/appdefs/v2.5/api/appdefs/query',
body: { limit: 200 },
label: 'POST /appdefs/v2.5/api/appdefs/query {limit:200}' },
{ method: 'POST', url: '/sdwan/appdefs/v2.1/api/appdefs/query',
body: { limit: 200 },
label: 'POST /appdefs/v2.1/api/appdefs/query {limit:200}' },
{ method: 'GET', url: '/sdwan/v2.5/api/appdefs',
label: 'GET /v2.5/api/appdefs (tenant-wide, top-level)' },
{ method: 'GET', url: '/sdwan/v2.1/api/appdefs',
label: 'GET /v2.1/api/appdefs (tenant-wide, top-level)' },
{ method: 'POST', url: '/sdwan/v2.5/api/appdefs/query',
body: { limit: 200 },
label: 'POST /v2.5/api/appdefs/query {limit:200}' },
{ method: 'GET', url: '/sdwan/config/v2.5/api/appdefs',
label: 'GET /config/v2.5/api/appdefs' },
];
// 2. Metric-name catalog sweep. Confirmed via HAR capture 2026-07-09
// that Prisma's v2.6 per-app family does NOT use the `PointMetric`
// suffix seen in the LQM family. Real names are plain Title case
// like ApplicationHealthscore, BandwidthUsage, TCPFlowCount.
// Below is a mix of KNOWN-WORKING names (as regression guards +
// dimension probes) and PLAUSIBLE audio-metric guesses following
// the observed naming convention.
const perAppMetricNames = [
// ─── CONFIRMED WORKING NAMES (HAR 2026-07-09) ─────────────────
// Use these as canary checks — if a Prisma tenant update ever
// renames them, we'll see it here.
'ApplicationHealthscore',
'BandwidthUsage',
'TCPFlowCount',
'UDPFlowCount',
'AppSuccessfulConnections',
'AppSuccessfulTransactions',
'AppFailedToEstablish',
'AppTransactionFailures',
// ─── PLAUSIBLE AUDIO / MOS GUESSES (per naming convention) ────
// Prisma UI shows Audio MOS Score / Audio Packet Loss / Audio
// Jitter on the Application Path Details page — the API names
// probably follow the same PlainTitle convention we just learned.
'AudioMOSScore',
'AudioMosScore',
'AudioMOS',
'AudioMos',
'AudioPacketLoss',
'AudioPktLoss',
'AudioLoss',
'AudioJitter',
'AudioBandwidth',
'AudioBandwidthUsage',
'ApplicationAudioMOS',
'ApplicationAudioPacketLoss',
'ApplicationAudioJitter',
'MOSScore',
'VoiceMOS',
'VoiceMOSScore',
// ─── VIDEO EQUIVALENTS (nice-to-have) ─────────────────────────
'VideoMOSScore',
'VideoPacketLoss',
'VideoJitter',
'VideoBandwidth',
];
// Metric sweep — uses the HAR-confirmed request shape:
// start_time + end_time (both required),
// view: { individual: "app", summary: false },
// NO filter.site (per-app queries are tenant-wide-per-app),
// NO filter.app (we're testing WHICH metric names exist for
// any app; adding an app id would narrow to a specific one).
//
// Sweep {gauge, count, Mbps} — the three units we've actually
// observed working. Skip the no-unit case (already known to
// return SCHEMA_CHECK_FAIL: "unit: is missing but it is required").
const endTime = new Date().toISOString();
const startTime = new Date(Date.now() - 60 * 60 * 1000).toISOString();
const metricSweepCandidates = [];
for (const name of perAppMetricNames) {
for (const unitVariant of ['gauge', 'count', 'Mbps']) {
metricSweepCandidates.push({
method: 'POST',
url: '/sdwan/monitor/v2.6/api/monitor/metrics',
label: `v2.6 monitor/metrics name="${name}", unit="${unitVariant}"`,
body: {
start_time: startTime,
end_time: endTime,
interval: pickInterval5min(),
metrics: [{ name, statistics: ['average'], unit: unitVariant }],
view: { individual: 'app', summary: false },
},
});
}
}
const combined = [];
for (const c of [...appdefEndpoints, ...metricSweepCandidates]) {
const results = await runCandidates(
c.method || 'POST', c.url,
[{ label: c.label, body: c.body || null }],
{}, args.flags,
);
combined.push(...results);
}
printSummary('app-list', combined);
info('If nothing passed, the fastest path from here is:');
info(' 1. Open the "Application Path Details" page for a site in the Prisma UI');
info(' 2. Open browser dev tools → Network tab → filter for "monitor" or "metrics"');
info(' 3. Right-click the request → Copy → Copy as cURL (or copy the request body)');
info(' 4. Paste the body into: `npm run prisma:probe -- raw POST <url> --body \'<json>\'`');
info('That surfaces the exact metric name + shape the UI uses.');
return;
}
// Phase 2 discovery: does AppPerf* accept filter.path = waninterface id?
if (which === 'app-by-path') {
const appId = args._[3];
const wiId = args._[4];
if (!appId || !wiId) {
throw new Error('usage: try-shapes app-by-path <siteId> <appId> <wanInterfaceId>');
}
heading(`try-shapes app-by-path → site=${siteId} app=${appId} path=${wiId}`);
const candidates = [
{
label: 'loss + path_type=all + path=[wi]',
body: null,
run: () => getAppMetric(siteId, appId, 'loss', {
windowMinutes: 60,
pathIds: [wiId],
}),
},
{
label: 'loss + path_type=[VPN] + path=[wi]',
body: null,
run: () => getAppMetric(siteId, appId, 'loss', {
windowMinutes: 60,
pathTypes: ['VPN'],
pathIds: [wiId],
}),
},
{
label: 'jitter + path=[wi]',
body: null,
run: () => getAppMetric(siteId, appId, 'jitter', {
windowMinutes: 60,
pathIds: [wiId],
}),
},
];
for (const c of candidates) {
process.stdout.write(` ${c.label}${ICON_WAIT}`);
const started = Date.now();
const raw = await c.run();
const ms = Date.now() - started;
if (raw) {
console.log(`${ICON_OK} data (${ms}ms)`);
if (args.flags.showBody) console.log(pretty(raw).slice(0, 500));
} else {
console.log(`${ICON_ERR} null/failed (${ms}ms)`);
}
}
info('If all null, filter.path is likely rejected — keep path_type-only attribution.');
return;
}
throw new Error(
`unknown try-shapes target "${which}" — expected "health", "lqm", ` +
`"lqm-latency", "lqm-jitter", "lqm-loss", "lqm-mos", ` +
`"app-list", "app-audio-mos", "app-audio-loss", "app-audio-jitter", "app-audio-bandwidth", ` +
`or "app-by-path"`,
);
}
/**
* 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',
' wi-status <siteId> <wanInterfaceId> Runtime waninterface status',
' tunnels <siteId> Overlay / VPN tunnels for site',
' app-by-path-type <siteId> <appId> Voice DPI broken out by path_type',
' health <siteId> Fetch healthscore',
' lqm <siteId> <wiCsv> [--metric X] Fetch LQM metric (latency|jitter|loss|mos)',
' alarms <siteId> [--window minutes] Fetch alarms',
' raw <METHOD> <path> [--body JSON] Arbitrary authenticated request',
' appdefs [<needle>] [--limit N] Dump the tenant app catalog (optional substring filter)',
' 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',
' try-shapes app-list <siteId> Discover per-site app names Prisma sees',
' try-shapes app-audio-mos <siteId> [app=rtp-base] Sweep per-app audio MOS shapes',
' try-shapes app-audio-loss <siteId> [app=rtp-base] Sweep per-app audio loss shapes',
' try-shapes app-audio-jitter <siteId> [app=rtp-base] Sweep per-app audio jitter shapes',
' try-shapes app-audio-bandwidth <siteId> [app=rtp-base] Sweep per-app audio BW shapes',
' try-shapes app-by-path <siteId> <appId> <wiId> Probe filter.path for voice DPI',
'',
'Global flags:',
' --json JSON output',
' --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 appdefs rtp',
' node scripts/prismaProbe.js appdefs --limit 1000',
' node scripts/prismaProbe.js try-shapes app-list 16158173173100144',
' node scripts/prismaProbe.js try-shapes app-audio-mos 16158173173100144 15932000365560116',
' node scripts/prismaProbe.js try-shapes app-audio-loss 16158173173100144 15932000365560116',
' 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,
'wi-status': cmdWiStatus,
tunnels: cmdTunnels,
'app-by-path-type': cmdAppByPathType,
health: cmdHealth,
lqm: cmdLqm,
alarms: cmdAlarms,
raw: cmdRaw,
appdefs: cmdAppdefs,
'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();