Canonicalize Patrol badge primitives

This commit is contained in:
rcourtman
2026-06-12 21:23:02 +01:00
parent 6725d6a784
commit 1708db68a0
21 changed files with 216 additions and 112 deletions
@@ -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
@@ -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`
@@ -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
@@ -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
@@ -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": [
@@ -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', () => {
@@ -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<PatrolStatusBarProps> = (props) => {
Recent activity
</span>
<Show when={showRunInProgress()}>
<span class="rounded border border-blue-200 bg-blue-50 px-1.5 py-0.5 text-[10px] font-medium text-blue-700 dark:border-blue-800 dark:bg-blue-900 dark:text-blue-300">
Run in progress
</span>
<StatusIndicatorBadge
label="Run in progress"
variant="info"
size="xs"
shape="rounded"
/>
</Show>
</>
}
@@ -184,13 +189,18 @@ export const PatrolStatusBar: Component<PatrolStatusBarProps> = (props) => {
<Show when={resolvedStats()!.lastRun}>
<span class="text-xs text-muted">
Latest: {resolvedStats()!.lastRunTypeLabel} <span class="text-muted">·</span>{' '}
<span class={`px-1.5 py-0.5 rounded ${resolvedStats()!.lastRunStatus.badgeClass}`}>
{resolvedStats()!.lastRunStatus.label}
</span>
<StatusIndicatorBadge
label={resolvedStats()!.lastRunStatus.label}
variant={resolvedStats()!.lastRunStatus.variant}
size="xs"
shape="rounded"
/>
<Show when={resolvedStats()!.lastRunHasFindingsSnapshot === false}>
{' '}
<span class="text-muted">·</span>{' '}
<span class="text-blue-600 dark:text-blue-400">Findings snapshot unavailable</span>
<MetadataBadge tone="info" size="xs" shape="rounded">
Findings snapshot unavailable
</MetadataBadge>
</Show>
</span>
</Show>
@@ -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', () => {
@@ -17,6 +17,7 @@ const VARIANT_CLASSES: Record<StatusIndicatorVariant, string> = {
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',
};
@@ -19,12 +19,7 @@ describe('StatusIndicatorBadge', () => {
it('renders custom state labels without forcing a dot or uppercase text', () => {
render(() => (
<StatusIndicatorBadge
variant="warning"
label="Cooldown: Missing"
size="md"
shape="rounded"
/>
<StatusIndicatorBadge variant="warning" label="Cooldown: Missing" size="md" shape="rounded" />
));
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(() => (
<StatusIndicatorBadge label="Run in progress" variant="info" size="xs" shape="rounded" />
));
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');
});
});
@@ -7,13 +7,7 @@ afterEach(cleanup);
describe('StatusDot', () => {
it('renders shared variant, size, pulse, and accessible status semantics', () => {
render(() => (
<StatusDot
variant="warning"
size="md"
pulse
title="Attention"
ariaLabel="Attention"
/>
<StatusDot variant="warning" size="md" pulse title="Attention" ariaLabel="Attention" />
));
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(() => <StatusDot variant="info" ariaLabel="Informational" />);
const dot = screen.getByRole('img', { name: 'Informational' });
expect(dot).toHaveClass('bg-blue-500');
});
});
@@ -183,8 +183,9 @@ const titleCase = (value: string): string =>
const STATUS_VARIANT_RANK: Record<StatusIndicatorVariant, number> = {
danger: 0,
warning: 1,
muted: 2,
success: 3,
info: 2,
muted: 3,
success: 4,
};
const compareByDockerStatus = (
@@ -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<StatusIndicatorVariant, number> = {
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 = (
@@ -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
<div
class={`flex flex-wrap items-center justify-between gap-3 border-b border-border-subtle px-4 py-3 ${runtimeShellPresentation().headerClass}`}
>
<span
class={`inline-flex items-center rounded-full border px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${runtimeShellPresentation().badgeClass}`}
>
Patrol runtime
</span>
<StatusIndicatorBadge
label="Patrol runtime"
variant={runtimeShellPresentation().badgeVariant}
size="md"
uppercase
/>
<Show when={recency().timestamp}>
<p class="text-xs font-medium text-muted">
{recency().label}{' '}
@@ -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
<Show when={(state.findingsTabBadgeCount() ?? 0) > 0}>
<span
aria-hidden="true"
class={`ml-1.5 px-1.5 py-0.5 text-xs rounded-full ${findingsBadgePresentation().toneClasses}`}
>
{' '}
{' '}
<MetadataBadge aria-hidden="true" tone={findingsBadgePresentation().tone} size="xs">
{state.findingsTabBadgeCount()}
</span>
</MetadataBadge>
</Show>
</button>
<button
@@ -78,13 +76,10 @@ export function PatrolIntelligenceWorkspace(props: { state: PatrolIntelligenceSt
>
Runs
<Show when={state.displayRunHistory().length > 0}>
<span
aria-hidden="true"
class="ml-1.5 px-1.5 py-0.5 text-xs rounded-full bg-surface-alt text-muted"
>
{' '}
{' '}
<MetadataBadge aria-hidden="true" tone="muted" size="xs">
{state.displayRunHistory().length}
</span>
</MetadataBadge>
</Show>
</button>
</div>
@@ -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', () => {
@@ -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('<table');
expect(source).not.toContain('<thead');
@@ -3557,6 +3556,22 @@ describe('frontend resource type boundaries', () => {
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('<Route path="/operations/*"');
expect(settingsNavigationModelSource).not.toContain(
@@ -358,8 +358,7 @@ describe('getPatrolSummaryPresentation', () => {
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',
});
@@ -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([
@@ -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<PatrolSummaryTone, PatrolSummaryPresentation>
const ASSESSMENT_SHELL_PRESENTATION: Record<SemanticTone, PatrolAssessmentShellPresentation> = {
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',
},
+2 -1
View File
@@ -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<StatusIndicatorVariant, string
success: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
warning: 'bg-amber-100 text-amber-700 dark:bg-amber-900 dark:text-amber-300',
danger: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300',
info: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
muted: 'bg-surface-alt text-base-content',
};