Adds three new SD-WAN checks (wanAppRtpMos/Loss/Jitter) that measure
REAL voice-traffic quality on actual RTP frames via Prisma DPI, not
synthetic link probes. Graded against the WORST 5-minute window so
transient degradation the 24h link-probe averages smooth away
actually surfaces.
Voice-app selection is tenant-configurable via PRISMA_APP_ID_VOICE +
PRISMA_APP_NAME_VOICE (Webex_Calling_RTP recommended for Webex
Calling shops — the Webex-specific DPI signature excludes non-Webex
UDP noise). Legacy PRISMA_APP_ID_RTP_BASE still honored with a
one-time deprecation warning.
Widens the default WAN look-back from 24h to 7 days: per-app metrics
only get datapoints when calls actually happen, so sporadic Webex
Calling stores (3-4 calls/day) need a wider window for worst-window
statistics to be meaningful. Interval picker snaps 7d to 1hour
buckets (168 pts) to keep payloads bounded while preserving
worst-hour granularity. Hard-capped at 7d — beyond that Prisma
downsamples to 1-day buckets and the signal collapses.
Also:
- Client-side concurrency limiter (PRISMA_MAX_INFLIGHT, default 3)
to prevent 429 cascades when /voicediag fans out 10+ parallel
metric fetches
- "View in Prisma UI" deep links in both /phonestatus WAN follow-up
and /voicediag details, threading through a new
integrations/paloalto/urls.js builder
- humanizeMetricUnit maps raw API unit strings ("percentage",
"milliseconds") to display symbols ("%", "ms") to fix
"11.83percentage" leaking to the UI
- getAppAudio envelope distinguishes not-configured / fetch-failed /
no-traffic states so misleading "set env var" messages don't fire
when the real problem is a 429
Co-authored-by: Cursor <cursoragent@cursor.com>
503 lines
21 KiB
JavaScript
503 lines
21 KiB
JavaScript
// src/integrations/paloalto/metrics.js
|
||
//
|
||
// Thin POST wrappers around Prisma SD-WAN's monitor v2.0 metrics
|
||
// API. All endpoints are POST — Prisma's monitor API always accepts
|
||
// a JSON body with `start_time` + `interval` + `metrics` + `filter`,
|
||
// even for what would idiomatically be a GET on other systems. See
|
||
// https://pan.dev/sdwan/api/metrics/ for the endpoint catalog.
|
||
//
|
||
// Endpoint / body-shape notes verified against a live SASE tenant.
|
||
// This section is the historical record of what broke and how the
|
||
// tenant's schema differs from pan.dev — read before changing any
|
||
// wrapper, because "helpful cleanup" here almost always regresses.
|
||
//
|
||
// - LQM point metrics live at a DEDICATED endpoint:
|
||
// POST /sdwan/monitor/v2.0/api/monitor/lqm_point_metrics
|
||
// NOT `sys_point_metrics` — that endpoint is for system metrics
|
||
// (CPU, Memory, Disk) and its schema rejects `filter.waninterface`
|
||
// ("is not defined in the schema"). LQM has its own endpoint.
|
||
// Metric names on the LQM endpoint carry the `PointMetric`
|
||
// suffix: `LqmLatencyPointMetric`, `LqmJitterPointMetric`,
|
||
// `LqmPktLossPointMetric` (Pkt, not Packet), `LqmMosPointMetric`.
|
||
//
|
||
// - LQM body shape on this tenant:
|
||
// `end_time` → rejected as "not defined in the schema"
|
||
// (endpoint interprets window as [start_time, now))
|
||
// Filter shape:
|
||
// `filter.site` — ARRAY of strings. Live 400 was
|
||
// "$.filter.site: string found, array expected".
|
||
// This is OPPOSITE to sys_point_metrics,
|
||
// which wants a plain string.
|
||
// `filter.path` — array. This is the KEY THAT WORKS on
|
||
// lqm_point_metrics for scoping to
|
||
// specific circuits — even though the
|
||
// circuit ids we pass are waninterface
|
||
// ids from /sites/{id}/waninterfaces.
|
||
// The LIVEcommunity working example uses
|
||
// this exact shape:
|
||
// filter: { site: [id], path: [wi_id] }
|
||
// Both `wan_interfaces` (plural + underscore)
|
||
// and `waninterface` (sys_point_metrics
|
||
// variant) are rejected as "not defined in
|
||
// the schema" on this tenant.
|
||
// No `elements` filter (that's a sys_point_metrics quirk).
|
||
//
|
||
// - `metrics[].statistics` is a PLURAL array (`["average"]` /
|
||
// `["max"]`), not the singular `statistic`.
|
||
//
|
||
// - `metrics[].unit` is case-sensitive: `milliseconds`, `Percentage`,
|
||
// `gauge`, `count`.
|
||
//
|
||
// - Healthscore on this tenant: v2.0 aiops/health is a DEAD END.
|
||
// Confirmed via prismaProbe try-shapes:
|
||
// v2.0 `metrics: [...]` → rejected ("not defined")
|
||
// v2.0 with `end_time` removed → 400 "end_time is required"
|
||
// v2.0 with `end_time` added + → same "$.metrics: not defined"
|
||
// v2.1 aiops/aggregates → requires app_id + aggregates
|
||
// v2.6 unified /monitor/metrics → ✅ 200 OK (only winner)
|
||
// We use `POST /sdwan/monitor/v2.6/api/monitor/metrics` with the
|
||
// standard `metrics: [{Healthscore}]` + `filter: {site: [id]}`.
|
||
// This endpoint accepts filter.site as an ARRAY and returns a
|
||
// per-site healthscore in the response.
|
||
//
|
||
// - `interval` must be one of Prisma's canonical bucket sizes:
|
||
// `10sec`, `1min`, `5min`, `1hour`, `1day`. `15min` etc.
|
||
// yield HTTP 400 with SCHEMA_CHECK_FAIL on `$.interval`.
|
||
//
|
||
// - Alarms live at `POST /sdwan/v3.7/api/events/query`, NOT
|
||
// `/sdwan/monitor/v2.0/api/monitor/alarms` (that endpoint
|
||
// 404s "ROUTE_NOT_FOUND" on the observed tenant).
|
||
//
|
||
// All wrappers return `null` on any failure and log the error with
|
||
// a preview of the response body so shape drift is diagnosable
|
||
// without cranking LOG_LEVEL=debug. Higher layers absorb per-metric
|
||
// failures via `Promise.allSettled`.
|
||
|
||
import { paloAltoAxios } from './client.js';
|
||
import { logger } from '../../utils/logger.js';
|
||
|
||
// Prisma metric-name catalog for the LQM (Link Quality Monitoring)
|
||
// bucket. Short JS keys → { name, unit } for the API body.
|
||
//
|
||
// Names carry the `PointMetric` suffix because this maps to the
|
||
// dedicated `lqm_point_metrics` endpoint (NOT `sys_point_metrics`).
|
||
// The unit strings are verified via 400 SCHEMA_CHECK_FAIL responses
|
||
// when they drift and are case-sensitive:
|
||
// - `milliseconds` (lowercase) → latency, jitter
|
||
// - `percentage` (lowercase — NOT `Percentage`, verified via
|
||
// prismaProbe try-shapes lqm-loss on 2026-07-09
|
||
// which returned 400 METRIC_UNIT_NOT_SUPPORTED for
|
||
// every capitalized/alternate variant) → loss
|
||
// - `count` → mos
|
||
// If Prisma ever rejects a unit here, re-run the matrix probe:
|
||
// `node scripts/prismaProbe.js try-shapes lqm-loss <site> <wis>`.
|
||
export const LQM_METRIC_NAMES = Object.freeze({
|
||
latency: { name: 'LqmLatencyPointMetric', unit: 'milliseconds' },
|
||
jitter: { name: 'LqmJitterPointMetric', unit: 'milliseconds' },
|
||
loss: { name: 'LqmPktLossPointMetric', unit: 'percentage' },
|
||
mos: { name: 'LqmMosPointMetric', unit: 'count' },
|
||
});
|
||
|
||
// ──────────────────────────────────────────────
|
||
// URL helpers — SASE and legacy have different prefixes
|
||
// ──────────────────────────────────────────────
|
||
|
||
function isSase() {
|
||
return String(process.env.PRISMA_AUTH_MODE || 'sase').toLowerCase().trim() === 'sase';
|
||
}
|
||
|
||
function monitorPath(endpoint) {
|
||
return isSase()
|
||
? `/sdwan/monitor/v2.0/api/monitor/${endpoint}`
|
||
: `/v2.0/api/monitor/${endpoint}`;
|
||
}
|
||
|
||
// v2.6 unified monitor/metrics endpoint. The observed tenant only
|
||
// serves healthscore correctly here — v2.0 aiops/health is a dead
|
||
// end (see the top-of-file schema notes).
|
||
function metricsV26Path() {
|
||
return isSase()
|
||
? '/sdwan/monitor/v2.6/api/monitor/metrics'
|
||
: '/v2.6/api/monitor/metrics';
|
||
}
|
||
|
||
// Events endpoint (SASE v3.7 is the current top of the docs). Used
|
||
// for alarm retrieval — the older `/monitor/alarms` path 404s on
|
||
// the SASE surface.
|
||
function eventsQueryPath() {
|
||
return isSase()
|
||
? '/sdwan/v3.7/api/events/query'
|
||
: '/v3.7/api/events/query';
|
||
}
|
||
|
||
// ──────────────────────────────────────────────
|
||
// Time-window helpers
|
||
// ──────────────────────────────────────────────
|
||
|
||
function windowStart(minutes) {
|
||
return new Date(Date.now() - minutes * 60 * 1000).toISOString();
|
||
}
|
||
|
||
function nowIso() {
|
||
return new Date().toISOString();
|
||
}
|
||
|
||
// Prisma's valid `interval` enum (verified via HTTP 400 error message).
|
||
// `15min`, `30min` etc. are NOT accepted — snap up to the next valid
|
||
// bucket (1hour) rather than silently down-sampling.
|
||
//
|
||
// Used for LQM and healthscore. Those endpoints return a SINGLE
|
||
// aggregated value per path/site (regardless of interval), so the
|
||
// interval only affects Prisma's internal downsampling — coarser is
|
||
// fine, and 1day for a 24h window is the cheapest ask.
|
||
function pickInterval(minutes) {
|
||
if (minutes <= 1) return '1min';
|
||
if (minutes <= 5) return '5min';
|
||
if (minutes < 60) return '5min'; // 15-minute default now snaps to 5min buckets
|
||
if (minutes <= 60) return '1hour';
|
||
return '1day';
|
||
}
|
||
|
||
// App-metric variant. UNLIKE LQM, per-app metrics return the FULL
|
||
// datapoint time series (metrics[].series[].data[].datapoints[]) and
|
||
// downstream code aggregates {avg, min, max, p95} client-side. That
|
||
// only works if the interval is fine enough to capture the transient
|
||
// windows we're trying to surface. HAR (2026-07-09) confirms the
|
||
// Prisma UI queries `5min` for a 24h window (yielding 288 datapoints
|
||
// per metric) — matching that keeps our worst-window statistics
|
||
// meaningful. Falling back to `1day` (as `pickInterval` does) would
|
||
// collapse the whole series into 1-2 aggregated points and hide the
|
||
// exact degradation these checks exist to catch.
|
||
function pickAppMetricInterval(minutes) {
|
||
if (minutes <= 1) return '1min';
|
||
if (minutes <= 5) return '5min';
|
||
if (minutes <= 60) return '5min'; // 12 points per hour
|
||
if (minutes <= 1440) return '5min'; // 24h → 288 points (HAR-confirmed)
|
||
// Beyond 24h, cap the point count with hourly buckets: 7d @ 1h = 168
|
||
// points which is still a manageable payload.
|
||
if (minutes <= 10080) return '1hour';
|
||
return '1day';
|
||
}
|
||
|
||
// Small helper that logs a failed metric call with a response-body
|
||
// preview AND (on 4xx) the request body that Prisma rejected — the
|
||
// most common failure mode is a metric name or filter key drifting
|
||
// on the tenant, and seeing both sides in the same log line makes
|
||
// the schema mismatch obvious without cranking LOG_LEVEL=debug.
|
||
function logMetricFailure(scope, err) {
|
||
const status = err.response?.status;
|
||
const respPreview = err.response?.data
|
||
? JSON.stringify(err.response.data).slice(0, 300)
|
||
: '';
|
||
const isSchemaError = status && status >= 400 && status < 500;
|
||
const reqPreview = isSchemaError && err.config?.data
|
||
? String(err.config.data).slice(0, 500)
|
||
: '';
|
||
const parts = [
|
||
`${scope} failed: ${err.message}`,
|
||
status ? `(HTTP ${status})` : '',
|
||
respPreview ? `— resp: ${respPreview}` : '',
|
||
reqPreview ? `— req: ${reqPreview}` : '',
|
||
].filter(Boolean);
|
||
logger('paloalto:metrics', parts.join(' '), 'warn');
|
||
}
|
||
|
||
// ──────────────────────────────────────────────
|
||
// Healthscore (site-level roll-up 0-100)
|
||
// ──────────────────────────────────────────────
|
||
|
||
/**
|
||
* Site healthscore (0-100) over the given window. Uses the v2.6
|
||
* unified monitor/metrics endpoint — the only healthscore endpoint
|
||
* that returned 200 on this tenant (verified via prismaProbe
|
||
* try-shapes; v2.0 aiops/health and v2.1 aiops/aggregates both
|
||
* dead-end on schema errors this tenant enforces).
|
||
*
|
||
* The v2.6 endpoint accepts:
|
||
* - `metrics: [{ name: 'Healthscore', statistics: ['max'], unit: 'gauge' }]`
|
||
* - `view: {}`
|
||
* - `filter: { site: [siteId] }` — scoped server-side (unlike
|
||
* v2.0 which rejects any filter)
|
||
* - `interval` + `start_time` (no `end_time`)
|
||
*
|
||
* @param {string} siteId
|
||
* @param {number} windowMinutes default 15
|
||
* @returns {Promise<object|null>} raw response or null on failure
|
||
*/
|
||
export async function getHealthscore(siteId, windowMinutes = 15) {
|
||
if (!siteId) return null;
|
||
try {
|
||
const res = await paloAltoAxios.post(metricsV26Path(), {
|
||
start_time: windowStart(windowMinutes),
|
||
interval: pickInterval(windowMinutes),
|
||
metrics: [{
|
||
name: 'Healthscore',
|
||
statistics: ['max'],
|
||
unit: 'gauge',
|
||
}],
|
||
view: {},
|
||
filter: { site: [String(siteId)] },
|
||
});
|
||
return res.data || null;
|
||
} catch (err) {
|
||
logMetricFailure(`getHealthscore(${siteId})`, err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────
|
||
// LQM point metrics (per-path latency / jitter / loss / MOS)
|
||
// ──────────────────────────────────────────────
|
||
|
||
/**
|
||
* Fetch one of the four LQM point metrics over the given window.
|
||
* `metricKey` is one of the keys of LQM_METRIC_NAMES ('latency',
|
||
* 'jitter', 'loss', 'mos').
|
||
*
|
||
* Uses the DEDICATED `lqm_point_metrics` endpoint. `sys_point_metrics`
|
||
* is a different endpoint for CPU/Memory/Disk system metrics and its
|
||
* schema rejects `filter.wan_interfaces` — that was the bug that led
|
||
* to the endpoint switch.
|
||
*
|
||
* Filter shape verified against live 400s on this tenant + the
|
||
* pan.dev LIVEcommunity #1235108 working example:
|
||
* - `filter.site` — ARRAY (opposite of sys_point_metrics, which
|
||
* wants a string). Live 400 was
|
||
* "$.filter.site: string found, array expected".
|
||
* - `filter.path` — array of waninterface ids. Yes, the key is
|
||
* "path" — NOT `wan_interfaces` (rejected as
|
||
* "not defined in the schema" on this tenant),
|
||
* NOT `waninterface` (that's the sys_point_metrics
|
||
* key). Prisma's LQM data model treats each
|
||
* circuit as a "path".
|
||
*
|
||
* Missing `waninterfaceIds` returns null (with a log line) rather
|
||
* than firing a doomed request that would come back empty.
|
||
*
|
||
* @param {string} siteId
|
||
* @param {string[]} waninterfaceIds one or more WAN interface ids
|
||
* @param {'latency'|'jitter'|'loss'|'mos'} metricKey
|
||
* @param {number} windowMinutes default 5
|
||
* @returns {Promise<object|null>}
|
||
*/
|
||
export async function getLqmMetric(siteId, waninterfaceIds, metricKey, windowMinutes = 5) {
|
||
const spec = LQM_METRIC_NAMES[metricKey];
|
||
if (!spec) {
|
||
throw new Error(`Unknown LQM metric key "${metricKey}" (expected: ${Object.keys(LQM_METRIC_NAMES).join(', ')})`);
|
||
}
|
||
if (!siteId) return null;
|
||
if (!Array.isArray(waninterfaceIds) || waninterfaceIds.length === 0) {
|
||
logger('paloalto:metrics', `getLqmMetric(${metricKey}): no waninterfaceIds — skipping`, 'debug');
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
const res = await paloAltoAxios.post(monitorPath('lqm_point_metrics'), {
|
||
// NOTE: no `end_time` — the observed tenant's lqm_point_metrics
|
||
// schema rejects it with 400 "$.end_time: is not defined in the
|
||
// schema and the schema does not allow additional properties".
|
||
// The endpoint interprets the window as [start_time, now).
|
||
start_time: windowStart(windowMinutes),
|
||
interval: pickInterval(windowMinutes),
|
||
metrics: [{
|
||
name: spec.name,
|
||
statistics: ['average'],
|
||
unit: spec.unit,
|
||
}],
|
||
view: {},
|
||
filter: {
|
||
site: [String(siteId)],
|
||
path: waninterfaceIds,
|
||
},
|
||
});
|
||
return res.data || null;
|
||
} catch (err) {
|
||
logMetricFailure(`getLqmMetric(${siteId}, ${metricKey})`, err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────
|
||
// Per-application "Application Path Details" metrics (v2.6 API)
|
||
// ──────────────────────────────────────────────
|
||
//
|
||
// These are what Prisma's Application Path Details dashboard renders:
|
||
// real audio/video traffic quality per app (rtp-base,
|
||
// Webex_Calling_RTP, MS_Teams_RTP, etc.) — measured on ACTUAL user
|
||
// packets via DPI, not on synthetic LQM probes across the underlying
|
||
// link. For voice diagnostics this is
|
||
// the more meaningful signal — link probes can pass while real RTP
|
||
// experiences packet loss spikes and MOS dips during flap events
|
||
// that get averaged out of the link probe view.
|
||
//
|
||
// Registry entries were confirmed via HAR captures 2026-07-09 against
|
||
// a live tenant (site CG00127) for both `rtp-base` (id 15932000365560116)
|
||
// and `Webex_Calling_RTP` (id 1708539371717015196). The metric names,
|
||
// units, and filter shapes are identical across voice apps — the only
|
||
// per-app knob is filter.app, so the check code is app-agnostic. The
|
||
// winning shapes are shown per-metric in the table below.
|
||
//
|
||
// Key filter-shape quirks:
|
||
// - `filter.direction` is REQUIRED as `"Ingress"` for audio-quality
|
||
// metrics (loss/jitter/MOS are what you RECEIVE). Bandwidth is
|
||
// bidirectional aggregate and does NOT take a direction.
|
||
// - `filter.path_type` is REQUIRED for AppPerf* metrics (loss,
|
||
// jitter, bandwidth) but NOT for AppAudioMos.
|
||
// - `filter.app` uses the numeric app id (resolvable via
|
||
// `POST /sdwan/v2.5/api/appdefs/query`), NOT the display name.
|
||
//
|
||
// Response shape is IDENTICAL to v2.6 healthscore
|
||
// (metrics[].series[].data[].datapoints[{time, value}]) so downstream
|
||
// parsing can reuse the same helper.
|
||
|
||
const ALL_PATH_TYPES = Object.freeze(
|
||
['DirectInternet', 'VPN', 'PrivateWAN', 'PrivateVPN', 'ServiceLink'],
|
||
);
|
||
|
||
// eslint-disable-next-line no-restricted-syntax -- frozen shared registry.
|
||
export const APP_METRIC_NAMES = Object.freeze({
|
||
mos: {
|
||
name: 'AppAudioMos',
|
||
unit: 'count',
|
||
direction: 'Ingress',
|
||
includePathType: false,
|
||
// Lower is worse for MOS: 5=excellent, 4=good, 3.5=acceptable,
|
||
// <3=impaired, <2=unintelligible. Grade against worst-window
|
||
// rather than average — a 24h avg smooths over the flap events
|
||
// that actually degrade calls.
|
||
lowIsBad: true,
|
||
},
|
||
loss: {
|
||
name: 'AppPerfUDPAudioPacketLoss',
|
||
unit: 'percentage',
|
||
direction: 'Ingress',
|
||
includePathType: true,
|
||
lowIsBad: false,
|
||
},
|
||
jitter: {
|
||
name: 'AppPerfUDPAudioJitter',
|
||
unit: 'milliseconds',
|
||
direction: 'Ingress',
|
||
includePathType: true,
|
||
lowIsBad: false,
|
||
},
|
||
bandwidth: {
|
||
name: 'AppPerfUDPAudioBandwidth',
|
||
unit: 'Mbps',
|
||
direction: null,
|
||
includePathType: true,
|
||
lowIsBad: null, // bandwidth isn't a quality metric — used for context only
|
||
},
|
||
});
|
||
|
||
// v2.6 monitor/metrics path (SASE unified prefix vs. legacy).
|
||
function appMetricsPath() {
|
||
return isSase()
|
||
? '/sdwan/monitor/v2.6/api/monitor/metrics'
|
||
: '/v2.6/api/monitor/metrics';
|
||
}
|
||
|
||
/**
|
||
* Fetch a single per-app metric time series for a site.
|
||
*
|
||
* Returns the raw Prisma response body (with metrics[].series[].data
|
||
* [].datapoints[] intact) or null on failure. The datapoints array is
|
||
* usually 24h × 5min = 288 points; the caller is responsible for
|
||
* client-side aggregation (avg / min / max / p95) because for voice
|
||
* quality the WORST window matters more than the average.
|
||
*
|
||
* @param {string} siteId
|
||
* @param {string} appId Prisma numeric app id (NOT display name)
|
||
* @param {'mos'|'loss'|'jitter'|'bandwidth'} metricKey
|
||
* @param {number} windowMinutes default 1440 (24h) — matches the
|
||
* shipping default for WAN checks
|
||
* @returns {Promise<object|null>}
|
||
*/
|
||
export async function getAppMetric(siteId, appId, metricKey, windowMinutes = 1440) {
|
||
const spec = APP_METRIC_NAMES[metricKey];
|
||
if (!spec) {
|
||
throw new Error(
|
||
`Unknown app metric key "${metricKey}" ` +
|
||
`(expected: ${Object.keys(APP_METRIC_NAMES).join(', ')})`,
|
||
);
|
||
}
|
||
if (!siteId || !appId) {
|
||
logger('paloalto:metrics',
|
||
`getAppMetric(${metricKey}): missing siteId or appId — skipping`, 'debug');
|
||
return null;
|
||
}
|
||
|
||
const filter = {
|
||
site: [String(siteId)],
|
||
app: [String(appId)],
|
||
};
|
||
if (spec.includePathType) filter.path_type = [...ALL_PATH_TYPES];
|
||
if (spec.direction) filter.direction = spec.direction;
|
||
|
||
try {
|
||
const res = await paloAltoAxios.post(appMetricsPath(), {
|
||
start_time: windowStart(windowMinutes),
|
||
end_time: nowIso(),
|
||
// NOT pickInterval — see pickAppMetricInterval doc for why:
|
||
// app metrics need the fine-grained series or the client-side
|
||
// worst-window aggregation is meaningless.
|
||
interval: pickAppMetricInterval(windowMinutes),
|
||
metrics: [{
|
||
name: spec.name,
|
||
statistics: ['average'],
|
||
unit: spec.unit,
|
||
}],
|
||
filter,
|
||
view: {},
|
||
});
|
||
return res.data || null;
|
||
} catch (err) {
|
||
logMetricFailure(`getAppMetric(${siteId}, app=${appId}, ${metricKey})`, err);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// ──────────────────────────────────────────────
|
||
// Alarms (via the events/query endpoint)
|
||
// ──────────────────────────────────────────────
|
||
|
||
/**
|
||
* Recent alarms for the site. Uses the SASE events endpoint
|
||
* (`POST /sdwan/v3.7/api/events/query`) — the older
|
||
* `/monitor/v2.0/api/monitor/alarms` path 404s on this tenant
|
||
* ("ROUTE_NOT_FOUND_0001").
|
||
*
|
||
* Body shape mirrors the Prisma ServiceNow CloudBlade example
|
||
* (pan.dev + PA integration guide): `limit` is an OBJECT with
|
||
* `count`/`sort_on`/`sort_order`, `query` carries `site`/`type`,
|
||
* `severity` is a top-level array, `start_time` is ISO.
|
||
* Additionally we filter `type: ['alarm']` so we don't get
|
||
* informational events mixed into the alarm count.
|
||
*
|
||
* @param {string} siteId
|
||
* @param {number} windowMinutes default 60
|
||
* @returns {Promise<object|null>}
|
||
*/
|
||
export async function getAlarms(siteId, windowMinutes = 60) {
|
||
if (!siteId) return null;
|
||
|
||
try {
|
||
const res = await paloAltoAxios.post(eventsQueryPath(), {
|
||
limit: {
|
||
count: 100,
|
||
sort_on: 'time',
|
||
sort_order: 'descending',
|
||
},
|
||
query: {
|
||
site: [String(siteId)],
|
||
type: ['alarm'],
|
||
},
|
||
severity: ['critical', 'major', 'minor'],
|
||
start_time: windowStart(windowMinutes),
|
||
});
|
||
return res.data || null;
|
||
} catch (err) {
|
||
logMetricFailure(`getAlarms(${siteId})`, err);
|
||
return null;
|
||
}
|
||
}
|