mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Canonicalize Patrol badge primitives
This commit is contained in:
@@ -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',
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user