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>
802 lines
32 KiB
JavaScript
802 lines
32 KiB
JavaScript
#!/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();
|