mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-08-20 14:33:30 +00:00
fix(patrol): clarify decisions and action origins
This commit is contained in:
parent
ac4116e09b
commit
bcea56db97
21 changed files with 342 additions and 59 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 '';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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/),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue