diff --git a/.changeset/0033-mgmt-dashboard-applications-overview.md b/.changeset/0033-mgmt-dashboard-applications-overview.md new file mode 100644 index 0000000..3e7d104 --- /dev/null +++ b/.changeset/0033-mgmt-dashboard-applications-overview.md @@ -0,0 +1,18 @@ +--- +'@cdot65/prisma-airs-sdk': minor +--- + +Add `mgmt.dashboard.applicationsOverview(opts?)` covering the dashboard's apps enumeration endpoint at `/v1/mgmt/dashboard/v2/apps/applicationsoverview`. This is the canonical apps-list source for dashboard reporting and is what the SCM UI's "AI Security > Runtime > API Applications" view uses to populate its list. + +The dashboard buckets traffic by the literal `metadata.app_name` value scan payloads sent. A single registered customer-app (`customer_apps.customer_appId`) can therefore appear here as multiple items, one per distinct scan-payload name, when integrations override `app_name` in scan metadata (LiteLLM's `panw_prisma_airs` guardrail does this by default, for example). Enumerating from `applicationsOverview` rather than `customerApps.list` is necessary to see every dashboard bucket - the `id` field is the registered `customer_appId` UUID, and the `name` field is the scan-payload value. Pair this with `dashboard.application(...)` and `dashboard.applicationViolationBreakdown(...)` to drill into specific buckets. + +API behavior verified live on 2026-05-29 and reflected in the type signature: + +- `timeInterval` is narrowed to `1 | 7 | 30 | 60`; other values return HTTP 400. +- `timeUnit` accepts `'days'`, `'day'`, and `'hour'` (note the singular forms - this endpoint accepts wider values than the per-app `application` endpoint, which only accepts `'days'`). +- Valid combinations live-tested: `(7, days)`, `(30, days)`, `(60, days)`, `(1, day)`, `(1, hour)`. The SCM UI uses `(1, day)` by default; the SDK defaults to `(30, days)` to match the existing dashboard methods. +- `limit` and `offset` provide offset-based pagination; the SCM UI uses `limit=25`. + +Response shape uses `z.passthrough()` and `nullable().optional()` for forward compatibility with future API fields. New exports: `DashboardApplicationsOverviewQuery`, `DashboardApplicationsOverview`, `DashboardApplicationsOverviewItem`, `DashboardApplicationSessionsBucket`, `DashboardPagination`, and their schemas. + +Also clarifies the `DashboardAppQuery.appName` JSDoc to reference `applicationsOverview` as the canonical enumeration source, so callers know where to obtain valid `(appId, appName)` pairs when integration-supplied scan-payload names diverge from SCM-registered customer-app names. diff --git a/src/constants.ts b/src/constants.ts index 26ae91f..6ef76d6 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -87,6 +87,8 @@ export const MGMT_OAUTH_TOKEN_PATH = '/v1/mgmt/oauth/client_credential/accesstok export const MGMT_DASHBOARD_APPLICATION_PATH = '/v1/mgmt/dashboard/v2/apps/application'; export const MGMT_DASHBOARD_APPLICATION_VIOLATION_BREAKDOWN_PATH = '/v1/mgmt/dashboard/v2/apps/applicationviolationbreakdown'; +export const MGMT_DASHBOARD_APPLICATIONS_OVERVIEW_PATH = + '/v1/mgmt/dashboard/v2/apps/applicationsoverview'; // DLP (Data Loss Prevention) API defaults export const DEFAULT_DLP_ENDPOINT = 'https://api.dlp.paloaltonetworks.com'; diff --git a/src/management/dashboard.ts b/src/management/dashboard.ts index cf053fc..b51f2fb 100644 --- a/src/management/dashboard.ts +++ b/src/management/dashboard.ts @@ -1,13 +1,16 @@ import { MGMT_DASHBOARD_APPLICATION_PATH, + MGMT_DASHBOARD_APPLICATIONS_OVERVIEW_PATH, MGMT_DASHBOARD_APPLICATION_VIOLATION_BREAKDOWN_PATH, } from '../constants.js'; import { request } from '../http/request.js'; import type { AuthAdapter } from '../http/types.js'; import { DashboardApplicationSchema, + DashboardApplicationsOverviewSchema, DashboardApplicationViolationBreakdownSchema, type DashboardApplication, + type DashboardApplicationsOverview, type DashboardApplicationViolationBreakdown, } from '../models/mgmt-dashboard.js'; @@ -32,7 +35,15 @@ export interface DashboardAppQuery { * {@link import('./customer-apps.js').CustomerAppsClient.list}'s `customer_appId` field. */ appId: string; - /** Application display name. Required, non-empty. URL encoding is handled internally. */ + /** + * Application display name as the dashboard tracks it - the literal `metadata.app_name` + * value scan payloads sent. This may differ from the `customer_apps.app_name` field when + * the integration overrides it (LiteLLM's `panw_prisma_airs` guardrail does this by default, + * for example). Required, non-empty. URL encoding is handled internally. + * + * To enumerate all valid `(appId, appName)` pairs the dashboard tracks, use + * {@link DashboardClient.applicationsOverview} - one item per dashboard bucket. + */ appName: string; /** * Look-back window length, in days. Defaults to 30 (matches the SCM UI's "30 days" claim). @@ -48,13 +59,43 @@ export interface DashboardAppQuery { timeUnit?: 'days'; } +/** + * Query parameters for {@link DashboardClient.applicationsOverview}. + * + * Distinct from {@link DashboardAppQuery} - this endpoint takes no `appId`/`appName` (it is the + * enumeration source for those) and accepts a wider set of time windows. + */ +export interface DashboardApplicationsOverviewQuery { + /** + * Look-back window length. Accepted values vary by `timeUnit`: + * - `timeUnit: 'days'` accepts `7`, `30`, or `60`. + * - `timeUnit: 'day'` accepts `1`. + * - `timeUnit: 'hour'` accepts `1`. + * + * Defaults to `30`. Other combinations return HTTP 400. + */ + timeInterval?: 1 | 7 | 30 | 60; + /** + * Look-back window unit. The dashboard apps-overview endpoint accepts `'days'`, `'day'`, + * and `'hour'` (note the singular forms - unlike the per-app `application` endpoint, which + * only accepts `'days'`). Defaults to `'days'`. + */ + timeUnit?: 'days' | 'day' | 'hour'; + /** Maximum items to return. Defaults to 25 (matches the SCM UI). */ + limit?: number; + /** Number of items to skip (offset-based pagination). Defaults to 0. */ + offset?: number; +} + /** * Client for AIRS SCM dashboard endpoints that power the - * "AI Security > Runtime > API Applications" detail panel. + * "AI Security > Runtime > API Applications" panel. * - * Together, {@link application} (overview + token consumption + session activity) and - * {@link applicationViolationBreakdown} (per-detector violations) reproduce the full panel and - * unlock per-app token chargeback reporting. + * The dashboard buckets traffic by the **literal `metadata.app_name` value scan payloads + * actually sent**. A single registered customer-app (`customer_apps.customer_appId`) can therefore + * map to multiple dashboard buckets, one per distinct scan-payload name. Use + * {@link applicationsOverview} to enumerate all dashboard buckets, then {@link application} and + * {@link applicationViolationBreakdown} to drill into any specific `(id, name)` pair. * * @example * ```ts @@ -175,4 +216,54 @@ export class DashboardClient { numRetries: this.numRetries, }); } + + /** + * Enumerate all dashboard application buckets the tenant has data for. + * + * This is the canonical apps-list source for the dashboard. The customer-apps endpoint + * (`mgmt.customerApps.list`) lists registered customer applications, but each registered + * customer-app can have **multiple dashboard buckets** when its API key is used to send scan + * requests carrying different `metadata.app_name` values - one bucket per distinct + * scan-payload name. This endpoint returns every bucket, so it is what you want for + * per-app token-consumption reporting and dashboard inventory. + * + * The `id` field on each item is the registered `customer_appId` UUID; the `name` field is + * the scan-payload value. Pass that `(id, name)` pair to {@link application} or + * {@link applicationViolationBreakdown} to retrieve drill-down data for a specific bucket. + * + * @param query - Time window and pagination. + * @returns Paginated bucket list plus pagination metadata. + * @example + * ```ts + * import { ManagementClient } from '@cdot65/prisma-airs-sdk'; + * const mgmt = new ManagementClient(); + * + * const { items } = await mgmt.dashboard.applicationsOverview(); + * for (const item of items ?? []) { + * const overview = await mgmt.dashboard.application({ + * appId: item.id ?? '', + * appName: item.name ?? '', + * }); + * console.log(item.name, overview.token_stats?.monthly_total_tokens); + * } + * ``` + */ + async applicationsOverview( + query?: DashboardApplicationsOverviewQuery, + ): Promise { + return request({ + method: 'GET', + baseUrl: this.baseUrl, + path: MGMT_DASHBOARD_APPLICATIONS_OVERVIEW_PATH, + params: { + time_interval: String(query?.timeInterval ?? 30), + time_unit: query?.timeUnit ?? 'days', + limit: String(query?.limit ?? 25), + offset: String(query?.offset ?? 0), + }, + responseSchema: DashboardApplicationsOverviewSchema, + auth: this.auth, + numRetries: this.numRetries, + }); + } } diff --git a/src/management/index.ts b/src/management/index.ts index 8fd89a2..2e4c50f 100644 --- a/src/management/index.ts +++ b/src/management/index.ts @@ -24,6 +24,7 @@ export { DashboardClient, type DashboardClientOptions, type DashboardAppQuery, + type DashboardApplicationsOverviewQuery, } from './dashboard.js'; export { DlpNamespace, type DlpNamespaceOptions } from './dlp/index.js'; export { diff --git a/src/models/index.ts b/src/models/index.ts index 2972df8..60e34bc 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -204,6 +204,14 @@ export { type DetectorViolationBreakdownEntry, DashboardApplicationViolationBreakdownSchema, type DashboardApplicationViolationBreakdown, + DashboardApplicationSessionsBucketSchema, + type DashboardApplicationSessionsBucket, + DashboardApplicationsOverviewItemSchema, + type DashboardApplicationsOverviewItem, + DashboardPaginationSchema, + type DashboardPagination, + DashboardApplicationsOverviewSchema, + type DashboardApplicationsOverview, } from './mgmt-dashboard.js'; export { DeploymentProfileEntrySchema, diff --git a/src/models/mgmt-dashboard.ts b/src/models/mgmt-dashboard.ts index 206f261..3ad3b44 100644 --- a/src/models/mgmt-dashboard.ts +++ b/src/models/mgmt-dashboard.ts @@ -100,3 +100,81 @@ export const DashboardApplicationViolationBreakdownSchema = z export type DashboardApplicationViolationBreakdown = z.infer< typeof DashboardApplicationViolationBreakdownSchema >; + +/** + * One time bucket of session activity inside an `applicationsoverview` item. + * + * The dashboard returns a series of buckets covering the requested window, useful for spark-line + * style rendering. The exact shape varies by `time_unit`/`time_interval` combination; fields + * use forward-compatible parsing. + */ +export const DashboardApplicationSessionsBucketSchema = z + .object({ + bucket_number: z.number().optional(), + date: z.string().nullable().optional(), + total: z.number().optional(), + violated: z.number().optional(), + }) + .passthrough(); + +/** One session-activity bucket in a dashboard applications-overview item. */ +export type DashboardApplicationSessionsBucket = z.infer< + typeof DashboardApplicationSessionsBucketSchema +>; + +/** + * One application entry in the `applicationsoverview` response. + * + * The dashboard buckets traffic by **the literal `metadata.app_name` value scan payloads + * actually sent**. A single registered customer-app can therefore appear here as multiple items, + * one per distinct scan-payload name. The `id` field is the registered `customer_appId` UUID + * (matches `customer_apps.customer_appId`); the `name` field is the scan-payload value (which + * may differ from `customer_apps.app_name` when the integration overrides it). + */ +export const DashboardApplicationsOverviewItemSchema = z + .object({ + id: z.string().nullable().optional(), + name: z.string().nullable().optional(), + cloud: z.string().nullable().optional(), + source: z.string().nullable().optional(), + created_at: z.string().nullable().optional(), + sessions: z.array(DashboardApplicationSessionsBucketSchema).nullable().optional(), + sessions_total: z.number().nullable().optional(), + sessions_violated: z.number().nullable().optional(), + }) + .passthrough(); + +/** One application in the dashboard applications-overview response. */ +export type DashboardApplicationsOverviewItem = z.infer< + typeof DashboardApplicationsOverviewItemSchema +>; + +/** Pagination metadata on the applications-overview response. */ +export const DashboardPaginationSchema = z + .object({ + limit: z.number().optional(), + skip: z.number().optional(), + total_items: z.number().optional(), + }) + .passthrough(); + +/** Pagination metadata returned by the applications-overview endpoint. */ +export type DashboardPagination = z.infer; + +/** + * Response from `dashboard/v2/apps/applicationsoverview`. + * + * Each item in `items[]` is one dashboard bucket (one per distinct scan-payload + * `metadata.app_name` per registered customer-app). Use this to enumerate all buckets the + * dashboard tracks, then call {@link DashboardClient.application} with each + * `(item.id, item.name)` pair to retrieve `token_stats` for that bucket. + */ +export const DashboardApplicationsOverviewSchema = z + .object({ + items: z.array(DashboardApplicationsOverviewItemSchema).optional(), + pagination: DashboardPaginationSchema.optional(), + }) + .passthrough(); + +/** Dashboard applications-overview response. */ +export type DashboardApplicationsOverview = z.infer; diff --git a/test/management/dashboard.spec.ts b/test/management/dashboard.spec.ts index 45b61ad..84c3af0 100644 --- a/test/management/dashboard.spec.ts +++ b/test/management/dashboard.spec.ts @@ -147,4 +147,96 @@ describe('DashboardClient', () => { expect(url).toContain('appname=chatbot'); }); }); + + describe('applicationsOverview', () => { + const OVERVIEW_RESPONSE = { + items: [ + { + id: '5e16929a-1234-4567-89ab-cdef01234567', + name: 'chatbot', + cloud: 'other', + source: 'api', + created_at: '2026-04-29T19:00:56.098009282Z', + sessions: [ + { bucket_number: 0, date: '2026-04-29T19:00:56Z', total: 0, violated: 0 }, + { bucket_number: 1, date: '2026-04-30T19:00:56Z', total: 13104, violated: 504 }, + ], + sessions_total: 13104, + sessions_violated: 504, + }, + { + id: '5e16929a-1234-4567-89ab-cdef01234567', + name: 'Claude Code', + cloud: 'other', + source: 'api', + created_at: '2026-04-29T19:00:56.098009282Z', + sessions: [], + sessions_total: 234, + sessions_violated: 12, + }, + ], + pagination: { limit: 25, skip: 0, total_items: 2 }, + }; + + it('GETs /v1/mgmt/dashboard/v2/apps/applicationsoverview with defaults', async () => { + mockFetch(OVERVIEW_RESPONSE); + const result = await client.applicationsOverview(); + + expect(result.items).toHaveLength(2); + expect(result.items?.[0].name).toBe('chatbot'); + expect(result.items?.[1].name).toBe('Claude Code'); + expect(result.pagination?.total_items).toBe(2); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toContain('/v1/mgmt/dashboard/v2/apps/applicationsoverview'); + expect(url).toContain('time_interval=30'); + expect(url).toContain('time_unit=days'); + expect(url).toContain('limit=25'); + expect(url).toContain('offset=0'); + }); + + it('exposes the (id, name) pair the application endpoint needs', async () => { + mockFetch(OVERVIEW_RESPONSE); + const result = await client.applicationsOverview(); + + // Both items share the same id (same registered customer_app) but have distinct names + // (each is a separate dashboard bucket driven by scan-payload metadata.app_name). + const ids = (result.items ?? []).map((i) => i.id); + const names = (result.items ?? []).map((i) => i.name); + expect(new Set(ids).size).toBe(1); + expect(new Set(names).size).toBe(2); + }); + + it('uses caller-supplied timeInterval / timeUnit / limit / offset', async () => { + mockFetch({ items: [], pagination: { limit: 100, skip: 50, total_items: 0 } }); + await client.applicationsOverview({ + timeInterval: 60, + timeUnit: 'days', + limit: 100, + offset: 50, + }); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toContain('time_interval=60'); + expect(url).toContain('time_unit=days'); + expect(url).toContain('limit=100'); + expect(url).toContain('offset=50'); + }); + + it('supports the singular time_unit values the API accepts (day, hour)', async () => { + mockFetch({ items: [], pagination: { limit: 25, skip: 0, total_items: 0 } }); + await client.applicationsOverview({ timeInterval: 1, timeUnit: 'hour' }); + + const [url] = (globalThis.fetch as ReturnType).mock.calls[0]; + expect(url).toContain('time_interval=1'); + expect(url).toContain('time_unit=hour'); + }); + + it('tolerates empty items[] without throwing', async () => { + mockFetch({ items: [], pagination: { limit: 25, skip: 0, total_items: 0 } }); + const result = await client.applicationsOverview(); + expect(result.items).toEqual([]); + expect(result.pagination?.total_items).toBe(0); + }); + }); });