#!/usr/bin/env node /** * Prisma SD-WAN API probe — a debugging harness that lets you fire * individual Prisma API calls against a live tenant without going * through `/phonestatus` or `/voicediag`. This is the tool for * discovering the tenant's actual schema when documented shapes * (pan.dev + LIVEcommunity examples) don't match. * * Design goals: * - Zero side effects. Never mutates Prisma state. * - Reuses the production axios client (same auth, retry, session * priming). So a shape that works here will work in-band. * - Prints BOTH the request body and the response body verbatim * on failure, which is the whole point — you need to see the * exact SCHEMA_CHECK_FAIL field to know what to change. * - `try-shapes` mode iterates a curated list of body variants * against a single endpoint and reports which pass — much * faster than one-shot testing when you don't know the tenant's * accepted shape. * * ─── Usage ───────────────────────────────────────────────────────── * * node scripts/prismaProbe.js [args] [flags] * * Subcommands: * * discover * Full store discovery. Resolves store → site, then lists * elements + waninterfaces at the site. This is what the bot * does before firing metrics. * * site * Just resolve store number → Prisma site (no per-site drilldowns). * * elements * List elements at a site. * * waninterfaces * List waninterfaces (circuits) at a site. * * health * Fetch healthscore via the production `getHealthscore` wrapper. * Shows what the bot would send; use `try-shapes health` to * experiment with alternative body shapes. * * lqm [--metric latency|jitter|loss|mos] * Fetch a single LQM metric via the production `getLqmMetric` * wrapper. `--metric` defaults to `latency`. * * alarms [--window ] * Fetch alarms via the production `getAlarms` wrapper. Default * window is 60 minutes. * * raw [--body '{"json":"body"}'] * Send an arbitrary request through the authenticated client. * Useful for testing endpoints we don't yet wrap (e.g. * `/sdwan/monitor/v2.5/api/monitor/metrics`). * * try-shapes health * try-shapes lqm * try-shapes lqm-latency * try-shapes lqm-jitter * try-shapes lqm-loss * try-shapes lqm-mos * Combinatorial shape testing: fire N candidate request bodies * against the given endpoint and print which pass, which fail, * and — for failures — the exact SCHEMA_CHECK_FAIL field. * `lqm-loss` / `lqm-mos` sweep {metric name × unit} combos when * the metric's identifier is wrong (400 METRIC_UNIT_NOT_SUPPORTED * or METRIC_NOT_FOUND). * * try-shapes app-list * 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 * try-shapes app-audio-loss * try-shapes app-audio-jitter * try-shapes app-audio-bandwidth * 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=[] (numeric id, NOT * display name), view.individual="app", no filter.site. Run * `appdefs ` 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 '); heading(`Discover store ${storeNum}`); const site = await findSdwanSiteForStore(storeNum); if (!site) { bad(`No Prisma site for store ${storeNum}`); return; } ok(`site: ${site.name} (${site.id})`); info(`description: ${site.description || '(none)'}`); heading('Elements at this site'); const elements = await getElementsForSite(site.id); if (!elements || elements.length === 0) { bad('no elements at this site'); } else { for (const e of elements) { console.log(` - ${e.name || e.id} (${e.id}) model=${e.model || '?'} connected=${e.connected}`); } } heading('WAN interfaces (circuits) at this site'); const wans = await getWanInterfacesForSite(site.id); if (!wans || wans.length === 0) { bad('no waninterfaces at this site'); } else { for (const w of wans) { const admin = w.adminUp === null ? '?' : (w.adminUp ? 'up' : 'down'); console.log(` - ${w.name || w.id} (${w.id}) usedFor=${w.usedFor || '?'} adminUp=${admin}`); } } info(`Use these ids for follow-up calls:`); console.log(` siteId = ${site.id}`); console.log(` waninterfaceIds = ${(wans || []).map((w) => w.id).join(',')}`); } async function cmdSite(args) { const storeNum = args._[1]; if (!storeNum) throw new Error('usage: site '); const site = await findSdwanSiteForStore(storeNum); if (!site) { bad(`No Prisma site for store ${storeNum}`); return; } ok(`store ${storeNum} → ${site.name} (${site.id})`); } async function cmdElements(args) { const siteId = args._[1]; if (!siteId) throw new Error('usage: elements '); const els = await getElementsForSite(siteId); console.log(pretty(els)); } async function cmdWaninterfaces(args) { const siteId = args._[1]; if (!siteId) throw new Error('usage: waninterfaces '); const wans = await getWanInterfacesForSite(siteId); console.log(pretty(wans)); } async function cmdWiStatus(args) { const siteId = args._[1]; const wiId = args._[2]; if (!siteId || !wiId) throw new Error('usage: wi-status '); 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 '); 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 [--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 '); const startedAt = Date.now(); const resp = await getHealthscore(siteId); const elapsed = Date.now() - startedAt; if (!resp) { bad(`getHealthscore returned null in ${elapsed}ms — check the paloalto:metrics warning above for the 400 body`); return; } ok(`getHealthscore returned in ${elapsed}ms`); console.log(pretty(resp)); } async function cmdLqm(args) { const siteId = args._[1]; const wiCsv = args._[2]; const metric = args.flags.metric || 'latency'; if (!siteId || !wiCsv) throw new Error('usage: lqm [--metric latency|jitter|loss|mos]'); if (!LQM_METRIC_NAMES[metric]) { throw new Error(`unknown --metric "${metric}" (expected: ${Object.keys(LQM_METRIC_NAMES).join(', ')})`); } const wiIds = wiCsv.split(',').map((s) => s.trim()).filter(Boolean); const startedAt = Date.now(); const resp = await getLqmMetric(siteId, wiIds, metric); const elapsed = Date.now() - startedAt; if (!resp) { bad(`getLqmMetric(${metric}) returned null in ${elapsed}ms — check the paloalto:metrics warning above for the 400 body`); return; } ok(`getLqmMetric(${metric}) returned in ${elapsed}ms`); console.log(pretty(resp)); } async function cmdAlarms(args) { const siteId = args._[1]; const window = Number(args.flags.window) || 60; if (!siteId) throw new Error('usage: alarms [--window ]'); const startedAt = Date.now(); const resp = await getAlarms(siteId, window); const elapsed = Date.now() - startedAt; if (!resp) { bad(`getAlarms returned null in ${elapsed}ms — check the paloalto:metrics warning above`); return; } ok(`getAlarms returned in ${elapsed}ms`); console.log(pretty(resp)); } async function cmdRaw(args) { const method = (args._[1] || '').toUpperCase(); const url = args._[2]; if (!method || !url) throw new Error('usage: raw [--body \'{"json":"body"}\']'); let body; if (typeof args.flags.body === 'string') { try { body = JSON.parse(args.flags.body); } catch (e) { throw new Error(`--body is not valid JSON: ${e.message}`); } } const verdict = await fire({ method, url, body }); printVerdict(verdict, args.flags); } /** * appdefs — resolve the tenant's app catalog. Optional filter arg: * - `appdefs` → dump all apps (first `--limit` rows, * default 500) * - `appdefs ` → 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.. // Below variants exist so any future schema drift shows up as a // clean try-shapes comparison rather than a silent regression. const base = { start_time: windowIsoStart(5), interval: pickInterval5min(), metrics: [{ name: 'LqmLatencyPointMetric', statistics: ['average'], unit: 'milliseconds' }], view: {}, }; return [ { label: 'filter={site:[X], path:[WI]} ← LIVE WINNER (2026-07-09)', body: { ...base, filter: { site: ['SITE_ID_PLACEHOLDER'], path: ['WI_ID_PLACEHOLDER'] } } }, // ─── Alternative filter-key variants (all 400 on this tenant) ── { label: 'filter={site:[X], waninterface:[WI]} (rejected)', body: { ...base, filter: { site: ['SITE_ID_PLACEHOLDER'], waninterface: ['WI_ID_PLACEHOLDER'] } } }, { label: 'filter={site:[X], wan_interfaces:[WI]} (rejected)', body: { ...base, filter: { site: ['SITE_ID_PLACEHOLDER'], wan_interfaces: ['WI_ID_PLACEHOLDER'] } } }, { label: 'filter={site:[X]} (no circuit filter)', body: { ...base, filter: { site: ['SITE_ID_PLACEHOLDER'] } } }, { label: 'filter={path:[WI]} (no site filter)', body: { ...base, filter: { path: ['WI_ID_PLACEHOLDER'] } } }, { label: 'filter={site:[X], path:[WI]} + end_time added back (regression check)', body: { ...base, end_time: new Date().toISOString(), filter: { site: ['SITE_ID_PLACEHOLDER'], path: ['WI_ID_PLACEHOLDER'] }, } }, { label: 'view={individual:"path"}, filter={site:[X], path:[WI]}', body: { ...base, view: { individual: 'path' }, filter: { site: ['SITE_ID_PLACEHOLDER'], path: ['WI_ID_PLACEHOLDER'] } } }, ]; } async function cmdTryShapes(args) { const which = args._[1]; const siteId = args._[2]; if (!which || !siteId) throw new Error('usage: try-shapes []'); if (which === 'health') { heading(`try-shapes health → siteId=${siteId}`); const els = await getElementsForSite(siteId); const elId = els?.[0]?.id; if (!elId) info('no elements at site — element-based candidates will be skipped'); const subs = { SITE_ID_PLACEHOLDER: siteId }; if (elId) subs.ELEMENT_ID_PLACEHOLDER = elId; const results = await runCandidates('POST', '/sdwan/monitor/v2.0/api/monitor/aiops/health', healthscoreCandidates(), subs, args.flags); printSummary('healthscore', results); return; } if (which === 'lqm') { const wiCsv = args._[3]; if (!wiCsv) throw new Error('usage: try-shapes lqm '); heading(`try-shapes lqm → siteId=${siteId} wiIds=${wiCsv}`); const els = await getElementsForSite(siteId); const elId = els?.[0]?.id; const firstWi = wiCsv.split(',')[0].trim(); const subs = { SITE_ID_PLACEHOLDER: siteId, WI_ID_PLACEHOLDER: firstWi, }; if (elId) subs.ELEMENT_ID_PLACEHOLDER = elId; const results = await runCandidates('POST', '/sdwan/monitor/v2.0/api/monitor/lqm_point_metrics', lqmCandidates(), subs, args.flags); printSummary('lqm_point_metrics', results); return; } // Metric name + unit matrix probe. Fires each {name, unit} combo // and reports which return 200. Use when a specific metric key // (loss, mos, etc.) is 400-ing with METRIC_UNIT_NOT_SUPPORTED // or METRIC_NOT_FOUND. Assumes filter + view shape is already // solved (uses the current winning filter={site:[X], path:[WI]}). const LQM_MATRIX_TARGETS = { 'lqm-loss': [ // Verified winner (2026-07-09): LqmPktLossPointMetric + percentage. { name: 'LqmPktLossPointMetric', unit: 'percentage' }, // ← LIVE WINNER { name: 'LqmPktLossPointMetric', unit: 'percent' }, { name: 'LqmPktLossPointMetric', unit: 'pct' }, { name: 'LqmPktLossPointMetric', unit: 'ratio' }, { name: 'LqmPktLossPointMetric', unit: 'count' }, { name: 'LqmPktLossPointMetric', unit: 'gauge' }, { name: 'LqmPktLossPointMetric', unit: 'Percentage' }, // regression check — was wrong pre-fix { name: 'LqmPacketLossPointMetric', unit: 'percentage' }, { name: 'LqmLossPointMetric', unit: 'percentage' }, { name: 'LqmPacketDropPointMetric', unit: 'percentage' }, { name: 'LqmPktLossPercentPointMetric', unit: 'percentage' }, { name: 'LqmPktLossPctPointMetric', unit: 'percentage' }, ], 'lqm-mos': [ // Verified winner (2026-07-09): LqmMosPointMetric + count. { name: 'LqmMosPointMetric', unit: 'count' }, // ← LIVE WINNER { name: 'LqmMosPointMetric', unit: 'score' }, { name: 'LqmMosPointMetric', unit: 'mos' }, { name: 'LqmMosPointMetric', unit: 'ratio' }, { name: 'LqmMosPointMetric', unit: 'gauge' }, { name: 'LqmMosScorePointMetric', unit: 'count' }, { name: 'LqmMeanOpinionScorePointMetric', unit: 'count' }, ], 'lqm-latency': [ // Verified working (2026-07-09) via `lqm --metric latency`. { name: 'LqmLatencyPointMetric', unit: 'milliseconds' }, // ← LIVE WINNER { name: 'LqmLatencyPointMetric', unit: 'ms' }, { name: 'LqmLatencyPointMetric', unit: 'Milliseconds' }, { name: 'LqmLatencyPointMetric', unit: 'count' }, { name: 'LqmRttLatencyPointMetric', unit: 'milliseconds' }, { name: 'LqmLatencyRttPointMetric', unit: 'milliseconds' }, ], 'lqm-jitter': [ // Currently working via fallback scanner; run this to confirm // the "true" name/unit and whether jitter is directional. { name: 'LqmJitterPointMetric', unit: 'milliseconds' }, // ← current default { name: 'LqmJitterPointMetric', unit: 'ms' }, { name: 'LqmJitterPointMetric', unit: 'count' }, { name: 'LqmRttJitterPointMetric', unit: 'milliseconds' }, { name: 'LqmJitterMsPointMetric', unit: 'milliseconds' }, ], }; if (LQM_MATRIX_TARGETS[which]) { const wiCsv = args._[3]; if (!wiCsv) throw new Error(`usage: try-shapes ${which} `); heading(`try-shapes ${which} → siteId=${siteId} wiIds=${wiCsv}`); const wiIds = wiCsv.split(',').map((s) => s.trim()).filter(Boolean); const nameUnitMatrix = LQM_MATRIX_TARGETS[which]; const candidates = nameUnitMatrix.map((mu) => ({ label: `name="${mu.name}", unit="${mu.unit}"`, body: { start_time: windowIsoStart(5), interval: pickInterval5min(), metrics: [{ name: mu.name, statistics: ['average'], unit: mu.unit }], view: {}, filter: { site: [siteId], path: wiIds }, }, })); const results = await runCandidates( 'POST', '/sdwan/monitor/v2.0/api/monitor/lqm_point_metrics', candidates, {}, args.flags, ); printSummary(`${which} name+unit matrix`, results); return; } // ── 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 ` 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 ` 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} \n` + ` Use \`node scripts/prismaProbe.js appdefs \` 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 --body \'\'`'); 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 '); } 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.} if (Array.isArray(metric.sites) && metric.sites.length > 0) { const s0 = metric.sites[0]; if (Array.isArray(s0.paths)) { // LQM shape: paths[].data. const p0 = s0.paths[0]; const dataKeys = p0?.data ? Object.keys(p0.data).filter((k) => k !== 'sample_completeness').join(',') : '(none)'; const firstNumeric = p0?.data ? Object.entries(p0.data).find(([k, v]) => k !== 'sample_completeness' && typeof v === 'number') : null; const lastVal = firstNumeric ? `${firstNumeric[0]}=${firstNumeric[1]}` : '(no data)'; return `sites=${metric.sites.length} paths=${s0.paths.length} data.keys=[${dataKeys}] first=${lastVal}`; } // Healthscore v2.6 shape: sites[].healthscore or sites[].data.score const siteKeys = Object.keys(s0).join(','); const scoreKey = ['healthscore', 'health_score', 'score', 'value'].find((k) => typeof s0[k] === 'number'); const nestedScoreKey = s0.data ? ['healthscore', 'health_score', 'score', 'value'].find((k) => typeof s0.data[k] === 'number') : null; let val = '(none)'; if (scoreKey) val = `${scoreKey}=${s0[scoreKey]}`; else if (nestedScoreKey) val = `data.${nestedScoreKey}=${s0.data[nestedScoreKey]}`; return `sites=${metric.sites.length} site[0].keys=[${siteKeys}] score=${val}`; } // Legacy (pan.dev) shape: metrics[0].series[].data[].value const series = metric.series || []; const s0 = series[0]; const viewKeys = s0?.view ? Object.keys(s0.view).join(',') : '(none)'; const dataLen = Array.isArray(s0?.data) ? s0.data.length : 0; const lastVal = dataLen > 0 ? s0.data[dataLen - 1]?.value : '(no data)'; return `series=${series.length} view.keys=[${viewKeys}] data=${dataLen}pt lastVal=${lastVal}`; } function printSummary(label, results) { heading(`Summary: ${label}`); const winners = results.filter((r) => r.verdict?.ok); const losers = results.filter((r) => r.verdict && !r.verdict.ok); const skips = results.filter((r) => r.status === 'skip'); const urlSuffix = (r) => r.url ? ` [url override: ${r.url}]` : ''; if (winners.length === 0) { bad(`no candidate passed schema check`); } else { ok(`${winners.length} candidate(s) passed:`); for (const w of winners) { console.log(` • ${w.label} → ${w.shape}${urlSuffix(w)}`); } } if (losers.length > 0) { console.log(`\n ${losers.length} candidate(s) rejected:`); for (const l of losers) { console.log(` • ${l.label} → ${l.schemaMsg}${urlSuffix(l)}`); } } if (skips.length > 0) { console.log(`\n ${skips.length} skipped:`); for (const s of skips) console.log(` • ${s.label} → ${s.reason}${urlSuffix(s)}`); } console.log(''); info(`When you find a winner, update integrations/paloalto/metrics.js`); info(`and add a regression test in tests/paloalto.metrics.test.js.`); info(`Re-run this command with --show-body to see the exact request bodies.`); } // ─── Main ─────────────────────────────────────────────────────────── function printHelp() { // Extract the usage docblock from the top of this file so help stays // in sync with the docstring. Falls back to a short summary if the // file isn't readable (e.g. bundled). console.log([ 'Prisma SD-WAN API probe', '', 'Usage:', ' node scripts/prismaProbe.js [args] [flags]', '', 'Subcommands:', ' discover Full store discovery', ' site Resolve store → site', ' elements List site elements', ' waninterfaces List site waninterfaces', ' wi-status Runtime waninterface status', ' tunnels Overlay / VPN tunnels for site', ' app-by-path-type Voice DPI broken out by path_type', ' health Fetch healthscore', ' lqm [--metric X] Fetch LQM metric (latency|jitter|loss|mos)', ' alarms [--window minutes] Fetch alarms', ' raw [--body JSON] Arbitrary authenticated request', ' appdefs [] [--limit N] Dump the tenant app catalog (optional substring filter)', ' try-shapes health Test N healthscore body shapes', ' try-shapes lqm Test N LQM body shapes', ' try-shapes lqm-latency Sweep {name × unit} combos for latency', ' try-shapes lqm-jitter Sweep {name × unit} combos for jitter', ' try-shapes lqm-loss Sweep {name × unit} combos for packet loss', ' try-shapes lqm-mos Sweep {name × unit} combos for MOS', ' try-shapes app-list Discover per-site app names Prisma sees', ' try-shapes app-audio-mos [app=rtp-base] Sweep per-app audio MOS shapes', ' try-shapes app-audio-loss [app=rtp-base] Sweep per-app audio loss shapes', ' try-shapes app-audio-jitter [app=rtp-base] Sweep per-app audio jitter shapes', ' try-shapes app-audio-bandwidth [app=rtp-base] Sweep per-app audio BW shapes', ' try-shapes app-by-path 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();