mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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`.
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user