mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Merge pull request #1860 from rcourtman/claude/patrol-value-digest-card-r2
Show what Patrol did this week on the Patrol page
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