diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index de5c557cd..b2aedfc9f 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -1092,6 +1092,11 @@ the canonical monitored-system blocked payload. and the Patrol findings empty-state behavior, so `0 active findings` only renders as a healthy frontend conclusion when the same governed AI summary contract still reports healthy overall health; degraded or not-fully-verified health predictions must flow through to the Patrol findings surface instead of being replaced by page-local "looks healthy" copy and the Patrol assessment headline plus compact summary-strip behavior, so the same governed AI summary contract decides whether the page leads with verified health, issues detected, coverage incomplete, or another attention state instead of letting count-only page fragments emit a stale `No issues found` conclusion and the Patrol summary shell treatment itself, so the same governed summary contract still lands inside the shared neutral page-card base while severity travels through compact header accents and icon badges instead of a page-local full-width semantic background + and the Patrol summary/workspace badge treatment, so the API-owned finding, + runtime, and run-history counts remain semantic input only while visible + state and count badges route through frontend-primitives-owned + `StatusIndicatorBadge` and `MetadataBadge` instead of page-local class + strings in the Patrol section owners and the Patrol verification summary derived from run history, so the page also states whether recent Patrol evidence came from a successful full patrol or only from scoped/erroring runs instead of leaving verification scope implicit and the same-day activity-mix explanation derived from that governed run history, so when a recent full patrol is followed by alert-triggered or anomaly-triggered scoped work the verification surface can explain the mix directly instead of reconstructing it from page-local timing heuristics and the Patrol status recency split, so `last_patrol_at` remains reserved for completed full Patrol sweeps while scoped runs and verification checks advance `last_activity_at` without claiming a fresh full-estate verification pass diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index b969d5e6f..40e614233 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -498,10 +498,11 @@ not a replacement status card, CTA band, or page-local nested card. `OrganizationRoleBadge` and `OrganizationShareStatusBadge`, so role/status tone mapping, pill shape, fit behavior, and whitespace handling do not drift across organization overview, access, and sharing surfaces. - Patrol run-history resource, outcome, snapshot, and scoped-run chips must - also compose `MetadataBadge`; Patrol remains the label/count/semantics - owner, but the visible badge shell, sizing, tone vocabulary, and whitespace - behavior stay in the shared primitive and `shared-template-registry.json`. + Patrol run-history/status-bar resource, outcome, snapshot, scoped-run, + workspace-tab count, and contextual metadata chips must also compose + `MetadataBadge`; Patrol remains the label/count/semantics owner, but the + visible badge shell, sizing, tone vocabulary, and whitespace behavior stay in + the shared primitive and `shared-template-registry.json`. Inline detail content belongs to the shared detail-section primitive family. Feature surfaces may own the platform-specific rows, section labels, and source model, but section row shaping, empty-row compaction, value tone @@ -2363,10 +2364,14 @@ presentation for product surfaces that display state rather than toggle it. Product components must compose `StatusIndicatorBadge` instead of calling `getStatusIndicatorBadgeToneClasses` directly; low-level status utilities may still expose the tone mapping for that primitive and utility-level tests. -Patrol run-history status labels follow this state-badge boundary: Patrol may -derive the status label and typed variant in `patrolRunPresentation.ts`, but -`RunHistoryEntry.tsx` must render the visible state badge through -`StatusIndicatorBadge` rather than `runStatus.badgeClass` or a local span. +Patrol run-history, status-bar, and runtime-summary status labels follow this +state-badge boundary: Patrol may derive the status label and typed variant in +`patrolRunPresentation.ts` or `patrolSummaryPresentation.ts`, but +`RunHistoryEntry.tsx`, `PatrolStatusBar.tsx`, and +`PatrolIntelligenceSummary.tsx` must render visible state badges through +`StatusIndicatorBadge` rather than `runStatus.badgeClass`, +`lastRunStatus.badgeClass`, `runtimeShellPresentation().badgeClass`, or a local +span. The shared segmented selector now follows that same owner split. `frontend-modern/src/components/shared/FilterButtonGroup.tsx` stays the render shell, `frontend-modern/src/components/shared/useFilterButtonGroupState.ts` diff --git a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md index 0e27e7455..ba7459ff2 100644 --- a/docs/release-control/v6/internal/subsystems/patrol-intelligence.md +++ b/docs/release-control/v6/internal/subsystems/patrol-intelligence.md @@ -57,13 +57,14 @@ Patrol-specific presentation helpers. ## Extension Points 1. Add or change Patrol page orchestration through `frontend-modern/src/features/patrol/usePatrolIntelligenceState.ts`, keep `frontend-modern/src/features/patrol/patrolInvestigationContextModel.ts` as the canonical investigation-context derivation owner, keep `frontend-modern/src/features/patrol/PatrolIntelligenceSurface.tsx` as the feature shell, keep the Patrol-owned section files under `frontend-modern/src/features/patrol/` as the heavy render owners, keep `frontend-modern/src/pages/AIIntelligence.tsx` as the route shell, keep `frontend-modern/src/stores/aiIntelligenceSummaryModel.ts` as the canonical AI summary normalization owner, and update `frontend-modern/src/stores/aiIntelligence.ts` together -2. Add or change Patrol findings, approvals, investigation, or run-history presentation through `frontend-modern/src/components/AI/FindingsPanel.tsx` and `frontend-modern/src/components/patrol/` +2. Add or change Patrol findings, approvals, investigation, status-bar, or run-history presentation through `frontend-modern/src/components/AI/FindingsPanel.tsx` and `frontend-modern/src/components/patrol/` Patrol owns run-history labels, counts, status variants, and domain copy, but - visible run-history status badges must compose the shared - `StatusIndicatorBadge`, and resource/outcome/snapshot/scoped-run metadata - chips must compose the shared `MetadataBadge`. If Patrol needs a new badge - shape or tone, extend the shared primitive and registry guard instead of - adding page-local rounded pill spans. + visible run-history, status-bar, and runtime-summary state badges must + compose the shared `StatusIndicatorBadge`, and + resource/outcome/snapshot/scoped-run/status-bar/workspace-tab metadata chips + must compose the shared `MetadataBadge`. If Patrol needs a new badge shape + or tone, extend the shared primitive and registry guard instead of adding + page-local rounded pill spans. 3. Keep remediation execution badge copy and severity styling aligned through `frontend-modern/src/components/patrol/RemediationStatus.tsx` and `frontend-modern/src/utils/remediationPresentation.ts` 4. Add or change Patrol header, summary, status runtime-state presentation, or runtime provider action presentation through `frontend-modern/src/features/patrol/PatrolIntelligenceHeader.tsx`, `frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx`, `frontend-modern/src/components/patrol/PatrolStatusBar.tsx`, `frontend-modern/src/utils/patrolRuntimePresentation.ts`, and `frontend-modern/src/utils/patrolRuntimeActions.ts` Patrol summary presentation may show recent activity mix and trigger mode as @@ -81,7 +82,7 @@ Patrol-specific presentation helpers. 1. Reintroducing Patrol finding, investigation, approval, or run-history copy directly inside page components when canonical Patrol presentation helpers already own it 2. Duplicating Patrol finding severity, lifecycle, alert-identity, or approval-risk derivation outside the governed Patrol presentation helpers 3. Letting the Patrol page, local store, and findings UI drift into separate shadow truths for the same Patrol status or finding lifecycle state -4. Recreating Patrol run-history status or metadata badge shells locally instead of composing `StatusIndicatorBadge` and `MetadataBadge` +4. Recreating Patrol run-history, status-bar, runtime-summary, or workspace-tab status/metadata badge shells locally instead of composing `StatusIndicatorBadge` and `MetadataBadge` ## Completion Obligations diff --git a/docs/release-control/v6/internal/subsystems/unified-resources.md b/docs/release-control/v6/internal/subsystems/unified-resources.md index 8e7087cb3..850f78814 100644 --- a/docs/release-control/v6/internal/subsystems/unified-resources.md +++ b/docs/release-control/v6/internal/subsystems/unified-resources.md @@ -225,6 +225,11 @@ Platform filter option semantics follow that split too: unified-resource consumers own the source-specific status buckets and labels, while the repeated FilterBar chip leading-dot presentation must use the frontend-primitives-owned `filterChipStatusDot` helper instead of page-local span factories. +Platform resource status ranking follows the same split. Docker / Podman and +Kubernetes page models own their source-specific attention ordering, but any +rank table over shared `StatusIndicatorVariant` values must include the full +shared variant vocabulary, including informational states, instead of assuming +only success/warning/danger/muted status classes. Platform table empty states use that same split. Unified-resource consumers own the source-specific empty-state vocabulary, action choice, and evidence rule that decides why a table is empty, but the table-card empty-state shell itself diff --git a/frontend-modern/scripts/shared-template-registry.json b/frontend-modern/scripts/shared-template-registry.json index b7dc15a01..92bcf5099 100644 --- a/frontend-modern/scripts/shared-template-registry.json +++ b/frontend-modern/scripts/shared-template-registry.json @@ -544,12 +544,28 @@ "requiredConsumers": [ { "path": "src/components/Settings/AgentProfilesPanel.tsx" }, { "path": "src/components/Settings/DiagnosticsResultsPanel.tsx" }, - { "path": "src/components/patrol/RunHistoryEntry.tsx" } + { "path": "src/components/patrol/RunHistoryEntry.tsx" }, + { "path": "src/components/patrol/PatrolStatusBar.tsx" }, + { "path": "src/features/patrol/PatrolIntelligenceSummary.tsx" } ], "forbiddenPatterns": [ { "path": "src/components/patrol/RunHistoryEntry.tsx", "patterns": ["runStatus.badgeClass"] + }, + { + "path": "src/components/patrol/PatrolStatusBar.tsx", + "patterns": [ + "lastRunStatus.badgeClass", + "rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700" + ] + }, + { + "path": "src/features/patrol/PatrolIntelligenceSummary.tsx", + "patterns": [ + "runtimeShellPresentation().badgeClass", + "inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]" + ] } ], "proof": [ @@ -568,7 +584,9 @@ }, "requiredConsumers": [ { "path": "src/components/shared/OrganizationBadges.tsx" }, - { "path": "src/components/patrol/RunHistoryEntry.tsx" } + { "path": "src/components/patrol/RunHistoryEntry.tsx" }, + { "path": "src/components/patrol/PatrolStatusBar.tsx" }, + { "path": "src/features/patrol/PatrolIntelligenceWorkspace.tsx" } ], "forbiddenPatterns": [ { @@ -577,6 +595,18 @@ "inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium", "inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-blue-50" ] + }, + { + "path": "src/components/patrol/PatrolStatusBar.tsx", + "patterns": ["text-blue-600 dark:text-blue-400\">Findings snapshot unavailable"] + }, + { + "path": "src/features/patrol/PatrolIntelligenceWorkspace.tsx", + "patterns": [ + "findingsBadgePresentation().toneClasses", + "ml-1.5 px-1.5 py-0.5 text-xs rounded-full", + "bg-surface-alt text-muted" + ] } ], "proof": [ diff --git a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts index 70672e6cd..c2c23e508 100644 --- a/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts +++ b/frontend-modern/src/components/AI/__tests__/FindingsPanel.test.ts @@ -394,7 +394,7 @@ describe('aiFindingPresentation', () => { }, ]), ).toEqual({ - toneClasses: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300', + tone: 'info', }); }); @@ -410,7 +410,7 @@ describe('aiFindingPresentation', () => { }, ]), ).toEqual({ - toneClasses: getFindingSeverityToneClasses('warning'), + tone: 'warning', }); }); }); @@ -759,7 +759,9 @@ describe('aiFindingPresentation', () => { it('routes the findings tab badge tone through the shared patrol findings badge helper', () => { expect(patrolWorkspaceSource).toContain('getPatrolFindingsBadgePresentation'); expect(patrolWorkspaceSource).toContain('state.findingsTabBadgeFindings()'); - expect(patrolWorkspaceSource).toContain('findingsBadgePresentation().toneClasses'); + expect(patrolWorkspaceSource).toContain('MetadataBadge'); + expect(patrolWorkspaceSource).toContain('findingsBadgePresentation().tone'); + expect(patrolWorkspaceSource).not.toContain('findingsBadgePresentation().toneClasses'); }); it('does not stack a detected loop-state badge on top of acknowledged active findings', () => { diff --git a/frontend-modern/src/components/patrol/PatrolStatusBar.tsx b/frontend-modern/src/components/patrol/PatrolStatusBar.tsx index 563ec20a0..7a73a5052 100644 --- a/frontend-modern/src/components/patrol/PatrolStatusBar.tsx +++ b/frontend-modern/src/components/patrol/PatrolStatusBar.tsx @@ -12,6 +12,8 @@ import { type PatrolRuntimeState, type PatrolTriggerStatus, } from '@/api/patrol'; +import { MetadataBadge } from '@/components/shared/MetadataBadge'; +import { StatusIndicatorBadge } from '@/components/shared/StatusIndicatorBadge'; import { aiIntelligenceStore } from '@/stores/aiIntelligence'; import { formatRelativeTime } from '@/utils/format'; import { formatTriggerReason } from '@/utils/patrolFormat'; @@ -133,9 +135,12 @@ export const PatrolStatusBar: Component = (props) => { Recent activity - - Run in progress - + } @@ -184,13 +189,18 @@ export const PatrolStatusBar: Component = (props) => { Latest: {resolvedStats()!.lastRunTypeLabel} ·{' '} - - {resolvedStats()!.lastRunStatus.label} - + {' '} ·{' '} - Findings snapshot unavailable + + Findings snapshot unavailable + diff --git a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts index 3ac26364f..c3f3d90d2 100644 --- a/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts +++ b/frontend-modern/src/components/shared/SharedPrimitives.guardrails.test.ts @@ -205,8 +205,10 @@ import infrastructureSourcePickerSource from '@/components/Settings/Infrastructu import resourcePickerSource from '@/components/Settings/ResourcePicker.tsx?raw'; import settingsPageShellSource from '@/components/Settings/SettingsPageShell.tsx?raw'; import patrolIntelligenceHeaderSource from '@/features/patrol/PatrolIntelligenceHeader.tsx?raw'; +import patrolIntelligenceSummarySource from '@/features/patrol/PatrolIntelligenceSummary.tsx?raw'; import patrolIntelligenceWorkspaceSource from '@/features/patrol/PatrolIntelligenceWorkspace.tsx?raw'; import runHistoryEntrySource from '@/components/patrol/RunHistoryEntry.tsx?raw'; +import patrolStatusBarSource from '@/components/patrol/PatrolStatusBar.tsx?raw'; import filterBarSource from '@/components/shared/FilterBar/FilterBar.tsx?raw'; import filterChipSource from '@/components/shared/FilterBar/FilterChip.tsx?raw'; import featureGateSectionSource from '@/components/shared/FeatureGateSection.tsx?raw'; @@ -1535,6 +1537,8 @@ describe('shared primitive guardrails', () => { 'src/components/Settings/AgentProfilesPanel.tsx', 'src/components/Settings/DiagnosticsResultsPanel.tsx', 'src/components/patrol/RunHistoryEntry.tsx', + 'src/components/patrol/PatrolStatusBar.tsx', + 'src/features/patrol/PatrolIntelligenceSummary.tsx', ]); expect(registeredGuard?.canonical?.path).toBe('src/components/shared/StatusIndicatorBadge.tsx'); expect(registeredGuard?.canonical?.export).toBe('StatusIndicatorBadge'); @@ -1556,6 +1560,16 @@ describe('shared primitive guardrails', () => { expect(diagnosticsResultsPanelSource).not.toContain('getStatusIndicatorBadgeToneClasses'); expect(runHistoryEntrySource).toContain('StatusIndicatorBadge'); expect(runHistoryEntrySource).not.toContain('runStatus.badgeClass'); + expect(patrolStatusBarSource).toContain('StatusIndicatorBadge'); + expect(patrolStatusBarSource).not.toContain('lastRunStatus.badgeClass'); + expect(patrolStatusBarSource).not.toContain( + 'rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700', + ); + expect(patrolIntelligenceSummarySource).toContain('StatusIndicatorBadge'); + expect(patrolIntelligenceSummarySource).not.toContain('runtimeShellPresentation().badgeClass'); + expect(patrolIntelligenceSummarySource).not.toContain( + 'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]', + ); }); it('keeps metadata badges on shared badge primitives', () => { @@ -1594,6 +1608,8 @@ describe('shared primitive guardrails', () => { expect(metadataRule?.requiredConsumers?.map((consumer) => consumer.path)).toEqual([ 'src/components/shared/OrganizationBadges.tsx', 'src/components/patrol/RunHistoryEntry.tsx', + 'src/components/patrol/PatrolStatusBar.tsx', + 'src/features/patrol/PatrolIntelligenceWorkspace.tsx', ]); expect(roleRule?.canonical?.path).toBe('src/components/shared/OrganizationBadges.tsx'); expect(roleRule?.canonical?.export).toBe('OrganizationRoleBadge'); @@ -1658,6 +1674,18 @@ describe('shared primitive guardrails', () => { expect(runHistoryEntrySource).not.toContain( 'inline-flex items-center gap-1 px-1.5 py-0.5 rounded bg-blue-50', ); + expect(patrolStatusBarSource).toContain('MetadataBadge'); + expect(patrolStatusBarSource).not.toContain( + 'text-blue-600 dark:text-blue-400">Findings snapshot unavailable', + ); + expect(patrolIntelligenceWorkspaceSource).toContain('MetadataBadge'); + expect(patrolIntelligenceWorkspaceSource).toContain('findingsBadgePresentation().tone'); + expect(patrolIntelligenceWorkspaceSource).not.toContain( + 'findingsBadgePresentation().toneClasses', + ); + expect(patrolIntelligenceWorkspaceSource).not.toContain( + 'ml-1.5 px-1.5 py-0.5 text-xs rounded-full', + ); }); it('keeps resource status dots on the shared StatusDot primitive', () => { diff --git a/frontend-modern/src/components/shared/StatusDot.tsx b/frontend-modern/src/components/shared/StatusDot.tsx index 2b2674b9e..76bd3ec26 100644 --- a/frontend-modern/src/components/shared/StatusDot.tsx +++ b/frontend-modern/src/components/shared/StatusDot.tsx @@ -17,6 +17,7 @@ const VARIANT_CLASSES: Record = { success: 'bg-emerald-500 dark:bg-emerald-400', warning: 'bg-amber-500 dark:bg-amber-400', danger: 'bg-red-500 dark:bg-red-400', + info: 'bg-blue-500 dark:bg-blue-400', muted: 'bg-slate-400', }; diff --git a/frontend-modern/src/components/shared/StatusIndicatorBadge.test.tsx b/frontend-modern/src/components/shared/StatusIndicatorBadge.test.tsx index 2631810ab..4c32be1b5 100644 --- a/frontend-modern/src/components/shared/StatusIndicatorBadge.test.tsx +++ b/frontend-modern/src/components/shared/StatusIndicatorBadge.test.tsx @@ -19,12 +19,7 @@ describe('StatusIndicatorBadge', () => { it('renders custom state labels without forcing a dot or uppercase text', () => { render(() => ( - + )); const badge = screen.getByText('Cooldown: Missing'); @@ -34,4 +29,16 @@ describe('StatusIndicatorBadge', () => { expect(badge.querySelector('[aria-hidden="true"]')).toBeNull(); expect(badge).not.toHaveClass('uppercase'); }); + + it('renders informational state badges through the shared variant catalog', () => { + render(() => ( + + )); + + const badge = screen.getByText('Run in progress'); + expect(badge).toHaveClass('rounded'); + expect(badge).toHaveClass('text-[10px]'); + expect(badge).toHaveClass('bg-blue-100'); + expect(badge).toHaveClass('text-blue-700'); + }); }); diff --git a/frontend-modern/src/components/shared/__tests__/StatusDot.test.tsx b/frontend-modern/src/components/shared/__tests__/StatusDot.test.tsx index 7ec29a0ab..409dede33 100644 --- a/frontend-modern/src/components/shared/__tests__/StatusDot.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/StatusDot.test.tsx @@ -7,13 +7,7 @@ afterEach(cleanup); describe('StatusDot', () => { it('renders shared variant, size, pulse, and accessible status semantics', () => { render(() => ( - + )); const dot = screen.getByRole('img', { name: 'Attention' }); @@ -39,4 +33,12 @@ describe('StatusDot', () => { expect(dot).toHaveAttribute('aria-hidden', 'true'); expect(dot).not.toHaveAttribute('role'); }); + + it('renders informational indicators from the shared variant catalog', () => { + render(() => ); + + const dot = screen.getByRole('img', { name: 'Informational' }); + + expect(dot).toHaveClass('bg-blue-500'); + }); }); diff --git a/frontend-modern/src/features/docker/dockerPageModel.ts b/frontend-modern/src/features/docker/dockerPageModel.ts index b6c8559fb..b3d11542d 100644 --- a/frontend-modern/src/features/docker/dockerPageModel.ts +++ b/frontend-modern/src/features/docker/dockerPageModel.ts @@ -183,8 +183,9 @@ const titleCase = (value: string): string => const STATUS_VARIANT_RANK: Record = { danger: 0, warning: 1, - muted: 2, - success: 3, + info: 2, + muted: 3, + success: 4, }; const compareByDockerStatus = ( diff --git a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts index 3145271c9..1584aa01e 100644 --- a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts +++ b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts @@ -65,7 +65,8 @@ const parseKubernetesEventObservedTime = (resource: Resource): number => { }; export const compareKubernetesEvents = (left: Resource, right: Resource): number => { - const timeDelta = parseKubernetesEventObservedTime(right) - parseKubernetesEventObservedTime(left); + const timeDelta = + parseKubernetesEventObservedTime(right) - parseKubernetesEventObservedTime(left); if (timeDelta !== 0) return timeDelta; return left.id.localeCompare(right.id); }; @@ -86,7 +87,12 @@ const POD_CONTAINER_FATAL_REASONS = new Set([ ]); const normalizeKubernetesToken = (value: unknown): string => - typeof value === 'string' ? value.trim().toLowerCase().replace(/[\s_-]/g, '') : ''; + typeof value === 'string' + ? value + .trim() + .toLowerCase() + .replace(/[\s_-]/g, '') + : ''; const displayName = (resource: Resource): string => asTrimmedString(resource.displayName) || @@ -123,13 +129,14 @@ const podAllContainersReady = (containers: ResourceKubernetesPodContainerStatus[ containers.length > 0 && containers.every((container) => container.ready === true); export function mapKubernetesPodStatus(resource: Resource): StatusIndicator { - const phase = normalizeKubernetesToken(resource.kubernetes?.podPhase || resource.kubernetes?.phase); + const phase = normalizeKubernetesToken( + resource.kubernetes?.podPhase || resource.kubernetes?.phase, + ); const containers = resource.kubernetes?.podContainers ?? []; if (phase === 'failed') return { variant: 'danger', label: 'Failed' }; if (podHasFatalContainer(containers)) { - const reason = - containers.find(containerHasFatalReason)?.reason?.trim() || 'Container error'; + const reason = containers.find(containerHasFatalReason)?.reason?.trim() || 'Container error'; return { variant: 'danger', label: reason }; } if (phase === 'pending') return { variant: 'warning', label: 'Pending' }; @@ -141,7 +148,10 @@ export function mapKubernetesPodStatus(resource: Resource): StatusIndicator { if (phase === 'succeeded') return { variant: 'success', label: 'Succeeded' }; if (phase === 'unknown') return { variant: 'muted', label: 'Unknown' }; if (!phase) return { variant: 'muted', label: 'Unknown' }; - return { variant: 'muted', label: eventTypeLabel(resource.kubernetes?.podPhase ?? '', 'Unknown') }; + return { + variant: 'muted', + label: eventTypeLabel(resource.kubernetes?.podPhase ?? '', 'Unknown'), + }; } export function mapKubernetesNodeStatus(resource: Resource): StatusIndicator { @@ -181,24 +191,15 @@ const replicaIndicator = ( }; export function mapKubernetesDeploymentStatus(resource: Resource): StatusIndicator { - return replicaIndicator( - resource.kubernetes?.desiredReplicas, - resource.kubernetes?.readyReplicas, - ); + return replicaIndicator(resource.kubernetes?.desiredReplicas, resource.kubernetes?.readyReplicas); } export function mapKubernetesReplicaSetStatus(resource: Resource): StatusIndicator { - return replicaIndicator( - resource.kubernetes?.desiredReplicas, - resource.kubernetes?.readyReplicas, - ); + return replicaIndicator(resource.kubernetes?.desiredReplicas, resource.kubernetes?.readyReplicas); } export function mapKubernetesStatefulSetStatus(resource: Resource): StatusIndicator { - return replicaIndicator( - resource.kubernetes?.desiredReplicas, - resource.kubernetes?.readyReplicas, - ); + return replicaIndicator(resource.kubernetes?.desiredReplicas, resource.kubernetes?.readyReplicas); } export function mapKubernetesDaemonSetStatus(resource: Resource): StatusIndicator { @@ -249,15 +250,17 @@ export function mapKubernetesControllerStatus(resource: Resource): StatusIndicat const STATUS_VARIANT_RANK: Record = { danger: 0, warning: 1, - muted: 2, - success: 3, + info: 2, + muted: 3, + success: 4, }; const compareByStatus = ( mapper: (resource: Resource) => StatusIndicator, ): ((left: Resource, right: Resource) => number) => { return (left, right) => { - const rankDelta = STATUS_VARIANT_RANK[mapper(left).variant] - STATUS_VARIANT_RANK[mapper(right).variant]; + const rankDelta = + STATUS_VARIANT_RANK[mapper(left).variant] - STATUS_VARIANT_RANK[mapper(right).variant]; if (rankDelta !== 0) return rankDelta; return displayName(left).localeCompare(displayName(right)); }; @@ -337,8 +340,8 @@ const hasKubernetesIncidentRollup = (resource: Resource): boolean => (resource.incidentCount ?? 0) > 0 || Boolean( asTrimmedString(resource.incidentCode) || - asTrimmedString(resource.incidentSummary) || - asTrimmedString(resource.incidentLabel), + asTrimmedString(resource.incidentSummary) || + asTrimmedString(resource.incidentLabel), ); const buildKubernetesIncidentRow = ( diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx index 30ea81053..752cb6f0f 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceSummary.tsx @@ -6,6 +6,7 @@ import { getPatrolRecencyPresentation, getPatrolSummaryMetricState, } from '@/utils/patrolSummaryPresentation'; +import { StatusIndicatorBadge } from '@/components/shared/StatusIndicatorBadge'; import { formatPatrolActivityBreakdown, getPatrolActivityBreakdown, @@ -169,11 +170,12 @@ export function PatrolIntelligenceSummary(props: { state: PatrolIntelligenceStat
- - Patrol runtime - +

{recency().label}{' '} diff --git a/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx b/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx index 1e14505c8..d9f6fcc9b 100644 --- a/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx +++ b/frontend-modern/src/features/patrol/PatrolIntelligenceWorkspace.tsx @@ -8,6 +8,7 @@ import { ResourcePolicySummary } from '@/components/Infrastructure/ResourcePolic import { ResourceCorrelationSummary } from '@/components/Infrastructure/ResourceCorrelationSummary'; import { ResourceChangeSummary } from '@/components/Infrastructure/ResourceChangeSummary'; import { Button } from '@/components/shared/Button'; +import { MetadataBadge } from '@/components/shared/MetadataBadge'; import { getPatrolSupportingContextCorrelationSummary, getPatrolSupportingContextToggleLabel, @@ -55,13 +56,10 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt > Findings 0}> - +

diff --git a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx index 32b9b8b74..f3430cb30 100644 --- a/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx +++ b/frontend-modern/src/features/patrol/__tests__/PatrolIntelligenceSummary.test.tsx @@ -69,7 +69,9 @@ describe('PatrolIntelligenceSummary', () => { expect( screen.getByText('Recent activity mix: 1 full, 1 alert-triggered, 1 anomaly-triggered'), ).toBeInTheDocument(); - expect(screen.getByText('Trigger mode: 4 queued · busy mode · anomalies off')).toBeInTheDocument(); + expect( + screen.getByText('Trigger mode: 4 queued · busy mode · anomalies off'), + ).toBeInTheDocument(); }); it('does not surface disabled run actions on the compact assessment strip', () => { diff --git a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts index 283ba2cff..81d55810c 100644 --- a/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts +++ b/frontend-modern/src/utils/__tests__/frontendResourceTypeBoundaries.test.ts @@ -2660,8 +2660,7 @@ describe('frontend resource type boundaries', () => { expect(storageDomainSource).toContain('export const getCephDisconnectedStatePresentation'); expect(storageDomainSource).toContain('export const getCephNoClustersStatePresentation'); expect(storageDomainSource).toContain('export const getCephPoolsSearchEmptyStatePresentation'); - for (const source of [ - ]) { + for (const source of []) { expect(source).toContain("from '@/components/shared/Table'"); expect(source).not.toContain(' { expect(patrolIntelligenceHeaderSource).not.toContain( 'flex-1 py-1.5 px-2 text-xs font-semibold rounded-md transition-all duration-200', ); + expect(patrolIntelligenceSummarySource).toContain('StatusIndicatorBadge'); + expect(patrolIntelligenceSummarySource).toContain('runtimeShellPresentation().badgeVariant'); + expect(patrolIntelligenceSummarySource).not.toContain('runtimeShellPresentation().badgeClass'); + expect(patrolIntelligenceSummarySource).not.toContain( + 'inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em]', + ); + expect(patrolSummaryPresentationSource).toContain('badgeVariant:'); + expect(patrolSummaryPresentationSource).not.toContain('badgeClass:'); + expect(patrolIntelligenceWorkspaceSource).toContain('MetadataBadge'); + expect(patrolIntelligenceWorkspaceSource).toContain('findingsBadgePresentation().tone'); + expect(patrolIntelligenceWorkspaceSource).not.toContain( + 'findingsBadgePresentation().toneClasses', + ); + expect(patrolIntelligenceWorkspaceSource).not.toContain( + 'ml-1.5 px-1.5 py-0.5 text-xs rounded-full', + ); expect(appSource).not.toContain("import('./pages/Operations')"); expect(appSource).not.toContain(' { it('uses the shared neutral shell with a warning accent for degraded patrol summaries', () => { expect(getPatrolAssessmentShellPresentation('warning')).toEqual({ headerClass: 'bg-amber-50/70 dark:bg-amber-950/30', - badgeClass: - 'border-amber-200 bg-amber-100/80 text-amber-700 dark:border-amber-800 dark:bg-amber-900/40 dark:text-amber-200', + badgeVariant: 'warning', iconClass: 'text-amber-600 dark:text-amber-300', iconContainerClass: 'border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40', }); @@ -368,8 +367,7 @@ describe('getPatrolSummaryPresentation', () => { it('defaults patrol shell styling to the informational accent', () => { expect(getPatrolAssessmentShellPresentation()).toEqual({ headerClass: 'bg-blue-50/70 dark:bg-blue-950/30', - badgeClass: - 'border-blue-200 bg-blue-100/80 text-blue-700 dark:border-blue-800 dark:bg-blue-900/40 dark:text-blue-200', + badgeVariant: 'info', iconClass: 'text-blue-600 dark:text-blue-300', iconContainerClass: 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/40', }); diff --git a/frontend-modern/src/utils/aiFindingPresentation.ts b/frontend-modern/src/utils/aiFindingPresentation.ts index 8100fb1e7..9454334c8 100644 --- a/frontend-modern/src/utils/aiFindingPresentation.ts +++ b/frontend-modern/src/utils/aiFindingPresentation.ts @@ -229,7 +229,7 @@ export interface FindingManualControlsPresentation { } export interface PatrolFindingsBadgePresentation { - toneClasses: string; + tone: 'danger' | 'warning' | 'info' | 'muted'; } export interface FindingSeverityPresentation { @@ -308,35 +308,31 @@ export const getPatrolFindingsBadgePresentation = ( (finding) => finding.severity === 'critical' && !isPatrolRuntimeFinding(finding), ) ) { - return { toneClasses: getFindingSeverityToneClasses('critical') }; + return { tone: 'danger' }; } if ( activeFindings.some( (finding) => finding.severity === 'critical' && isPatrolRuntimeFinding(finding), ) ) { - return { - toneClasses: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300', - }; + return { tone: 'info' }; } if ( activeFindings.some( (finding) => finding.severity === 'warning' && !isPatrolRuntimeFinding(finding), ) ) { - return { toneClasses: getFindingSeverityToneClasses('warning') }; + return { tone: 'warning' }; } if ( activeFindings.some( (finding) => finding.severity === 'warning' && isPatrolRuntimeFinding(finding), ) ) { - return { - toneClasses: 'bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300', - }; + return { tone: 'info' }; } - return { toneClasses: 'bg-surface-alt text-muted' }; + return { tone: 'muted' }; }; export const getFindingSeverityCompactLabel = ( @@ -553,9 +549,7 @@ export const hasFindingInvestigationHandoffPointer = ( >, ): boolean => Boolean( - finding.investigationOutcome || - finding.investigationSessionId || - finding.lastInvestigatedAt, + finding.investigationOutcome || finding.investigationSessionId || finding.lastInvestigatedAt, ); const ATTENTION_OUTCOMES = new Set([ diff --git a/frontend-modern/src/utils/patrolSummaryPresentation.ts b/frontend-modern/src/utils/patrolSummaryPresentation.ts index 81547d86f..2cb842ea3 100644 --- a/frontend-modern/src/utils/patrolSummaryPresentation.ts +++ b/frontend-modern/src/utils/patrolSummaryPresentation.ts @@ -12,6 +12,7 @@ import { import { getPatrolProviderSettingsAction } from '@/utils/patrolRuntimeActions'; import type { SemanticTone } from '@/utils/semanticTonePresentation'; import { getPatrolRuntimePresentation } from '@/utils/patrolRuntimePresentation'; +import type { StatusIndicatorVariant } from '@/utils/status'; export type PatrolSummaryTone = 'critical' | 'warning' | 'success'; @@ -36,7 +37,7 @@ export interface PatrolAssessmentPresentation { export interface PatrolAssessmentShellPresentation { headerClass: string; - badgeClass: string; + badgeVariant: StatusIndicatorVariant; iconClass: string; iconContainerClass: string; } @@ -110,30 +111,26 @@ const ACTIVE_PRESENTATION: Record const ASSESSMENT_SHELL_PRESENTATION: Record = { success: { headerClass: 'bg-emerald-50/60 dark:bg-emerald-950/30', - badgeClass: - 'border-emerald-200 bg-emerald-100/80 text-emerald-700 dark:border-emerald-800 dark:bg-emerald-900/40 dark:text-emerald-200', + badgeVariant: 'success', iconClass: 'text-emerald-600 dark:text-emerald-300', iconContainerClass: 'border-emerald-200 bg-emerald-50 dark:border-emerald-800 dark:bg-emerald-950/40', }, warning: { headerClass: 'bg-amber-50/70 dark:bg-amber-950/30', - badgeClass: - 'border-amber-200 bg-amber-100/80 text-amber-700 dark:border-amber-800 dark:bg-amber-900/40 dark:text-amber-200', + badgeVariant: 'warning', iconClass: 'text-amber-600 dark:text-amber-300', iconContainerClass: 'border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/40', }, error: { headerClass: 'bg-red-50/70 dark:bg-red-950/30', - badgeClass: - 'border-red-200 bg-red-100/80 text-red-700 dark:border-red-800 dark:bg-red-900/40 dark:text-red-200', + badgeVariant: 'danger', iconClass: 'text-red-600 dark:text-red-300', iconContainerClass: 'border-red-200 bg-red-50 dark:border-red-800 dark:bg-red-950/40', }, info: { headerClass: 'bg-blue-50/70 dark:bg-blue-950/30', - badgeClass: - 'border-blue-200 bg-blue-100/80 text-blue-700 dark:border-blue-800 dark:bg-blue-900/40 dark:text-blue-200', + badgeVariant: 'info', iconClass: 'text-blue-600 dark:text-blue-300', iconContainerClass: 'border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/40', }, diff --git a/frontend-modern/src/utils/status.ts b/frontend-modern/src/utils/status.ts index e9e23f742..0b03e0061 100644 --- a/frontend-modern/src/utils/status.ts +++ b/frontend-modern/src/utils/status.ts @@ -48,7 +48,7 @@ export const getCanonicalStatusLabel = (value?: string | null, fallback = 'Unkno return STATUS_LABELS[normalized] || raw; }; -export type StatusIndicatorVariant = 'success' | 'warning' | 'danger' | 'muted'; +export type StatusIndicatorVariant = 'success' | 'warning' | 'danger' | 'info' | 'muted'; export interface StatusIndicator { variant: StatusIndicatorVariant; @@ -61,6 +61,7 @@ const STATUS_INDICATOR_BADGE_TONE_CLASSES: Record