diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 09014865f..8278f7e48 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,25 +1,27 @@ { "version": 1, - "base_sha": "3adeb77d60620cdcc4c71431685725384bdc562a", - "verified_at": "2026-08-12T08:19:01Z", + "base_sha": "929c03b4906590fa12b7d7ce1d63e81ab384854b", + "verified_at": "2026-08-13T20:53:32Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/api/settings.ts", - "frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts", - "frontend-modern/src/components/Settings/useSystemSettingsState.ts" + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx", + "frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx", + "frontend-modern/src/routing/resourceLinks.ts" ], "content_sha256": { - "frontend-modern/src/api/settings.ts": "cb95289d5df7cd9332cc78573a5c6052f8968cc0fbb2d66c15c8ddc49f44ab62", - "frontend-modern/src/components/Settings/useInfrastructureSettingsState.ts": "528b32af6b1c777ed87f5dd2d5b15b531f830c520b0f624acfc5a2d75ad3e314", - "frontend-modern/src/components/Settings/useSystemSettingsState.ts": "acb66acc4a0254a6816564713f65a1ed5cf723eb526e4960076b4c84335e2c09" + "frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx": "34d7d90cfb0a0a6405346115393688a8db89be602e4112512a61fce7f17237f2", + "frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx": "396357b7930259e4be0947e97264d5311956c118295c10d0daaa2f59be89e44c", + "frontend-modern/src/features/patrol/__tests__/PatrolAttentionWorkbench.test.tsx": "d809cd5ee80973042e8c703e746a392ee0235d3868a0177ff8e874c87d7b811e", + "frontend-modern/src/routing/resourceLinks.ts": "9b44383c2061a6ec4f2e49a124be766a2d7294862dd32f3343bde1199e690030" }, "routes": [ - "/settings/system-general" + "/patrol" ], "viewports": [ { - "width": 1280, - "height": 720 + "width": 1311, + "height": 1234 }, { "width": 375, @@ -27,21 +29,16 @@ } ], "states": [ - "Admin session Monitoring Cadence card at the fresh-install Realtime (10s) default", - "Admin card after selecting the Low (60s) preset and saving (GET /api/system/settings confirmed pvePollingInterval 60)", - "Non-admin session (persisted session cookie whose user no longer matches the configured admin) with GET /api/system/settings returning 403 while GET /api/runtime/display returned pvePollingInterval 60: card showed Current cadence: 60 seconds with Low (60s) pressed instead of the old Realtime fallback", - "Admin card after switching to Custom 45s and saving (GET /api/system/settings confirmed 45)", - "Non-admin card after reload showing Current cadence: 45 seconds with Custom pressed and 45 in the custom input", - "Same non-admin Custom 45s state re-exercised at the 375px mobile viewport", - "Admin session reload showing Custom 45s restored through the refactored preset-resolution path" + "Needs attention list with 10 active mock items", + "Attention detail open with Lifecycle controls and the new durable-options guidance", + "Findings disclosure collapsed with '5 open' badge on the summary", + "Findings disclosure expanded showing the Open work findings list", + "Expanded finding Manage menu listing Remember as expected and dismiss actions" ], "interactions": [ - "Built the sidecar backend and ran it as a scratch instance; logged in as admin on the pulse-1601-admin Vite frontend", - "Selected Low (60s) on the Monitoring Cadence card and saved; verified the effective interval via the settings and runtime-display APIs", - "Restarted the backend with a different configured admin username so the persisted browser session became a genuine non-admin session (settings 403, security-status capabilities all false)", - "Reloaded /settings/system-general in that non-admin session and confirmed the cadence card rendered the server's real 60s interval from the runtime-display projection", - "Logged in as the new admin in a separate cookie jar, set a non-preset Custom 45s cadence, and saved", - "Reloaded the non-admin session and confirmed Custom 45s displayed, then repeated the check at the 375px mobile viewport", - "Reloaded the admin session to confirm the shared preset-resolution refactor still renders the saved Custom 45s state" + "Clicked an attention item to open the detail panel (desktop and 375px mobile)", + "Verified 'adjust alert thresholds' renders as a link with href /alerts/thresholds", + "Clicked 'review Patrol findings' on both viewports: disclosure opened and scrolled into view", + "Expanded a finding row and opened its Manage menu to confirm Remember as expected is reachable" ] } diff --git a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx index 427108c02..da6787450 100644 --- a/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx +++ b/frontend-modern/src/features/patrol/PatrolAttentionWorkbench.tsx @@ -1,4 +1,4 @@ -import { useLocation } from '@solidjs/router'; +import { A, useLocation } from '@solidjs/router'; import { createEffect, createMemo, @@ -46,6 +46,7 @@ import { import { aiChatStore } from '@/stores/aiChat'; import { patrolAttentionStore } from '@/stores/patrolAttention'; import { + ALERT_THRESHOLDS_PATH, buildPatrolAttentionPath, buildStandalonePath, buildWorkloadsRouteSearch, @@ -67,7 +68,7 @@ const FILTERS: Array<{ id: AttentionFilter; label: string }> = [ { id: 'resolved', label: 'Recent resolved' }, ]; -export function PatrolAttentionWorkbench() { +export function PatrolAttentionWorkbench(props: { onOpenFindings?: () => void } = {}) { const location = useLocation(); const [selectedItemId, setSelectedItemId] = createSignal(''); const [actionDetail, setActionDetail] = createSignal(null); @@ -306,6 +307,7 @@ export function PatrolAttentionWorkbench() { changeLifecycle(() => suppressPatrolAttention(itemId, reason, expiresAt)) } onUnsuppress={(itemId) => changeLifecycle(() => unsuppressPatrolAttention(itemId))} + onOpenFindings={props.onOpenFindings} /> @@ -466,6 +468,7 @@ function AttentionDetail(props: { onUnacknowledge: (itemId: string) => Promise; onSuppress: (itemId: string, reason: string, expiresAt: string) => Promise; onUnsuppress: (itemId: string) => Promise; + onOpenFindings?: () => void; }) { const detail = () => props.detail; const item = () => detail()?.item; @@ -597,6 +600,7 @@ function AttentionDetail(props: { onUnacknowledge={props.onUnacknowledge} onSuppress={props.onSuppress} onUnsuppress={props.onUnsuppress} + onOpenFindings={props.onOpenFindings} /> @@ -810,6 +814,7 @@ function AttentionLifecycleControls(props: { onUnacknowledge: (itemId: string) => Promise; onSuppress: (itemId: string, reason: string, expiresAt: string) => Promise; onUnsuppress: (itemId: string) => Promise; + onOpenFindings?: () => void; }) { const [showSuppression, setShowSuppression] = createSignal(false); const [reason, setReason] = createSignal(''); @@ -975,6 +980,30 @@ function AttentionLifecycleControls(props: { {props.error}

+
+

These controls cover only this occurrence, and suppression always expires.

+

+ For a permanent change,{' '} + + adjust alert thresholds + {' '} + to change or turn off this alert for the affected resource + + , or{' '} + {' '} + to mark a finding as expected so Patrol stops raising it. + +

+
); diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx index dc68eed4b..7241a5b78 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx @@ -1,3 +1,6 @@ +import { createSignal, Show } from 'solid-js'; +import { MetadataBadge } from '@/components/shared/MetadataBadge'; +import { aiIntelligenceStore } from '@/stores/aiIntelligence'; import { usePatrolIntelligenceState } from './usePatrolIntelligenceState'; import { PatrolIntelligenceHeader } from './PatrolIntelligenceHeader'; import { PatrolIntelligenceBanners } from './PatrolIntelligenceBanners'; @@ -6,16 +9,45 @@ import { PatrolAttentionWorkbench } from './PatrolAttentionWorkbench'; export function PatrolIntelligenceSurface() { const state = usePatrolIntelligenceState(); + const [findingsOpen, setFindingsOpen] = createSignal(false); + let findingsSummary: HTMLElement | undefined; + const openWorkCount = () => aiIntelligenceStore.patrolOpenWorkCount; + const openFindings = () => { + setFindingsOpen(true); + queueMicrotask(() => { + findingsSummary?.scrollIntoView?.({ block: 'start' }); + findingsSummary?.focus?.({ preventScroll: true }); + }); + }; return (
- + -
- - Patrol checks, investigations, and run history +
setFindingsOpen(event.currentTarget.open)} + > + + + Patrol checks, investigations, and run history + 0}> + + {openWorkCount()} open + + +
{ const renderWorkbench = () => render(() => ( - + } /> )); @@ -471,6 +471,56 @@ describe('PatrolAttentionWorkbench', () => { expect(screen.getByRole('button', { name: 'Return to active attention' })).toBeInTheDocument(); }); + it('points lifecycle users at the durable threshold and finding controls', async () => { + const active = item(); + apiMocks.getList.mockResolvedValue( + listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })), + ); + apiMocks.getDetail.mockResolvedValue(detail(active)); + const onOpenFindings = vi.fn(); + render(() => ( + + } /> + + )); + + fireEvent.click( + await screen.findByRole('button', { + name: 'Open Disk pressure on Database VM', + }), + ); + + expect( + await screen.findByText(/These controls cover only this occurrence/i), + ).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'adjust alert thresholds' })).toHaveAttribute( + 'href', + '/alerts/thresholds', + ); + fireEvent.click(screen.getByRole('button', { name: 'review Patrol findings' })); + expect(onOpenFindings).toHaveBeenCalledTimes(1); + }); + + it('omits the findings pointer when no findings surface is wired', async () => { + const active = item(); + apiMocks.getList.mockResolvedValue( + listResponse([active], summary({ activeCount: 1, openCount: 1, calm: false })), + ); + apiMocks.getDetail.mockResolvedValue(detail(active)); + renderWorkbench(); + + fireEvent.click( + await screen.findByRole('button', { + name: 'Open Disk pressure on Database VM', + }), + ); + + expect( + await screen.findByText(/These controls cover only this occurrence/i), + ).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'review Patrol findings' })).not.toBeInTheDocument(); + }); + it('opens the canonical governed action review from an eligible attention item', async () => { const actionOffer = { targetResourceId: 'docker:host-1/container-1', diff --git a/frontend-modern/src/routing/resourceLinks.ts b/frontend-modern/src/routing/resourceLinks.ts index 832afc283..a88313f45 100644 --- a/frontend-modern/src/routing/resourceLinks.ts +++ b/frontend-modern/src/routing/resourceLinks.ts @@ -41,6 +41,7 @@ export const TRUENAS_PATH = '/truenas'; export const TRUENAS_DEFAULT_TAB = 'overview'; export const VMWARE_PATH = '/vmware'; export const VMWARE_DEFAULT_TAB = 'overview'; +export const ALERT_THRESHOLDS_PATH = '/alerts/thresholds'; export const PMG_THRESHOLDS_PATH = '/alerts/thresholds/mail-gateway'; export const PATROL_PATH = '/patrol'; export const PATROL_ATTENTION_QUERY_PARAM = 'attention';