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>
1292 lines
54 KiB
JavaScript
1292 lines
54 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).
|
||
*
|
||
* 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();
|