fix(patrol): clarify decisions and action origins

This commit is contained in:
rcourtman 2026-08-18 12:44:20 +01:00
parent ac4116e09b
commit bcea56db97
21 changed files with 342 additions and 59 deletions

View file

@ -4223,7 +4223,12 @@ projection over alert-owned operational records, transition timelines,
evidence envelopes, and recovery-owned protection postures. It may order,
filter, paginate, summarize, and shape selected Assistant context, but it must
not create a second writable lifecycle or infer a healthy state when lifecycle
coverage is unavailable. `internal/ai/patrol_metrics.go` exports only
coverage is unavailable. The projection also owns customer-facing attention
titles: provider alarm messages yield their bounded alarm subject, generic
resource/storage incident types become plain issue language, and known
acronyms such as CPU, ZFS, and I/O retain their canonical casing. Clients must
not receive machine-title-cased placeholders such as `Resource Incident` and
repair them independently. `internal/ai/patrol_metrics.go` exports only
low-cardinality lifecycle-state counts, queue age, acknowledgement time, and
calm-evaluation age; raw resource IDs are forbidden as metric labels.

View file

@ -3924,7 +3924,10 @@ next selection from the refreshed typed membership. It must not optimistically
author lifecycle state, keep acknowledged/suppressed records in the active
inbox, or turn review progress into a second queue API. Newly returned active
items may be appended to the in-progress review session without changing their
server-authored identity or trust fields.
server-authored identity or trust fields. Visible queue position and calm-state
evaluation age must remain reactive to those refreshed typed projections; a
client must not capture either accessor as mount-time copy and then display a
stale position or evaluation timestamp.
Lifecycle read failure returns a typed unavailable response and never a
synthetic zero or calm state. Protection lookup failure may return lifecycle

View file

@ -2653,7 +2653,13 @@ announces the remaining count and advances to the next current item, and the
final settled item returns to the calm inbox. Lifecycle copy must explain that
review removes one occurrence from today's inbox while suppression is bounded;
generic backend verbs must not leave the user guessing whether the alert was
resolved, hidden permanently, or merely recorded as seen.
resolved, hidden permanently, or merely recorded as seen. Both the position
label and calm evaluation age are reactive text nodes so detail navigation and
refresh cannot leave their first-render values frozen. The selected metadata
keeps latest-observation age visible, action verification copy stays generic
across capabilities, and the list itself is not a broad live region; only
errors, calm/empty transitions, and explicit completion announcements receive
scoped assistive announcement semantics.
The selected attention detail may compose the shared Actions review for an
eligible backend-authored offer. The detail owns only the expected
@ -5772,7 +5778,10 @@ universal audit records originating from Patrol, Assistant, MCP, or manual
controls. While that route is open, desktop and mobile navigation select
Actions and the browser title identifies `Actions`. The first-class navigation
entry composes the existing shared review dialog, route, API client, and durable
action identity rather than creating another action client.
action identity rather than creating another action client. When the trusted
audit origin is present, both the queue row and decision packet show bounded
product attribution such as `From Patrol`; unknown first-party surfaces fall
back to `From Pulse`, and absent origin remains absent rather than guessed.
### Protection posture presentation boundary

View file

@ -1246,7 +1246,9 @@ keeps the canonical resource identifier behind a copy affordance, suppresses
impact text that merely repeats the summary, and groups evidence, protection,
and lifecycle history behind one deliberate disclosure. Occurrence controls
remain visible, while durable threshold and finding-management guidance is
secondary disclosure. This is progressive presentation of the same typed
secondary disclosure. Selected metadata includes the latest observation age,
and action-result wording remains capability-neutral rather than assuming every
offer is a restart or container operation. This is progressive presentation of the same typed
record, not a loss of evidence or lifecycle truth.
Plan-locked Watch-only installs do not render an empty mode disclosure after
@ -1256,13 +1258,17 @@ choice controls. The empty Protection mode offers bounded example outcomes
that prefill the existing objective form without creating an objective until
the operator reviews and submits it. Activity keeps Actions and Patrol records
as separate subordinate handoffs, but the Patrol-record handoff does not expose
the legacy findings count beside the canonical decision count.
the legacy findings count beside the canonical decision count. Its supporting
copy explicitly distinguishes raw finding/run history from Inbox decisions, and
its collapsed container must not leave a decorative border artifact behind.
Selecting the first item starts a bounded review session over the loaded
decision order. The selected context shows its current position and exposes
previous/next navigation on desktop and mobile; deterministic identity breaks
otherwise equal priority ties so polling cannot reshuffle equal decisions
arbitrarily. The session retains its review order while fresh list responses
arbitrarily. Position copy is a live projection of the selected identity and
loaded queue length, not a one-time string captured when detail first mounts.
The session retains its review order while fresh list responses
remove settled items and append genuinely new work. Acknowledging an occurrence
is presented as `Mark reviewed`: on success the item leaves the active inbox,
Patrol announces the remaining decision count, and advances to the next current
@ -2238,11 +2244,12 @@ signal from the most recent completed run via
`PatrolRecencyPresentation.resourcesChecked`, populated by
`getPatrolRecencyPresentation` from
`PatrolRunRecord.resources_checked`. The render reads "Last full
patrol: 3m ago — verified 47 resources" so operators see both
patrol: 3m ago — checked 47 resources" so operators see both
temporal recency and coverage in one line. The field stays optional
(omitted when zero) so a degenerate run that completed without
checking any resources does not render a misleading "verified 0
resources" line.
checking any resources does not render a misleading "checked 0
resources" line. Coverage never uses `verified`, which is reserved for
confirmed action outcomes rather than inventory traversal.
The expanded finding card must also expose a "Copy summary" action
that produces a paste-ready Markdown summary of the finding (severity,
title, resource header, description, impact, recommendation,

View file

@ -1695,7 +1695,10 @@ served clones. Proof: `TestClonedResourcesPreservePlatformAdmission` and
`migrateActionAuditsSchema`) and round-tripped through
`scanActionAuditRecord`. Origin identifies which internal surface
proposed the action (e.g. Patrol) so decisions and terminal outcomes
can be reconciled back onto that surface's records. It is set only by
can be reconciled back onto that surface's records. The Actions queue and
decision packet render that trusted surface as bounded product copy such as
`From Patrol`; they do not expose an internal surface token or invent a
return link when origin metadata lacks a canonical route. It is set only by
in-process planning callers through the action lifecycle service's
plan options; the public `POST /api/actions/plan` body must never be
able to claim a first-party origin. `NormalizeActionOrigin` trims
@ -1720,6 +1723,11 @@ served clones. Proof: `TestClonedResourcesPreservePlatformAdmission` and
`TestSQLiteStoreActionAuditOriginRoundTrip`,
`TestSQLiteActionAuditOriginOperationalRecordReader`, and the matching
memory/batch reader tests in `internal/unifiedresources/store_test.go`.
Incident next-step fallback follows the same canonical resource boundary:
storage guidance is reserved for storage, physical-disk, network-share,
PBS, and Ceph resources. Network and every other non-storage type receive
neutral resource-health guidance rather than being misclassified as
storage merely because they are not a host or VM.
27. Keep API-added TrueNAS systems keyed by the configured connection,
never by snapshot-reported identity. `systemSourceID` in
`internal/truenas/provider.go` scopes the system source ID (and every

View file

@ -1,25 +1,29 @@
{
"version": 1,
"base_sha": "95c253c4860d704dfdc8b3d743b482182d749fff",
"verified_at": "2026-08-18T10:49:01Z",
"base_sha": "ac4116e09b85055e281aee3895ba65012e747215",
"verified_at": "2026-08-18T11:35:39Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/features/actions/ActionDecisionPacket.tsx",
"frontend-modern/src/features/actions/actionPresentation.ts",
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx",
"frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx"
"frontend-modern/src/pages/Actions.tsx",
"frontend-modern/src/utils/patrolSummaryPresentation.ts"
],
"content_sha256": {
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "42d0e595053546270727bcfe57de37b1f9c6e4c721a2bbc6175335136a4e03b2",
"frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx": "eeaecb901bac67f4b2ba2c442a5f669be50ee2a1bb01928340eef4d83da5532d",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "90a0e1b7b7b75567a6c026d4d6c115b82d06c1f86412f971c0c8989a60562471",
"frontend-modern/src/features/patrol/PatrolObjectivesPanel.tsx": "fc03c1905afae9aaa684aecd759cd6e409eee5fb987abcdee5e2c83d850352d1"
"frontend-modern/src/features/actions/ActionDecisionPacket.tsx": "4bd177108ab987198c977d1c763a0ca21293d665b71520364eb168a312ac0ac0",
"frontend-modern/src/features/actions/actionPresentation.ts": "924734c195777644da82dfe4190eaa18ba4491d75ad67204b3bb39bd9f3eb1c8",
"frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "8b28588b1117a87a7c22944b29446a9b6944abf402b5ea162bbe88635e8a8f35",
"frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "edb3703a3f28c562fa16b8762c8907651504d8aff0ae006a130c2d3a3afe98be",
"frontend-modern/src/pages/Actions.tsx": "7649d29a5a32650265fd745c07eab2e5c42f397a2e0a6da3b5563170daade9dd",
"frontend-modern/src/utils/patrolSummaryPresentation.ts": "22416045104e977f1a8ec7995a32a5c5943ae98a02d5bbae5aed0bab29ed2330"
},
"routes": ["/patrol"],
"routes": ["/patrol", "/actions"],
"viewports": [
{
"width": 1440,
"height": 900
"width": 1280,
"height": 720
},
{
"width": 390,
@ -27,20 +31,20 @@
}
],
"states": [
"Inbox default queue with resource-first decision titles and no selected detail",
"Inbox selected decision with compact trust metadata and evidence collapsed",
"Inbox selected decision with evidence, protection, and timeline expanded",
"Protection empty state with starter outcomes",
"Protection create-objective dialog prefilled from a starter outcome",
"Activity with verified outcomes, Actions handoff, and Patrol records disclosure",
"Watch-only header with its mode explanation and no empty automation disclosure"
"Patrol Inbox default queue with canonical checked-resource recency and human-readable incident titles",
"Patrol selected decision at the first and second live queue positions on desktop and mobile",
"Patrol selected detail with latest-observation age, lifecycle controls, and evidence disclosure collapsed",
"Activity with verified outcomes, separate Actions and Patrol-record handoffs, and no collapsed border artifact",
"Expanded Patrol records with explicit raw-record versus Inbox-decision relationship copy",
"Actions Open queue with Patrol origin badges and the route selected in desktop and mobile navigation",
"Actions governed review with Patrol origin repeated in the decision packet"
],
"interactions": [
"Used the live in-app Browser to switch between Inbox, Protection, and Activity",
"Selected the highest-priority decision and confirmed Assistant, Copy ID, Open resource, lifecycle controls, and progressive disclosure",
"Expanded Evidence and history and confirmed the latest evidence, older-observation reveal, protection posture, and timeline",
"Used a Protection starter outcome and confirmed the objective dialog opened with the chosen brief prefilled",
"Used Claude Code Playwright verification at desktop and phone widths to inspect layout, focus visibility, copy, console output, and dialog behaviour",
"Confirmed there are no user-visible semicolons or em dashes and no console errors at either viewport"
"Used the live in-app Browser to start review and move from Decision 1 to Decision 2 at desktop and 390-pixel phone widths",
"Confirmed previous and next controls, resource-first titles, latest-observation age, and neutral action verification framing",
"Opened Activity, inspected the collapsed handoffs, expanded Patrol records, and confirmed decision/history separation",
"Opened Actions from Patrol, inspected From Patrol badges, and opened a governed action review to confirm its Origin field",
"Verified the mobile document and body widths equal the 390-pixel viewport with no horizontal overflow",
"Confirmed the final stable Patrol and Actions pass produced no new console warnings or errors"
]
}

View file

@ -6,6 +6,7 @@ import {
formatEvidenceClass,
formatPolicyAuthority,
formatPolicyReason,
getActionOriginLabel,
getActionResourcePresentation,
verificationTruthLabel,
} from './actionPresentation';
@ -19,6 +20,7 @@ export const ActionDecisionPacket: Component<{
const result = () => props.audit.result?.actionResultV2;
const apt = () => getAPTActionPresentation(props.audit);
const firstEvidence = () => result()?.verification.evidence?.[0];
const originLabel = () => getActionOriginLabel(props.audit.origin);
const resource = createMemo(() =>
getActionResourcePresentation(props.audit.request.resourceId, props.audit.resource),
);
@ -57,6 +59,14 @@ export const ActionDecisionPacket: Component<{
<dt class="text-muted">Reason</dt>
<dd>{props.audit.request.reason}</dd>
</div>
<Show when={originLabel()}>
{(label) => (
<div>
<dt class="text-muted">Origin</dt>
<dd class="font-medium">{label()}</dd>
</div>
)}
</Show>
<Show when={props.audit.plan.preflight?.currentState}>
<div>
<dt class="text-muted">Current state</dt>

View file

@ -19,6 +19,12 @@ const audit: ActionAuditRecord = {
requestedBy: 'ui:docker-page',
},
resource: { id: 'docker:container:edge', name: 'Edge proxy', type: 'app-container' },
origin: {
surface: 'operational_trust_attention',
findingId: 'finding-1',
investigationId: 'investigation-1',
proposalId: 'proposal-1',
},
plan: {
actionId: 'action-1',
requestId: 'request-1',
@ -85,6 +91,8 @@ describe('ActionDecisionPacket', () => {
render(() => <ActionDecisionPacket audit={audit} />);
expect(screen.getByText('Edge proxy')).toBeInTheDocument();
expect(screen.getByText('docker:container:edge')).toBeInTheDocument();
expect(screen.getByText('Origin')).toBeInTheDocument();
expect(screen.getByText('From Patrol')).toBeInTheDocument();
expect(screen.getByText('Policy evidence')).toBeInTheDocument();
expect(screen.getByText(/2 authorities checked at planning/)).toBeInTheDocument();
expect(screen.getByText('Capability safety policy')).not.toBeVisible();

View file

@ -1,4 +1,5 @@
import type {
ActionAuditOrigin,
ActionAuditRecord,
ActionResourceReference,
ActionAuditState,
@ -20,6 +21,24 @@ export const formatActionName = (value: string): string =>
.trim()
.replace(/\b\w/g, (letter) => letter.toUpperCase());
export const getActionOriginLabel = (origin?: ActionAuditOrigin): string | undefined => {
const surface = origin?.surface.trim().toLowerCase();
if (!surface) return undefined;
if (
surface === 'operational_trust_attention' ||
surface === 'patrol_control' ||
surface === 'pulse_patrol' ||
surface === 'patrol'
) {
return 'From Patrol';
}
if (surface === 'pulse_assistant' || surface === 'assistant' || surface === 'chat') {
return 'From Assistant';
}
if (surface === 'mcp' || surface === 'pulse_mcp') return 'From MCP';
return 'From Pulse';
};
export interface ActionInboxStatePresentation {
accentClass: string;
label: string;

View file

@ -517,10 +517,13 @@ function AttentionList(props: {
};
return (
<div aria-live="polite">
<div>
<Show when={patrolAttentionStore.error()}>
{(message) => (
<div class="m-4 flex items-start gap-3 rounded-md border border-red-200 bg-red-50 p-4 text-red-800 dark:border-red-900 dark:bg-red-950/30 dark:text-red-200">
<div
role="alert"
class="m-4 flex items-start gap-3 rounded-md border border-red-200 bg-red-50 p-4 text-red-800 dark:border-red-900 dark:bg-red-950/30 dark:text-red-200"
>
<AlertTriangleIcon class="mt-0.5 h-5 w-5 shrink-0" aria-hidden="true" />
<div>
<h3 class="text-sm font-semibold">Patrol attention is unavailable</h3>
@ -643,7 +646,10 @@ function AttentionEmptyState(props: { hasQuietWork: boolean }) {
!patrolAttentionStore.error();
return (
<div class="flex min-h-52 flex-col items-center justify-center px-6 py-10 text-center">
<div
aria-live="polite"
class="flex min-h-52 flex-col items-center justify-center px-6 py-10 text-center"
>
<Show
when={trustworthyCalm() || props.hasQuietWork}
fallback={
@ -652,8 +658,8 @@ function AttentionEmptyState(props: { hasQuietWork: boolean }) {
<h3 class="mt-3 text-sm font-semibold text-base-content">No items in this view</h3>
<p class="mt-1 max-w-md text-xs leading-5 text-muted">
{summary()?.coverageState === 'partial'
? 'The lifecycle queue is empty, but protection context is incomplete. Pulse is not treating that gap as proof of health.'
: 'Choose another lifecycle filter or refresh the current evaluation.'}
? 'The Inbox is empty, but protection context is incomplete. Pulse is not treating that gap as proof of health.'
: 'Refresh the Inbox to check the current evaluation.'}
</p>
</>
}
@ -665,7 +671,8 @@ function AttentionEmptyState(props: { hasQuietWork: boolean }) {
? 'Patrol can continue with the current issues under this mode.'
: 'The current operational evaluation has no active items.'}
<Show when={summary()?.evaluatedAt}>
{(evaluatedAt) => ` Checked ${formatRelativeTime(evaluatedAt(), { compact: true })}.`}
{' Checked '}
{formatRelativeTime(summary()!.evaluatedAt, { compact: true })}.
</Show>
</p>
</Show>
@ -796,7 +803,7 @@ function AttentionDetail(props: {
</button>
<p class="text-[11px] font-semibold uppercase tracking-wider text-muted">
<Show when={props.queuePosition()} fallback="Decision context">
{(position) => `Decision ${position()} of ${props.queueCount()}`}
Decision {props.queuePosition()} of {props.queueCount()}
</Show>
</p>
<div class="hidden items-center gap-1 lg:flex">
@ -878,6 +885,10 @@ function AttentionDetail(props: {
<EvidenceLabel item={loaded().item} detail />
<span aria-hidden="true">·</span>
<ProtectionLabel item={loaded().item} detail />
<span aria-hidden="true">·</span>
<span>
Last seen {formatRelativeTime(loaded().item.lastObservedAt, { compact: true })}
</span>
</div>
<p class="mt-3 text-sm leading-6 text-base-content">
{loaded().item.plainLanguageSummary}
@ -1335,11 +1346,11 @@ function ActionVerificationMessage(props: { state: AttentionItem['verificationSt
case 'pending':
return 'The action is awaiting a decision, execution, or verification.';
case 'succeeded':
return 'The restart postcondition was confirmed. This issue stays open until fresh health evidence shows the container is healthy.';
return 'The action postcondition was confirmed. This issue stays open until fresh health evidence confirms recovery.';
case 'failed':
return 'The restart did not satisfy its postcondition. The issue remains open.';
return 'The action did not satisfy its postcondition. The issue remains open.';
case 'unknown':
return 'Pulse could not conclusively verify the restart. The issue remains open.';
return 'Pulse could not conclusively verify the action. The issue remains open.';
default:
return '';
}

View file

@ -195,7 +195,7 @@ export function PatrolIntelligenceSurface() {
ref={findingsPanel}
id="patrol-operational-records"
tabindex="-1"
class="rounded-lg border border-border bg-surface"
class={`rounded-lg bg-surface ${findingsOpen() ? 'border border-border' : 'border-0'}`}
open={findingsOpen()}
onToggle={(event) => setFindingsOpen(event.currentTarget.open)}
>
@ -203,6 +203,10 @@ export function PatrolIntelligenceSurface() {
<div
class={`space-y-4 border-t border-border p-4 sm:p-5 ${!state.patrolEnabledLocal() ? 'opacity-50 pointer-events-none' : ''}`}
>
<p class="text-xs leading-5 text-muted">
Patrol records keep raw check findings and run history. The Inbox separately lists
items that currently need a decision.
</p>
<PatrolIntelligenceWorkspace state={state} />
</div>
</details>

View file

@ -204,7 +204,7 @@ describe('PatrolAttentionWorkbench', () => {
));
it('renders a plain trustworthy calm state without a proof strip', async () => {
const calm = summary();
const calm = summary({ evaluatedAt: new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString() });
apiMocks.getList.mockResolvedValue(listResponse([], calm));
renderWorkbench();
@ -216,6 +216,17 @@ describe('PatrolAttentionWorkbench', () => {
).toBeInTheDocument();
expect(screen.queryByText(/trust score/i)).not.toBeInTheDocument();
expect(screen.queryByText(/auto-resolved/i)).not.toBeInTheDocument();
expect(screen.getByText(/Checked 1d ago/i)).toBeInTheDocument();
apiMocks.getList.mockResolvedValue(
listResponse(
[],
summary({ evaluatedAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString() }),
),
);
await patrolAttentionStore.load('active');
await waitFor(() => expect(screen.getByText(/Checked 10d ago/i)).toBeInTheDocument());
});
it('keeps lifecycle filters out of the primary decision surface', async () => {
@ -529,7 +540,9 @@ describe('PatrolAttentionWorkbench', () => {
summary({ activeCount: 1, openCount: 1, acknowledgedCount: 1, calm: false }),
),
);
apiMocks.getDetail.mockResolvedValueOnce(detail(active)).mockResolvedValue(detail(next));
apiMocks.getDetail.mockImplementation((itemId: string) =>
Promise.resolve(detail(itemId === active.id ? active : next)),
);
apiMocks.acknowledge.mockResolvedValue({ success: true });
renderWorkbench();
@ -541,6 +554,20 @@ describe('PatrolAttentionWorkbench', () => {
expect(await screen.findByText('Decision 1 of 2')).toBeInTheDocument();
expect(screen.getByTitle('Previous decision')).toBeDisabled();
expect(screen.getByTitle('Next decision')).toBeEnabled();
fireEvent.click(screen.getByTitle('Next decision'));
expect(
await screen.findByRole('complementary', { name: 'Database replication is delayed' }),
).toBeInTheDocument();
expect(screen.getByText('Decision 2 of 2')).toBeInTheDocument();
expect(screen.getByTitle('Previous decision')).toBeEnabled();
expect(screen.getByTitle('Next decision')).toBeDisabled();
fireEvent.click(screen.getByTitle('Previous decision'));
expect(
await screen.findByRole('complementary', { name: 'Database VM · Disk pressure' }),
).toBeInTheDocument();
expect(screen.getByText('Decision 1 of 2')).toBeInTheDocument();
fireEvent.click(await screen.findByRole('button', { name: 'Mark reviewed' }));
await waitFor(() => expect(apiMocks.acknowledge).toHaveBeenCalledWith('record-1'));
@ -732,6 +759,8 @@ describe('PatrolAttentionWorkbench', () => {
const currentTrigger = await screen.findByRole('button', { name: 'Review action' });
expect(currentTrigger).not.toBe(trigger);
expect(screen.getByText(/recorded the action result below/i)).toBeInTheDocument();
expect(screen.getByText(/action postcondition was confirmed/i)).toBeInTheDocument();
expect(screen.queryByText(/container is healthy/i)).not.toBeInTheDocument();
expect(
screen.queryByText(/explicit review and approval before Pulse sends anything/i),
).not.toBeInTheDocument();

View file

@ -23,6 +23,7 @@ import { ActionReviewDialog } from '@/features/actions/ActionReviewDialog';
import { ACTION_REVIEW_QUERY_PARAM, parseActionReviewId } from '@/features/actions/actionRouting';
import {
formatActionName,
getActionOriginLabel,
getActionInboxStatePresentation,
getActionResourcePresentation,
getActionsWatchOnlyEmptyState,
@ -256,6 +257,7 @@ export function Actions() {
const resource = () =>
getActionResourcePresentation(action.request.resourceId, action.resource);
const title = () => formatActionName(action.request.capabilityName);
const originLabel = () => getActionOriginLabel(action.origin);
return (
<li>
<button
@ -275,6 +277,13 @@ export function Actions() {
>
{state().label}
</MetadataBadge>
<Show when={originLabel()}>
{(label) => (
<MetadataBadge tone="info" size="xs" shape="rounded">
{label()}
</MetadataBadge>
)}
</Show>
<span class="truncate text-sm font-semibold text-base-content">
{title()}
</span>

View file

@ -2079,7 +2079,7 @@ describe('AIIntelligence entitlement gating', () => {
expect(screen.queryByRole('link', { name: 'Open Patrol settings' })).not.toBeInTheDocument();
});
it('does not repeat stale coverage caveats after a successful full patrol verified resources', async () => {
it('does not repeat stale coverage caveats after a successful full patrol checked resources', async () => {
hasFeatureMock.mockReturnValue(true);
licenseStatusMock.mockReturnValue({ subscription_state: 'active' });
getPatrolStatusMock.mockResolvedValue(
@ -2188,7 +2188,7 @@ describe('AIIntelligence entitlement gating', () => {
render(() => <AIIntelligence />);
await waitFor(() => {
expect(screen.getByText(/verified 58 resources/i)).toBeInTheDocument();
expect(screen.getByText(/checked 58 resources/i)).toBeInTheDocument();
});
expect(
screen.queryByText(/1 warning issue · 2 past regressions · health score 85\/100/),

View file

@ -1106,7 +1106,7 @@ describe('getPatrolRecencyPresentation — timestamp fallback logic', () => {
});
describe('getPatrolRecencyPresentation — resourcesCheckedLabel', () => {
it('uses "verified 1 resource" (singular) for a successful full run with one resource', () => {
it('uses "checked 1 resource" (singular) for a successful full run with one resource', () => {
expect(
getPatrolRecencyPresentation({
runs: [
@ -1122,7 +1122,7 @@ describe('getPatrolRecencyPresentation — resourcesCheckedLabel', () => {
label: 'Last check',
timestamp: '2026-07-10T09:05:00Z',
resourcesChecked: 1,
resourcesCheckedLabel: 'verified 1 resource',
resourcesCheckedLabel: 'checked 1 resource',
});
});

View file

@ -837,7 +837,7 @@ describe('getPatrolSummaryPresentation', () => {
});
});
it('labels completed Patrol recency as the last check', () => {
it('labels completed Patrol recency as the last check without claiming verified outcomes', () => {
expect(
getPatrolRecencyPresentation({
runs: [
@ -875,7 +875,7 @@ describe('getPatrolSummaryPresentation', () => {
label: 'Last check',
timestamp: '2026-03-12T09:57:00Z',
resourcesChecked: 58,
resourcesCheckedLabel: 'verified 58 resources',
resourcesCheckedLabel: 'checked 58 resources',
});
});

View file

@ -514,8 +514,7 @@ function formatRecencyResourcesCheckedLabel(run: PatrolRunRecord): string | unde
return undefined;
}
const verb = isFullPatrolRun(run) && !hasRunErrors(run) ? 'verified' : 'checked';
return `${verb} ${resourcesChecked} resource${resourcesChecked === 1 ? '' : 's'}`;
return `checked ${resourcesChecked} resource${resourcesChecked === 1 ? '' : 's'}`;
}
export function getPatrolAssessmentPresentation(args: {

View file

@ -478,13 +478,75 @@ func attentionTitle(alert alerts.Alert, resourceName string) string {
if alertType == "" {
return "Issue on " + resourceName
}
if incidentTitle := attentionIncidentTitle(alertType, alert.Message); incidentTitle != "" {
return incidentTitle + " on " + resourceName
}
switch strings.ToLower(alertType) {
case "usage":
if strings.EqualFold(attentionResourceType(alert), "storage") {
return "Storage usage on " + resourceName
}
return "Resource usage on " + resourceName
case "cpu":
return "CPU usage on " + resourceName
case "disk":
return "Disk usage on " + resourceName
case "memory":
return "Memory usage on " + resourceName
}
words := strings.Fields(strings.NewReplacer("-", " ", "_", " ").Replace(alertType))
for i := range words {
words[i] = strings.ToUpper(words[i][:1]) + words[i][1:]
word := strings.ToLower(words[i])
switch word {
case "api", "cpu", "dns", "ip", "vm", "zfs":
words[i] = strings.ToUpper(word)
case "io":
words[i] = "I/O"
default:
words[i] = word
if i == 0 {
words[i] = strings.ToUpper(word[:1]) + word[1:]
}
}
}
return strings.Join(words, " ") + " on " + resourceName
}
func attentionIncidentTitle(alertType, message string) string {
normalizedType := strings.NewReplacer("-", "_", " ", "_").Replace(
strings.ToLower(strings.TrimSpace(alertType)),
)
if normalizedType != "resource_incident" && normalizedType != "storage_incident" {
return ""
}
if strings.Contains(strings.ToLower(message), "pg degraded") {
return "Ceph placement groups degraded"
}
const vmwareAlarmMarker = " has VMware alarm "
if markerIndex := strings.Index(message, vmwareAlarmMarker); markerIndex >= 0 {
issue := strings.TrimSpace(message[markerIndex+len(vmwareAlarmMarker):])
for _, suffix := range []string{" (", ". Affects "} {
if suffixIndex := strings.Index(issue, suffix); suffixIndex >= 0 {
issue = issue[:suffixIndex]
}
}
if issue = strings.TrimSpace(strings.TrimSuffix(issue, ".")); issue != "" {
return issue
}
}
if strings.Contains(message, " has VMware overall status ") {
if normalizedType == "storage_incident" {
return "VMware datastore health"
}
return "VMware host health"
}
if normalizedType == "storage_incident" {
return "Storage issue"
}
return "Infrastructure issue"
}
func attentionResourceType(alert alerts.Alert) string {
if alert.Metadata != nil {
if value, ok := alert.Metadata["resourceType"].(string); ok {

View file

@ -242,6 +242,66 @@ func TestProjectAttentionItemsProducesCurrentCalmEvaluation(t *testing.T) {
}
}
func TestAttentionTitleProducesHumanReadableLabels(t *testing.T) {
tests := []struct {
name string
alertType string
message string
resourceName string
want string
}{
{
name: "VMware alarm uses the actual alarm",
alertType: "storage-incident",
message: "Datastore datastore-202 has VMware alarm Datastore latency above threshold (yellow). Affects 2 dependent resources",
resourceName: "archive-tier",
want: "Datastore latency above threshold on archive-tier",
},
{
name: "VMware host status uses product language",
alertType: "resource-incident",
message: "Host has VMware overall status yellow",
resourceName: "esxi-07.lab.local",
want: "VMware host health on esxi-07.lab.local",
},
{
name: "generic incident avoids incident placeholder",
alertType: "resource_incident",
message: "A monitored resource needs attention",
resourceName: "router-1",
want: "Infrastructure issue on router-1",
},
{
name: "Ceph incident names the degraded placement group",
alertType: "resource-incident",
message: "1 PG degraded",
resourceName: "Mock Cluster Ceph",
want: "Ceph placement groups degraded on Mock Cluster Ceph",
},
{
name: "acronyms stay readable",
alertType: "high_cpu_and_zfs_io",
resourceName: "database-1",
want: "High CPU and ZFS I/O on database-1",
},
{
name: "one-word disk type states what is measured",
alertType: "disk",
resourceName: "pve1",
want: "Disk usage on pve1",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got := attentionTitle(alerts.Alert{Type: test.alertType, Message: test.message}, test.resourceName)
if got != test.want {
t.Fatalf("attentionTitle() = %q, want %q", got, test.want)
}
})
}
}
func attentionTestAlert(
id string,
state operationaltrust.OperationalState,

View file

@ -11,6 +11,7 @@ const (
func IncidentActionForResource(resource *Resource, incident ResourceIncident, category string) (string, string) {
baseType := resourceBaseType(resource)
storageResource := isStorageIncidentResource(baseType)
switch incident.Code {
case "zfs_pool_state":
if incident.Severity == storagehealth.RiskCritical {
@ -55,7 +56,7 @@ func IncidentActionForResource(resource *Resource, incident ResourceIncident, ca
}
return IncidentUrgencyToday, "Investigate disk health and schedule replacement if degradation continues"
case IncidentCategoryAvailability:
if baseType == ResourceTypeAgent || baseType == ResourceTypeVM {
if !storageResource {
if incident.Severity == storagehealth.RiskCritical {
return IncidentUrgencyNow, "Restore resource availability immediately"
}
@ -71,7 +72,7 @@ func IncidentActionForResource(resource *Resource, incident ResourceIncident, ca
}
return IncidentUrgencyToday, "Confirm whether the app should be running, then start it or suppress the alert for intentional downtime"
default:
if baseType == ResourceTypeAgent || baseType == ResourceTypeVM {
if !storageResource {
if incident.Severity == storagehealth.RiskCritical {
return IncidentUrgencyToday, "Investigate resource health immediately"
}
@ -83,3 +84,16 @@ func IncidentActionForResource(resource *Resource, incident ResourceIncident, ca
return IncidentUrgencyPlan, "Review storage health and plan corrective action"
}
}
func isStorageIncidentResource(resourceType ResourceType) bool {
switch resourceType {
case ResourceTypeStorage,
ResourceTypePhysicalDisk,
ResourceTypeNetworkShare,
ResourceTypePBS,
ResourceTypeCeph:
return true
default:
return false
}
}

View file

@ -6175,3 +6175,25 @@ func TestRegistryResourcesReportPlatformAdmission(t *testing.T) {
t.Fatalf("no other platform should be admitted, got %+v", admission)
}
}
func TestIncidentActionFallbackKeepsNetworkAndStorageGuidanceDistinct(t *testing.T) {
warning := ResourceIncident{Code: "generic", Severity: storagehealth.RiskWarning}
_, networkAction := IncidentActionForResource(
&Resource{ID: "network-1", Type: ResourceTypeNetwork},
warning,
IncidentCategoryHealth,
)
if networkAction != "Review resource health and plan corrective action" {
t.Fatalf("network action = %q, want neutral resource guidance", networkAction)
}
_, storageAction := IncidentActionForResource(
&Resource{ID: "storage-1", Type: ResourceTypeStorage},
warning,
IncidentCategoryHealth,
)
if storageAction != "Review storage health and plan corrective action" {
t.Fatalf("storage action = %q, want storage guidance", storageAction)
}
}