From 123e342c6c8cd031a4b0947770f09df4e351f33e Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Wed, 2 Sep 2026 09:39:55 +0100 Subject: [PATCH] Show what Patrol did this week on the Patrol page A paying customer had no place in Pulse that added up Patrol's work: runs were per run, findings per finding, actions under Actions, spend on the AI cost dashboard. The Activity tab now opens with a "This week" card that reads GET /api/ai/patrol/digest and shows six things in plain language: Patrol runs, new issues and how many are still open, issues resolved, investigations, fixes run, and estimated spend. Tile copy is mode-aware, so a watch-only install reads that nothing was investigated because Patrol is watch only, and the card links to Actions only when Patrol-origin fixes are waiting for approval. The card never recomputes counts client-side and keeps forensic vocabulary out; a failed load says the summary is unavailable rather than showing zeros, and a truncated run history says since when the numbers hold. The page header already states the Patrol mode sentence, so the card does not repeat it. Second slice of the "Patrol weekly digest" named bet in the pulse-pro demand ledger; browser proof in browser-verification.json covers the Activity tab at 1280 and 375 pixels against an isolated mock-mode backend. --- .../v6/internal/subsystems/ai-runtime.md | 9 + .../v6/internal/subsystems/api-contracts.md | 9 + .../subsystems/frontend-primitives.md | 13 + .../subsystems/patrol-intelligence.md | 20 ++ frontend-modern/browser-verification.json | 46 +-- .../src/api/__tests__/patrol.test.ts | 15 + frontend-modern/src/api/patrol.ts | 90 +++++ .../patrol/PatrolIntelligenceSurface.tsx | 3 + .../patrol/PatrolWeeklyDigestCard.tsx | 334 ++++++++++++++++++ .../PatrolIntelligenceSurface.test.tsx | 4 + .../__tests__/PatrolWeeklyDigestCard.test.tsx | 205 +++++++++++ .../pages/__tests__/AIIntelligence.test.tsx | 43 +++ 12 files changed, 768 insertions(+), 23 deletions(-) create mode 100644 frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx create mode 100644 frontend-modern/src/features/patrol/__tests__/PatrolWeeklyDigestCard.test.tsx diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 4d455b9f8..01505359f 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -7923,3 +7923,12 @@ longer reaches the start of the window. Unknown model pricing is reported as `monitor`. `frontend-modern/src/api/patrol.ts` mirrors the payload as `PatrolDigest`. Proofs: `internal/ai/patrol_digest_test.go` and `frontend-modern/src/api/__tests__/patrol.test.ts`. + +### Patrol digest client is the only consumer path + +`getPatrolDigest(days)` in `frontend-modern/src/api/patrol.ts` is the single +client for `GET /api/ai/patrol/digest`; it forwards the `days` window and +returns the typed `PatrolDigest` payload unchanged. Presentation code must not +recompute digest counts from run history, findings, or cost events, and must +not call the endpoint through any other client. Proof: +`frontend-modern/src/api/__tests__/patrol.test.ts`. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index ae4a06d39..15f92ce6c 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -10542,3 +10542,12 @@ an error so clients can render an honest "Patrol has not run" state, and `internal/api/security_regression_test.go`, `frontend-modern/src/api/__tests__/patrol.test.ts`, and `frontend-modern/src/utils/__tests__/docsLinks.test.ts`. + +### Patrol digest client mirrors the endpoint exactly + +The `PatrolDigest` types and `getPatrolDigest` client in +`frontend-modern/src/api/patrol.ts` mirror the `GET /api/ai/patrol/digest` +payload field for field (snake_case, `by_outcome` as an object, optional +`history_since` and `last_run_at`). New payload fields are additive and the +client must tolerate their absence. Proof: +`frontend-modern/src/api/__tests__/patrol.test.ts`. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 44ca69e15..e7894ab66 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -7044,3 +7044,16 @@ the settings surface; `useAISettingsState` fetches `aiPatrolCostPresentation.ts` owns the copy. `AIModelPicker.test.tsx`, `AISettings.test.tsx`, and `settingsArchitecture.test.ts` pin those distinctions. + +### Patrol weekly digest card is a read-only summary + +The Patrol Activity tab gains `PatrolWeeklyDigestCard` ("This week") above +Verified outcomes. It renders the server-computed `GET /api/ai/patrol/digest` +rollup as definition-list stat tiles built from the shared `Button` and +`ButtonLink` primitives and the existing surface, border, and muted text +tokens; it introduces no new shared primitive, theme token, or layout helper. +The only navigation it offers is the existing `/actions` route, shown only when +Patrol-origin fixes are waiting for approval. Loading, failed-load, no-runs, +and truncated-history states carry distinct copy, and a failed load never +renders zero counts as if the week were quiet. Browser proof covers the desktop +and narrow Activity tab in `frontend-modern/browser-verification.json`. diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index deaf9e516..819d3616c 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -65,6 +65,7 @@ sources, and retains the note as operator context. 36. `frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx` 37. `frontend-modern/src/features/patrol/patrolHomePresentation.ts` 38. `frontend-modern/src/features/patrol/PatrolRecentWorkPanel.tsx` +39. `frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx` ## Shared Boundaries @@ -2558,3 +2559,22 @@ finding and carried onto the unified finding as `failureCause`, and a provider preflight success no longer clears a budget-exhausted runtime finding, so the setup card still routes to the budget after a restart clears the in-memory block state. + +### This week card answers what Patrol did for the customer + +The Activity tab leads with `PatrolWeeklyDigestCard` ("This week"), rendered +above Verified outcomes and never inside the Inbox decision surface. It shows +six tiles in plain customer language: Patrol runs, New issues, Issues resolved, +Investigated, Fixes run, and Estimated spend, with the effective Patrol mode +sentence underneath, and it links to `/actions` only when Patrol-origin fixes +are waiting for approval. The card reads `GET /api/ai/patrol/digest` and must +not recompute counts from findings or run history client-side. Forensic +vocabulary (evidence classes, verdicts, model names, tool traces) stays out of +the card and remains in run history and the Actions audit. Empty history +renders "Patrol has not run in the last N days"; a truncated history says +"Since (older runs are no longer kept)"; a failed load says the summary +is unavailable rather than showing zeros. Proofs: +`frontend-modern/src/features/patrol/__tests__/PatrolWeeklyDigestCard.test.tsx`, +`frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx`, +`frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx` (card ordering), +and the browser receipt in `frontend-modern/browser-verification.json`. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 7dba434b2..4d29339e3 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,42 +1,42 @@ { "version": 1, - "base_sha": "23b3893ae88b86497f3f103c8972663508555766", - "verified_at": "2026-09-02T07:15:12Z", + "base_sha": "facee87bb4e7b84a0ce682d6e142d35b55be3ace", + "verified_at": "2026-09-02T08:55:07Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/shared/CommandPaletteModal.tsx", - "frontend-modern/src/components/shared/SearchField.tsx", - "frontend-modern/src/components/shared/searchFieldModel.ts", - "frontend-modern/src/components/shared/useCommandPaletteState.ts" + "frontend-modern/src/api/patrol.ts", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", + "frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx" ], "content_sha256": { - "frontend-modern/src/components/shared/CommandPaletteModal.tsx": "300030975b76634513d98b9459fe9ae4d07e8d7d03fedffa91f702746a7d3f06", - "frontend-modern/src/components/shared/SearchField.tsx": "0c2cc0d002763b59c2c065ceccff134318122aa975fe45ddc31271387cd623ad", - "frontend-modern/src/components/shared/searchFieldModel.ts": "3b9ca4ffc6aef0e5910e5094daebb9b03878b2b6ef4c9c7aed165bf7f46097db", - "frontend-modern/src/components/shared/useCommandPaletteState.ts": "df4baf2ccba420f0c8d7d67ee7ddeba5a55ddbee8af323d8d7924aa389ad9b3a" + "frontend-modern/src/api/patrol.ts": "c411c12d504b53e64b1435c5b336ba953f5051f6909f2bfef3e46067d5b88f34", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "0d46fd5afbbdf8e1b885fa558be070c07203cc6613dda4458e9790fa8f2e4099", + "frontend-modern/src/features/patrol/PatrolWeeklyDigestCard.tsx": "9a9728f618c502a5379f56e93cad29ea939f5c26e73e3c7d22195308a8087b81" }, - "routes": ["/proxmox"], + "routes": [ + "/patrol" + ], "viewports": [ { "width": 1280, - "height": 720 + "height": 800 }, { - "width": 393, - "height": 851 + "width": 375, + "height": 812 } ], "states": [ - "Command palette open with twelve results and the first result selected at desktop and narrow widths", - "Command palette scrolled to the last keyboard-selected result at desktop and narrow widths", - "Command palette empty result state after a query with no matching commands at desktop and narrow widths", - "Command palette closed after Escape and after backdrop dismissal at desktop and narrow widths" + "Activity tab with the This week card above Verified outcomes, populated from an isolated mock-mode backend (11 runs, 3 new issues, watch-only mode)", + "card tiles in single column at 375px with no horizontal overflow", + "card refresh in flight and settled", + "Verified outcomes empty state and Review and history below the card", + "watch-only tile copy for Investigated and Fixes run" ], "interactions": [ - "opened the command palette with Control+K on the authenticated Proxmox route", - "verified the search retained DOM focus while pointer hover and Home and End keys updated aria-activedescendant and aria-selected", - "verified End scrolled the last selected option fully into the result viewport and Tab did not focus an option", - "entered a no-match query and verified the combobox collapsed, cleared its active descendant, and removed the listbox", - "dismissed the palette with Escape and reopened and dismissed it through the backdrop" + "clicked the Activity workspace tab", + "scrolled the card into view at desktop and narrow widths", + "clicked Refresh this week's summary and confirmed the tiles reloaded without an error state", + "checked console for card-originated errors (only unrelated dev websocket/update-check noise)" ] } diff --git a/frontend-modern/src/api/__tests__/patrol.test.ts b/frontend-modern/src/api/__tests__/patrol.test.ts index f9a63f401..741418d60 100644 --- a/frontend-modern/src/api/__tests__/patrol.test.ts +++ b/frontend-modern/src/api/__tests__/patrol.test.ts @@ -6,6 +6,7 @@ vi.mock('@/utils/apiClient', () => ({ import { getPatrolStatus, + getPatrolDigest, getPatrolRun, getPatrolFindings, getPatrolRunHistory, @@ -582,4 +583,18 @@ describe('triggerPatrolRun scope body', () => { await triggerPatrolRun({ resource_ids: [], resource_types: [] }); expect(apiFetchJSONMock).toHaveBeenCalledWith('/api/ai/patrol/run', { method: 'POST' }); }); + + it('reads the weekly digest for the requested window', async () => { + const digest = { window: { days: 7 }, runs: { total: 3 } }; + apiFetchJSONMock.mockResolvedValueOnce(digest as any); + await expect(getPatrolDigest()).resolves.toBe(digest); + expect(apiFetchJSONMock).toHaveBeenLastCalledWith('/api/ai/patrol/digest?days=7', { + signal: undefined, + }); + + await getPatrolDigest(30); + expect(apiFetchJSONMock).toHaveBeenLastCalledWith('/api/ai/patrol/digest?days=30', { + signal: undefined, + }); + }); }); diff --git a/frontend-modern/src/api/patrol.ts b/frontend-modern/src/api/patrol.ts index 590b3fa0b..e45416d2f 100644 --- a/frontend-modern/src/api/patrol.ts +++ b/frontend-modern/src/api/patrol.ts @@ -1051,3 +1051,93 @@ export async function runPatrolModelReadiness( signal, }); } + +// --- Weekly digest ("what Patrol did for you") --------------------------- +// Mirrors internal/ai/patrol_digest.go. Every number is a rollup over records +// Pulse already keeps; see docs/PATROL_WEEKLY_DIGEST.md for sources and limits. + +export type PatrolDigestMode = 'monitor' | 'approval' | 'assisted' | 'full'; + +export interface PatrolDigestWindow { + start: string; + end: string; + days: number; + history_complete: boolean; + history_since?: string; +} + +export interface PatrolDigestRuns { + total: number; + scheduled: number; + event_triggered: number; + manual: number; + failed: number; + checks: number; + resources_covered: number; + last_run_at?: string; +} + +export interface PatrolDigestSeverityCounts { + critical: number; + warning: number; + watch: number; + info: number; +} + +export interface PatrolDigestFindings { + new: number; + open_by_severity: PatrolDigestSeverityCounts; + resolved: number; + auto_resolved: number; + dismissed: number; + suppressed: number; +} + +export interface PatrolDigestInvestigations { + total: number; + by_outcome: Record; +} + +export interface PatrolDigestActions { + proposed: number; + approved: number; + rejected: number; + executed: number; + verified: number; + failed: number; + pending: number; +} + +export interface PatrolDigestAlerts { + reviewed: number; +} + +export interface PatrolDigestSpend { + estimated_usd: number; + pricing_known: boolean; + input_tokens: number; + output_tokens: number; + calls: number; +} + +export interface PatrolDigest { + generated_at: string; + window: PatrolDigestWindow; + mode: PatrolDigestMode; + runs: PatrolDigestRuns; + findings: PatrolDigestFindings; + investigations: PatrolDigestInvestigations; + actions: PatrolDigestActions; + alerts: PatrolDigestAlerts; + spend: PatrolDigestSpend; +} + +export const PATROL_DIGEST_DEFAULT_DAYS = 7; + +export async function getPatrolDigest( + days: number = PATROL_DIGEST_DEFAULT_DAYS, + signal?: AbortSignal, +): Promise { + const search = new URLSearchParams({ days: String(days) }); + return apiFetchJSON(`/api/ai/patrol/digest?${search.toString()}`, { signal }); +} diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx index b9587c060..92c5ee61b 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx @@ -13,6 +13,7 @@ import { PatrolIntelligenceWorkspace } from './PatrolIntelligenceWorkspace'; import { PatrolAttentionWorkbench } from './PatrolAttentionWorkbench'; import { PatrolObjectivesPanel } from './PatrolObjectivesPanel'; import { PatrolRecentWorkPanel } from './PatrolRecentWorkPanel'; +import { PatrolWeeklyDigestCard } from './PatrolWeeklyDigestCard'; import type { AttentionItem } from '@/api/patrolAttention'; type PatrolWorkspaceView = 'inbox' | 'protection' | 'activity'; @@ -131,6 +132,8 @@ export function PatrolIntelligenceSurface() { aria-labelledby="patrol-activity-tab" class="space-y-4 lg:space-y-5" > + +
= [ + { key: 'needs_attention', label: 'need you' }, + { key: 'fix_failed', label: 'fix failed' }, + { key: 'fix_verification_failed', label: 'fix not confirmed' }, + { key: 'cannot_fix', label: 'could not fix' }, + { key: 'timed_out', label: 'timed out' }, + { key: 'fix_queued', label: 'fix waiting for approval' }, + { key: 'fix_executed', label: 'fix run' }, + { key: 'fix_verification_unknown', label: 'fix run, result unknown' }, + { key: 'fix_verified', label: 'fixed and verified' }, + { key: 'resolved', label: 'resolved' }, + { key: 'fix_rejected', label: 'fix declined' }, +]; + +const plural = (count: number, singular: string, pluralForm = `${singular}s`): string => + `${count} ${count === 1 ? singular : pluralForm}`; + +const formatDigestError = (error: unknown): string => + error instanceof Error ? error.message : 'The weekly summary could not be loaded.'; + +const formatWindowDate = (iso: string): string => { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }); +}; + +export function describeDigestInvestigationOutcomes( + byOutcome: Record, + limit = 2, +): string[] { + const lines: string[] = []; + for (const entry of INVESTIGATION_OUTCOME_COPY) { + const count = byOutcome[entry.key] ?? 0; + if (count > 0) lines.push(`${count} ${entry.label}`); + if (lines.length >= limit) break; + } + return lines; +} + +export function describeDigestOpenFindings(digest: PatrolDigest): string { + const open = digest.findings.open_by_severity; + const openTotal = open.critical + open.warning + open.watch + open.info; + if (digest.findings.new === 0) return 'Nothing new was raised.'; + if (openTotal === 0) return 'All of them have since cleared.'; + const parts: string[] = []; + if (open.critical > 0) parts.push(`${open.critical} critical`); + if (open.warning > 0) parts.push(`${open.warning} warning`); + const detail = parts.length > 0 ? ` (${parts.join(', ')})` : ''; + return `${openTotal} still open${detail}.`; +} + +interface DigestTile { + id: string; + value: string; + label: string; + details: string[]; + tone?: 'default' | 'positive' | 'attention'; +} + +export function buildDigestTiles(digest: PatrolDigest): DigestTile[] { + const { runs, findings, investigations, actions, alerts, spend } = digest; + + const runDetails = [ + `${plural(runs.checks, 'check')} across ${plural(runs.resources_covered, 'resource')}.`, + ]; + if (alerts.reviewed > 0) runDetails.push(`${plural(alerts.reviewed, 'alert')} looked into.`); + if (runs.failed > 0) runDetails.push(`${plural(runs.failed, 'run')} failed.`); + if (runs.last_run_at) runDetails.push(`Last run ${formatRelativeTime(runs.last_run_at)}.`); + + const resolvedDetails: string[] = []; + if (findings.auto_resolved > 0) { + resolvedDetails.push(`${findings.auto_resolved} cleared by Patrol on its own.`); + } + if (findings.dismissed > 0) resolvedDetails.push(`${findings.dismissed} dismissed by you.`); + if (findings.suppressed > 0) resolvedDetails.push(`${findings.suppressed} muted for good.`); + if (resolvedDetails.length === 0) { + resolvedDetails.push( + findings.resolved > 0 ? 'Resolved by you.' : 'No issues were resolved this period.', + ); + } + + const investigationDetails = describeDigestInvestigationOutcomes(investigations.by_outcome); + if (investigationDetails.length === 0) { + investigationDetails.push( + digest.mode === 'monitor' + ? 'Patrol is watch only, so it reports issues without investigating them.' + : 'No issues needed a closer look.', + ); + } else { + investigationDetails[investigationDetails.length - 1] += '.'; + if (investigationDetails.length > 1) investigationDetails[0] += ','; + } + + const actionDetails: string[] = []; + if (actions.executed > 0) { + actionDetails.push(`${actions.verified} of ${actions.executed} verified afterwards.`); + } + if (actions.failed > 0) actionDetails.push(`${plural(actions.failed, 'action')} failed.`); + if (actions.rejected > 0) actionDetails.push(`${actions.rejected} declined by you.`); + if (actionDetails.length === 0 && actions.pending === 0) { + actionDetails.push( + digest.mode === 'monitor' + ? 'Patrol is watch only, so no fixes were proposed.' + : 'No fixes were needed.', + ); + } + + const spendDetails = [`${plural(spend.calls, 'model call')}.`]; + if (spend.calls > 0 && !spend.pricing_known) { + spendDetails.push('Some calls used a model with no known price.'); + } + + return [ + { id: 'runs', value: String(runs.total), label: 'Patrol runs', details: runDetails }, + { + id: 'new', + value: String(findings.new), + label: 'New issues', + details: [describeDigestOpenFindings(digest)], + tone: + findings.open_by_severity.critical + findings.open_by_severity.warning > 0 + ? 'attention' + : 'default', + }, + { + id: 'resolved', + value: String(findings.resolved), + label: 'Issues resolved', + details: resolvedDetails, + tone: findings.resolved > 0 ? 'positive' : 'default', + }, + { + id: 'investigated', + value: String(investigations.total), + label: 'Investigated', + details: investigationDetails, + }, + { + id: 'actions', + value: String(actions.executed), + label: 'Fixes run', + details: actionDetails, + tone: actions.pending > 0 ? 'attention' : actions.executed > 0 ? 'positive' : 'default', + }, + { + id: 'spend', + value: spend.calls > 0 ? usdFormatter.format(spend.estimated_usd) : usdFormatter.format(0), + label: 'Estimated spend', + details: spendDetails, + }, + ]; +} + +export function PatrolWeeklyDigestCard() { + const [digest, setDigest] = createSignal(null); + const [loading, setLoading] = createSignal(true); + const [error, setError] = createSignal(''); + + const load = async (quiet = false) => { + if (!quiet) setLoading(true); + try { + setDigest(await getPatrolDigest(PATROL_DIGEST_DEFAULT_DAYS)); + setError(''); + } catch (cause) { + setError(formatDigestError(cause)); + } finally { + if (!quiet) setLoading(false); + } + }; + + onMount(() => { + void load(); + const refresh = () => { + if (document.visibilityState === 'visible') void load(true); + }; + const timer = window.setInterval(refresh, REFRESH_INTERVAL_MS); + document.addEventListener('visibilitychange', refresh); + onCleanup(() => { + window.clearInterval(timer); + document.removeEventListener('visibilitychange', refresh); + }); + }); + + const windowLabel = createMemo(() => { + const current = digest(); + if (!current) return `Last ${PATROL_DIGEST_DEFAULT_DAYS} days`; + if (!current.window.history_complete && current.window.history_since) { + const since = formatWindowDate(current.window.history_since); + return since + ? `Since ${since} (older runs are no longer kept)` + : `Last ${current.window.days} days`; + } + return `Last ${current.window.days} days`; + }); + + const tiles = createMemo(() => { + const current = digest(); + return current ? buildDigestTiles(current) : []; + }); + + const pendingCount = createMemo(() => digest()?.actions.pending ?? 0); + const hasRuns = createMemo(() => (digest()?.runs.total ?? 0) > 0); + + return ( +
+
+
+

+ This week +

+

+ What Patrol did for you. {windowLabel()}. +

+
+ +
+ +
+ + {(message) => ( +
+

This week's summary is unavailable

+

{message()}

+
+ )} +
+ + Adding up this week…

} + > + + {(current) => ( + +
+ } + > +
+ + {(tile) => ( +
+
+ {tile.label} +
+
+ {tile.value} +
+
+ + {(line) => {line}} + + 0}> + + {plural(pendingCount(), 'fix')} waiting for your approval + + +
+
+ )} +
+
+ + )} + + + +
+ ); +} + +export default PatrolWeeklyDigestCard; diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx index 11cb6529a..07696612e 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx +++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSurface.test.tsx @@ -54,6 +54,10 @@ vi.mock('../PatrolRecentWorkPanel', () => ({ PatrolRecentWorkPanel: () =>
Recent work
, })); +vi.mock('../PatrolWeeklyDigestCard', () => ({ + PatrolWeeklyDigestCard: () =>
This week
, +})); + vi.mock('@/stores/actionInbox', () => ({ actionInboxStore: { pendingActionCount: 0 }, })); diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolWeeklyDigestCard.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolWeeklyDigestCard.test.tsx new file mode 100644 index 000000000..2f00c6ad5 --- /dev/null +++ b/frontend-modern/src/features/patrol/__tests__/PatrolWeeklyDigestCard.test.tsx @@ -0,0 +1,205 @@ +import { cleanup, render, screen } from '@solidjs/testing-library'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PatrolDigest } from '@/api/patrol'; + +const apiMocks = vi.hoisted(() => ({ getDigest: vi.fn() })); + +vi.mock('@/api/patrol', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getPatrolDigest: (...args: unknown[]) => apiMocks.getDigest(...args), + }; +}); + +vi.mock('@/components/shared/Button', () => ({ + Button: (props: { children?: unknown; onClick?: () => void; 'aria-label'?: string }) => ( + + ), + ButtonLink: (props: { href: string; children?: unknown }) => ( + {props.children as never} + ), +})); + +import { + PatrolWeeklyDigestCard, + buildDigestTiles, + describeDigestInvestigationOutcomes, + describeDigestOpenFindings, +} from '../PatrolWeeklyDigestCard'; + +const digest = (overrides: Partial = {}): PatrolDigest => ({ + generated_at: '2026-09-01T12:00:00Z', + window: { + start: '2026-08-25T12:00:00Z', + end: '2026-09-01T12:00:00Z', + days: 7, + history_complete: true, + }, + mode: 'approval', + runs: { + total: 38, + scheduled: 34, + event_triggered: 3, + manual: 1, + failed: 0, + checks: 1520, + resources_covered: 40, + last_run_at: '2026-09-01T11:00:00Z', + }, + findings: { + new: 12, + open_by_severity: { critical: 1, warning: 3, watch: 0, info: 0 }, + resolved: 9, + auto_resolved: 7, + dismissed: 2, + suppressed: 0, + }, + investigations: { total: 4, by_outcome: { fix_verified: 2, needs_attention: 1, resolved: 1 } }, + actions: { + proposed: 3, + approved: 2, + rejected: 0, + executed: 2, + verified: 1, + failed: 0, + pending: 1, + }, + alerts: { reviewed: 5 }, + spend: { + estimated_usd: 1.2345, + pricing_known: true, + input_tokens: 4_000_000, + output_tokens: 200_000, + calls: 40, + }, + ...overrides, +}); + +describe('PatrolWeeklyDigestCard', () => { + beforeEach(() => apiMocks.getDigest.mockReset()); + afterEach(cleanup); + + it('adds up the week in customer terms and links pending fixes to Actions', async () => { + apiMocks.getDigest.mockResolvedValue(digest()); + + render(() => ); + + expect(await screen.findByText('Patrol runs')).toBeInTheDocument(); + expect(apiMocks.getDigest).toHaveBeenCalledWith(7); + expect(screen.getByText('38')).toBeInTheDocument(); + expect(screen.getByText('1520 checks across 40 resources.')).toBeInTheDocument(); + expect(screen.getByText('5 alerts looked into.')).toBeInTheDocument(); + expect(screen.getByText('New issues')).toBeInTheDocument(); + expect(screen.getByText('4 still open (1 critical, 3 warning).')).toBeInTheDocument(); + expect(screen.getByText('7 cleared by Patrol on its own.')).toBeInTheDocument(); + expect(screen.getByText('2 dismissed by you.')).toBeInTheDocument(); + expect(screen.getByText('1 need you,')).toBeInTheDocument(); + expect(screen.getByText('2 fixed and verified.')).toBeInTheDocument(); + expect(screen.getByText('1 of 2 verified afterwards.')).toBeInTheDocument(); + const pendingLink = screen.getByText('1 fix waiting for your approval'); + expect(pendingLink.closest('a')).toHaveAttribute('href', '/actions'); + expect(screen.getByText('$1.23')).toBeInTheDocument(); + expect(screen.getByText('40 model calls.')).toBeInTheDocument(); + // The page header already states the mode sentence; the card must not repeat it. + expect(screen.queryByText(/every change waits for your approval/)).not.toBeInTheDocument(); + expect(screen.getByText(/Last 7 days/)).toBeInTheDocument(); + // Forensic vocabulary stays out of the customer summary. + expect(screen.queryByText(/evidence class/i)).not.toBeInTheDocument(); + expect(screen.queryByText(/verdict/i)).not.toBeInTheDocument(); + }); + + it('says plainly when Patrol has not run and when history is cut short', async () => { + apiMocks.getDigest.mockResolvedValueOnce( + digest({ + runs: { + total: 0, + scheduled: 0, + event_triggered: 0, + manual: 0, + failed: 0, + checks: 0, + resources_covered: 0, + }, + }), + ); + render(() => ); + expect(await screen.findByText('Patrol has not run in the last 7 days')).toBeInTheDocument(); + expect(screen.queryByText('Patrol runs')).not.toBeInTheDocument(); + cleanup(); + + apiMocks.getDigest.mockResolvedValueOnce( + digest({ + window: { + start: '2026-08-25T12:00:00Z', + end: '2026-09-01T12:00:00Z', + days: 7, + history_complete: false, + history_since: '2026-08-29T08:00:00Z', + }, + }), + ); + render(() => ); + expect(await screen.findByText(/older runs are no longer kept/)).toBeInTheDocument(); + }); + + it('reports a failed load without pretending the week was empty', async () => { + apiMocks.getDigest.mockRejectedValueOnce(new Error('digest offline')); + render(() => ); + expect(await screen.findByText("This week's summary is unavailable")).toBeInTheDocument(); + expect(screen.getByText('digest offline')).toBeInTheDocument(); + expect(screen.queryByText(/Patrol has not run/)).not.toBeInTheDocument(); + }); + + it('keeps tile copy honest for watch-only installs and unknown pricing', () => { + const tiles = buildDigestTiles( + digest({ + mode: 'monitor', + investigations: { total: 0, by_outcome: {} }, + actions: { + proposed: 0, + approved: 0, + rejected: 0, + executed: 0, + verified: 0, + failed: 0, + pending: 0, + }, + spend: { + estimated_usd: 0.5, + pricing_known: false, + input_tokens: 1, + output_tokens: 1, + calls: 3, + }, + }), + ); + const byId = Object.fromEntries(tiles.map((tile) => [tile.id, tile])); + expect(byId.investigated.details).toEqual([ + 'Patrol is watch only, so it reports issues without investigating them.', + ]); + expect(byId.actions.details).toEqual(['Patrol is watch only, so no fixes were proposed.']); + expect(byId.spend.details).toEqual([ + '3 model calls.', + 'Some calls used a model with no known price.', + ]); + expect(describeDigestOpenFindings(digest({ findings: { ...digest().findings, new: 0 } }))).toBe( + 'Nothing new was raised.', + ); + expect( + describeDigestOpenFindings( + digest({ + findings: { + ...digest().findings, + open_by_severity: { critical: 0, warning: 0, watch: 0, info: 0 }, + }, + }), + ), + ).toBe('All of them have since cleared.'); + expect( + describeDigestInvestigationOutcomes({ resolved: 3, fix_failed: 1, cannot_fix: 2 }), + ).toEqual(['1 fix failed', '2 could not fix']); + }); +}); diff --git a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx index 0ae1abe3f..2e0add29d 100644 --- a/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx +++ b/frontend-modern/src/pages/__tests__/AIIntelligence.test.tsx @@ -154,6 +154,46 @@ vi.mock('@/api/patrol', () => ({ triggerPatrolRun: (...args: unknown[]) => triggerPatrolRunMock(...args), getPatrolRunHistory: (...args: unknown[]) => getPatrolRunHistoryMock(...args), getPatrolObjectives: vi.fn().mockResolvedValue([]), + PATROL_DIGEST_DEFAULT_DAYS: 7, + getPatrolDigest: vi.fn().mockResolvedValue({ + generated_at: '2026-09-01T12:00:00Z', + window: { + start: '2026-08-25T12:00:00Z', + end: '2026-09-01T12:00:00Z', + days: 7, + history_complete: true, + }, + mode: 'monitor', + runs: { + total: 0, + scheduled: 0, + event_triggered: 0, + manual: 0, + failed: 0, + checks: 0, + resources_covered: 0, + }, + findings: { + new: 0, + open_by_severity: { critical: 0, warning: 0, watch: 0, info: 0 }, + resolved: 0, + auto_resolved: 0, + dismissed: 0, + suppressed: 0, + }, + investigations: { total: 0, by_outcome: {} }, + actions: { + proposed: 0, + approved: 0, + rejected: 0, + executed: 0, + verified: 0, + failed: 0, + pending: 0, + }, + alerts: { reviewed: 0 }, + spend: { estimated_usd: 0, pricing_known: true, input_tokens: 0, output_tokens: 0, calls: 0 }, + }), createPatrolObjective: vi.fn(), updatePatrolObjective: vi.fn(), deletePatrolObjective: vi.fn(), @@ -564,12 +604,15 @@ describe('AIIntelligence entitlement gating', () => { const attentionIndex = patrolIntelligenceSurfaceSource.indexOf(' {