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.
This commit is contained in:
rcourtman
2026-09-02 09:39:55 +01:00
parent facee87bb4
commit 123e342c6c
12 changed files with 768 additions and 23 deletions
@@ -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`.
@@ -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`.
@@ -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`.
@@ -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 <date> (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`.
+23 -23
View File
@@ -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)"
]
}
@@ -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,
});
});
});
+90
View File
@@ -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<string, number>;
}
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<PatrolDigest> {
const search = new URLSearchParams({ days: String(days) });
return apiFetchJSON<PatrolDigest>(`/api/ai/patrol/digest?${search.toString()}`, { signal });
}
@@ -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"
>
<PatrolWeeklyDigestCard />
<PatrolRecentWorkPanel />
<section
@@ -0,0 +1,334 @@
import { For, Show, createMemo, createSignal, onCleanup, onMount } from 'solid-js';
import CalendarCheckIcon from 'lucide-solid/icons/calendar-check';
import RefreshIcon from 'lucide-solid/icons/refresh-cw';
import { getPatrolDigest, PATROL_DIGEST_DEFAULT_DAYS, type PatrolDigest } from '@/api/patrol';
import { Button, ButtonLink } from '@/components/shared/Button';
import { formatRelativeTime } from '@/utils/format';
// "This week" answers one question for a paying customer: what did Patrol do
// for me? Every tile is a rollup the backend already computed from records
// Pulse keeps (docs/PATROL_WEEKLY_DIGEST.md). The card shows only numbers the
// reader can act on; forensic detail stays in run history and Actions. The
// effective Patrol mode is stated once by the page header, so the card only
// carries mode-aware tile copy and never repeats that sentence.
const REFRESH_INTERVAL_MS = 60_000;
const usdFormatter = new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 2,
maximumFractionDigits: 2,
});
const INVESTIGATION_OUTCOME_COPY: Array<{ key: string; label: string }> = [
{ 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<string, number>,
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<PatrolDigest | null>(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 (
<section
class="overflow-hidden rounded-lg border border-border bg-surface"
aria-labelledby="patrol-weekly-digest-title"
>
<div class="flex items-start justify-between gap-3 border-b border-border px-4 py-4 sm:px-5">
<div>
<h2 id="patrol-weekly-digest-title" class="text-base font-semibold text-base-content">
This week
</h2>
<p class="mt-1 max-w-3xl text-sm leading-5 text-muted">
What Patrol did for you. {windowLabel()}.
</p>
</div>
<Button
variant="secondary"
size="sm"
class="gap-1.5"
onClick={() => void load()}
disabled={loading()}
aria-label="Refresh this week's summary"
>
<RefreshIcon
class={`h-4 w-4 ${loading() ? 'motion-safe:animate-spin' : ''}`}
aria-hidden="true"
/>
<span class="hidden sm:inline">Refresh</span>
</Button>
</div>
<div aria-live="polite">
<Show when={error()}>
{(message) => (
<div class="m-4 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-800 dark:border-red-900 dark:bg-red-950/30 dark:text-red-200">
<p class="font-semibold">This week's summary is unavailable</p>
<p class="mt-1 text-xs leading-5">{message()}</p>
</div>
)}
</Show>
<Show
when={!loading() || digest()}
fallback={<p class="px-4 py-8 text-center text-sm text-muted">Adding up this week…</p>}
>
<Show when={digest()}>
{(current) => (
<Show
when={hasRuns()}
fallback={
<div class="flex min-h-24 items-start gap-3 px-4 py-4 sm:px-5">
<CalendarCheckIcon
class="mt-0.5 h-5 w-5 shrink-0 text-muted"
aria-hidden="true"
/>
<div>
<h3 class="text-sm font-semibold text-base-content">
Patrol has not run in the last {current().window.days} days
</h3>
<p class="mt-1 text-xs leading-5 text-muted">
Once Patrol runs, this card adds up what it checked, found, fixed, and cost
you.
</p>
</div>
</div>
}
>
<dl class="grid divide-y divide-border sm:grid-cols-2 sm:divide-x sm:divide-y-0 xl:grid-cols-3 sm:[&>*:nth-child(2n+1)]:border-l-0 xl:[&>*:nth-child(2n+1)]:border-l xl:[&>*:nth-child(3n+1)]:border-l-0 sm:[&>*:nth-child(n+3)]:border-t xl:[&>*:nth-child(n+3)]:border-t-0 xl:[&>*:nth-child(n+4)]:border-t">
<For each={tiles()}>
{(tile) => (
<div class="px-4 py-4 sm:px-5" data-digest-tile={tile.id}>
<dt class="text-xs font-semibold uppercase tracking-wide text-muted">
{tile.label}
</dt>
<dd
class={`mt-1 text-2xl font-semibold tabular-nums ${
tile.tone === 'attention'
? 'text-amber-700 dark:text-amber-300'
: tile.tone === 'positive'
? 'text-emerald-700 dark:text-emerald-300'
: 'text-base-content'
}`}
>
{tile.value}
</dd>
<dd class="mt-1 text-xs leading-5 text-muted">
<For each={tile.details}>
{(line) => <span class="block">{line}</span>}
</For>
<Show when={tile.id === 'actions' && pendingCount() > 0}>
<ButtonLink href="/actions" variant="secondary" size="sm" class="mt-2">
{plural(pendingCount(), 'fix')} waiting for your approval
</ButtonLink>
</Show>
</dd>
</div>
)}
</For>
</dl>
</Show>
)}
</Show>
</Show>
</div>
</section>
);
}
export default PatrolWeeklyDigestCard;
@@ -54,6 +54,10 @@ vi.mock('../PatrolRecentWorkPanel', () => ({
PatrolRecentWorkPanel: () => <div>Recent work</div>,
}));
vi.mock('../PatrolWeeklyDigestCard', () => ({
PatrolWeeklyDigestCard: () => <div>This week</div>,
}));
vi.mock('@/stores/actionInbox', () => ({
actionInboxStore: { pendingActionCount: 0 },
}));
@@ -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<typeof import('@/api/patrol')>();
return {
...original,
getPatrolDigest: (...args: unknown[]) => apiMocks.getDigest(...args),
};
});
vi.mock('@/components/shared/Button', () => ({
Button: (props: { children?: unknown; onClick?: () => void; 'aria-label'?: string }) => (
<button type="button" aria-label={props['aria-label']} onClick={props.onClick}>
{props.children as never}
</button>
),
ButtonLink: (props: { href: string; children?: unknown }) => (
<a href={props.href}>{props.children as never}</a>
),
}));
import {
PatrolWeeklyDigestCard,
buildDigestTiles,
describeDigestInvestigationOutcomes,
describeDigestOpenFindings,
} from '../PatrolWeeklyDigestCard';
const digest = (overrides: Partial<PatrolDigest> = {}): 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(() => <PatrolWeeklyDigestCard />);
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(() => <PatrolWeeklyDigestCard />);
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(() => <PatrolWeeklyDigestCard />);
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(() => <PatrolWeeklyDigestCard />);
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']);
});
});
@@ -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('<PatrolAttentionWorkbench');
const objectivesIndex = patrolIntelligenceSurfaceSource.indexOf('<PatrolObjectivesPanel');
const verifiedOutcomesIndex = patrolIntelligenceSurfaceSource.indexOf('<PatrolRecentWorkPanel');
const weeklyDigestIndex = patrolIntelligenceSurfaceSource.indexOf('<PatrolWeeklyDigestCard');
const historyIndex = patrolIntelligenceSurfaceSource.indexOf('Review and history');
expect(attentionIndex).toBeGreaterThan(-1);
expect(objectivesIndex).toBeGreaterThan(attentionIndex);
expect(verifiedOutcomesIndex).toBeGreaterThan(attentionIndex);
expect(historyIndex).toBeGreaterThan(verifiedOutcomesIndex);
expect(weeklyDigestIndex).toBeGreaterThan(attentionIndex);
expect(weeklyDigestIndex).toBeLessThan(verifiedOutcomesIndex);
});
it('keeps the advanced Patrol settings drawer out of the old save-spinner path', () => {