diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 9e825c1ea..7c3ccf22f 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -274,6 +274,7 @@ function App() { }); let appShellRoutePreloadCleanup: (() => void) | undefined; let appShellRoutesPreloadScheduled = false; + let workspaceRedirectPending = false; createEffect(() => { location.pathname; @@ -286,8 +287,13 @@ function App() { createEffect(() => { if (runtime.isLoading() || runtime.needsAuth() || isPublicRoute()) return; - if (!isWorkspaceEntryRoutePath(location.pathname)) return; + if (!isWorkspaceEntryRoutePath(location.pathname)) { + workspaceRedirectPending = false; + return; + } if (!platformNavigationResolved()) return; + if (workspaceRedirectPending) return; + workspaceRedirectPending = true; navigate(getDefaultWorkspaceRoute(platformNavigationVisibility(), hasSettingsAccess()), { replace: true, }); diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 5c7e4768e..6430193fd 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -147,6 +147,9 @@ describe('App architecture', () => { expect(appSource).toContain("normalizedPath === '/'"); expect(appSource).toContain("normalizedPath === '/login'"); expect(appSource).toContain("normalizedPath === '/infrastructure'"); + expect(appSource).toContain('let workspaceRedirectPending = false'); + expect(appSource).toContain('if (workspaceRedirectPending) return'); + expect(appSource).toContain('workspaceRedirectPending = true'); expect(appSource).toContain( '', ); diff --git a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx index 648c22d19..390fdc9cd 100644 --- a/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx +++ b/frontend-modern/src/components/Workloads/__tests__/WorkloadsSurface.performance.contract.test.tsx @@ -1045,6 +1045,10 @@ describe('Workloads performance contract', () => { ); expect(workloadsWorkloadRouteStateSource).toContain('useWorkloadUrlSync'); expect(workloadsWorkloadRouteStateSource).toContain('useWorkloadFilterOptions'); + expect(workloadsWorkloadRouteStateSource).not.toContain('window.location.pathname'); + expect(workloadsWorkloadRouteStateSource).not.toContain('window.location.search'); + expect(workloadsControlsStateSource).not.toContain('window.location.pathname'); + expect(workloadsControlsStateSource).not.toContain('window.location.search'); expect(workloadsWorkloadRouteStateSource).not.toContain('buildWorkloadsPath({'); expect(workloadsWorkloadRouteStateSource).not.toContain('normalizeWorkloadViewModeParam'); expect(workloadsWorkloadRouteStateSource).not.toContain( diff --git a/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts b/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts index 0f133cd52..5dbbd2e19 100644 --- a/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts +++ b/frontend-modern/src/components/Workloads/__tests__/useWorkloadsControlsState.test.ts @@ -141,7 +141,9 @@ describe('useWorkloadsControlsState', () => { }); setMockRouterSearch(''); - window.history.replaceState(null, '', '/workloads'); + // During a router transition the browser URL can still reflect the route + // being left. Restores must target the router location that owns the hook. + window.history.replaceState(null, '', '/stale-workspace-entry'); navigateSpy.mockClear(); const disposeRestore = createRoot((dispose) => { diff --git a/frontend-modern/src/components/Workloads/useWorkloadRouteState.ts b/frontend-modern/src/components/Workloads/useWorkloadRouteState.ts index 24ab1f610..06cc1d526 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadRouteState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadRouteState.ts @@ -1,5 +1,5 @@ import { createSignal, onMount, type Accessor, type Setter } from 'solid-js'; -import { useNavigate } from '@solidjs/router'; +import { useLocation, useNavigate } from '@solidjs/router'; import type { WorkloadGuest, ViewMode } from '@/types/workloads'; import { deserializeWorkloadViewMode } from './workloadRouteModel'; import { @@ -20,6 +20,7 @@ export interface WorkloadRouteStateOptions { } export function useWorkloadRouteState(options: WorkloadRouteStateOptions) { + const location = useLocation(); const navigate = useNavigate(); const [selectedNode, setSelectedNode] = createSignal(null); const [selectedPlatform, setSelectedPlatform] = createSignal(null); @@ -36,7 +37,7 @@ export function useWorkloadRouteState(options: WorkloadRouteStateOptions) { onMount(() => { if (typeof window === 'undefined') return; - const params = new URLSearchParams(window.location.search); + const params = new URLSearchParams(location.search); let mutated = false; if (!params.has(WORKLOADS_QUERY_PARAMS.type)) { @@ -60,7 +61,7 @@ export function useWorkloadRouteState(options: WorkloadRouteStateOptions) { } if (mutated) { - navigate(`${window.location.pathname}?${params.toString()}`, { replace: true }); + navigate(`${location.pathname}?${params.toString()}`, { replace: true }); } }); const filterViewMode = () => options.forcedViewMode ?? viewMode(); diff --git a/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts b/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts index 8cbaea829..4eb5cfea4 100644 --- a/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts +++ b/frontend-modern/src/components/Workloads/useWorkloadsControlsState.ts @@ -125,12 +125,12 @@ export function useWorkloadsControlsState(options: WorkloadsControlsStateOptions onMount(() => { if (typeof window === 'undefined') return; - const params = new URLSearchParams(window.location.search); + const params = new URLSearchParams(location.search); if (params.has('status')) return; const saved = readSavedWorkloadsStatusMode(options.statusModeStorageScope); if (saved !== DEFAULT_WORKLOADS_STATUS_MODE) { params.set('status', saved); - navigate(`${window.location.pathname}?${params.toString()}`, { replace: true }); + navigate(`${location.pathname}?${params.toString()}`, { replace: true }); } }); diff --git a/frontend-modern/src/features/docker/DockerImagesTable.tsx b/frontend-modern/src/features/docker/DockerImagesTable.tsx index 08c36961b..4a623c1b4 100644 --- a/frontend-modern/src/features/docker/DockerImagesTable.tsx +++ b/frontend-modern/src/features/docker/DockerImagesTable.tsx @@ -20,14 +20,26 @@ import { DockerResourceNameCell, dockerByteValue, dockerHostName, - dockerJoinValues, dockerResourceName, - dockerNumberValue, type DockerNativeTableProps, } from './DockerNativeTableShared'; import { filterDockerResources, type DockerResourceStatusFilter } from './dockerPageModel'; +import { + getDockerImageOperationalPresentation, + type DockerImageUpdateTone, +} from './dockerImagePresentation'; +import type { Resource } from '@/types/resource'; -export const DockerImagesTable: Component = (props) => { +const updateToneClass: Record = { + danger: 'bg-red-100 text-red-700 dark:bg-red-950/40 dark:text-red-300', + warning: 'bg-amber-100 text-amber-700 dark:bg-amber-950/40 dark:text-amber-300', + success: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-950/40 dark:text-emerald-300', + muted: 'bg-surface-hover text-muted', +}; + +export const DockerImagesTable: Component< + DockerNativeTableProps & { relatedContainers?: Resource[] } +> = (props) => { const tableState = createPlatformTableFilterState({ resources: () => props.resources, initialStatus: 'all' as DockerResourceStatusFilter, @@ -52,7 +64,7 @@ export const DockerImagesTable: Component = (props) => { = (props) => { > - + Image - - Tags + + Host + + + Used by - Digests - - Size - - In Use - - - Host + + Update check > } @@ -107,6 +112,11 @@ export const DockerImagesTable: Component = (props) => { <> {(resource) => { + const operational = () => + getDockerImageOperationalPresentation( + resource, + props.relatedContainers ?? [], + ); const detailRowId = () => drawer.detailRowId(resource); const isExpanded = () => drawer.isExpanded(resource); return ( @@ -134,21 +144,16 @@ export const DockerImagesTable: Component = (props) => { - - {dockerJoinValues(resource.docker?.repoTags)} - + {dockerHostName(resource)} - {dockerJoinValues(resource.docker?.repoDigests)} + {operational().consumerSummary} = (props) => { > {dockerByteValue(resource.docker?.sizeBytes)} - - {dockerNumberValue(resource.docker?.imageContainers)} - - - {dockerHostName(resource)} + + + {operational().updateLabel} + drawer.close(resource)} /> diff --git a/frontend-modern/src/features/docker/DockerPageSurface.tsx b/frontend-modern/src/features/docker/DockerPageSurface.tsx index 22150aba5..394c58fec 100644 --- a/frontend-modern/src/features/docker/DockerPageSurface.tsx +++ b/frontend-modern/src/features/docker/DockerPageSurface.tsx @@ -161,6 +161,7 @@ export function DockerPageSurface() { { }, }), ]} + relatedContainers={[ + makeResource({ + id: 'container-1', + type: 'app-container', + name: 'edge-web', + docker: { + image: 'nginx:latest', + updateStatus: { + updateAvailable: true, + currentDigest: 'sha256:current', + latestDigest: 'sha256:latest', + }, + }, + }), + ]} emptyIcon={} emptyTitle="No images" emptyDescription="No images" @@ -759,12 +774,12 @@ describe('Docker native tables', () => { /> )); - expect(screen.getByText('Tags')).toBeInTheDocument(); - expect(screen.getByText('Digests')).toBeInTheDocument(); + expect(screen.getByText('Used by')).toBeInTheDocument(); + expect(screen.getByText('Update check')).toBeInTheDocument(); expect(screen.getByText('nginx:latest')).toBeInTheDocument(); - expect(screen.getByText('nginx:latest, nginx:stable')).toBeInTheDocument(); - expect(screen.getByText('nginx@sha256:manifest')).toBeInTheDocument(); - expect(screen.getByText('2')).toBeInTheDocument(); + expect(screen.getByText('edge-web')).toBeInTheDocument(); + expect(screen.getByText('Update available')).toBeInTheDocument(); + expect(screen.queryByText('nginx@sha256:manifest')).not.toBeInTheDocument(); expect(screen.getByText('edge-01')).toBeInTheDocument(); }); diff --git a/frontend-modern/src/features/docker/dockerImagePresentation.ts b/frontend-modern/src/features/docker/dockerImagePresentation.ts new file mode 100644 index 000000000..fb7797159 --- /dev/null +++ b/frontend-modern/src/features/docker/dockerImagePresentation.ts @@ -0,0 +1,96 @@ +import type { Resource } from '@/types/resource'; +import { asTrimmedString } from '@/utils/stringUtils'; + +export type DockerImageUpdateTone = 'danger' | 'warning' | 'success' | 'muted'; + +const trimmed = (value: unknown): string => asTrimmedString(value) ?? ''; + +export type DockerImageOperationalPresentation = { + consumerCount: number; + consumerSummary: string; + updateLabel: string; + updateDetail: string; + updateTone: DockerImageUpdateTone; +}; + +const imageIdentityTokens = (image: Resource): Set => + new Set( + [ + image.id, + image.name, + image.displayName, + image.docker?.image, + image.docker?.imageId, + ...(image.docker?.repoTags ?? []), + ] + .map(trimmed) + .filter((value): value is string => value.length > 0), + ); + +const containerUsesImage = (container: Resource, tokens: ReadonlySet): boolean => + [container.docker?.image, container.docker?.imageId] + .map(trimmed) + .some((value) => value.length > 0 && tokens.has(value)); + +const resourceLabel = (resource: Resource): string => + trimmed(resource.name) || trimmed(resource.displayName) || resource.id; + +const summarizeConsumers = (consumers: readonly Resource[], reportedCount: number): string => { + if (consumers.length === 0) { + if (reportedCount <= 0) return 'Unused'; + return `${reportedCount} container${reportedCount === 1 ? '' : 's'}`; + } + const labels = consumers.map(resourceLabel); + const visible = labels.slice(0, 2); + const extra = labels.length - visible.length; + return `${visible.join(', ')}${extra > 0 ? ` +${extra}` : ''}`; +}; + +export function getDockerImageOperationalPresentation( + image: Resource, + containers: readonly Resource[] = [], +): DockerImageOperationalPresentation { + const tokens = imageIdentityTokens(image); + const consumers = containers.filter((container) => containerUsesImage(container, tokens)); + const reportedCount = Math.max(0, image.docker?.imageContainers ?? 0); + const consumerCount = Math.max(reportedCount, consumers.length); + const updateStates = [image, ...consumers] + .map((resource) => resource.docker?.updateStatus) + .filter((state): state is NonNullable => state !== undefined); + const failed = updateStates.find((state) => trimmed(state.error).length > 0); + + if (failed) { + return { + consumerCount, + consumerSummary: summarizeConsumers(consumers, reportedCount), + updateLabel: 'Check failed', + updateDetail: trimmed(failed.error), + updateTone: 'danger', + }; + } + if (updateStates.some((state) => state.updateAvailable === true)) { + return { + consumerCount, + consumerSummary: summarizeConsumers(consumers, reportedCount), + updateLabel: 'Update available', + updateDetail: 'At least one running container is behind the latest reported digest.', + updateTone: 'warning', + }; + } + if (updateStates.some((state) => state.updateAvailable === false)) { + return { + consumerCount, + consumerSummary: summarizeConsumers(consumers, reportedCount), + updateLabel: 'Current', + updateDetail: 'No newer digest was reported by the last image check.', + updateTone: 'success', + }; + } + return { + consumerCount, + consumerSummary: summarizeConsumers(consumers, reportedCount), + updateLabel: 'Not checked', + updateDetail: 'No update comparison has been reported for this image.', + updateTone: 'muted', + }; +} diff --git a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx index 027b3353b..a7d47140d 100644 --- a/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx +++ b/frontend-modern/src/features/kubernetes/KubernetesPageSurface.tsx @@ -1,15 +1,17 @@ import { useLocation, useSearchParams } from '@solidjs/router'; import { Show, createMemo, type Accessor } from 'solid-js'; +import { ButtonLink } from '@/components/shared/Button'; import { buildInfrastructureAgentUpdatesPath } from '@/components/Settings/infrastructureWorkspaceModel'; import type { FilterDef } from '@/components/shared/FilterBar'; import { getPlatformIcon } from '@/features/platformPage/platformIcon'; +import { PlatformAttentionSummary } from '@/features/platformPage/PlatformAttentionSummary'; import { PlatformOutdatedAgentNotice } from '@/features/platformPage/PlatformOutdatedAgentNotice'; import { collectOutdatedAgentHosts, formatAgentVersionDisplay, } from '@/features/platformPage/agentVersion'; import { useUnifiedResources } from '@/hooks/useUnifiedResources'; -import { KUBERNETES_QUERY_PARAMS } from '@/routing/resourceLinks'; +import { buildKubernetesPath, KUBERNETES_QUERY_PARAMS } from '@/routing/resourceLinks'; import { updateStore } from '@/stores/updates'; import { PLATFORM_HEALTH_FILTER_OPTIONS, @@ -35,6 +37,7 @@ import { KubernetesServicesTable } from './KubernetesServicesTable'; import { KubernetesStorageTable } from './KubernetesStorageTable'; import { buildKubernetesPageModel, + buildKubernetesOverviewPosture, filterKubernetesResources, getKubernetesPageTabSpecs, resolveKubernetesPageTabId, @@ -328,8 +331,7 @@ function KubernetesWorkloads(props: { model: KubernetesPageModel; controllers: R countKubernetesVisible(scope.scopedSections(), toolbar.search(), toolbar.status()), ); const hasActiveFilters = () => toolbar.hasActiveFilters() || scope.hasActiveNamespace(); - const resetFilters = () => - toolbar.resetFilters({ [KUBERNETES_QUERY_PARAMS.namespace]: null }); + const resetFilters = () => toolbar.resetFilters({ [KUBERNETES_QUERY_PARAMS.namespace]: null }); return ( toolbar.hasActiveFilters() || scope.hasActiveNamespace(); - const resetFilters = () => - toolbar.resetFilters({ [KUBERNETES_QUERY_PARAMS.namespace]: null }); + const resetFilters = () => toolbar.resetFilters({ [KUBERNETES_QUERY_PARAMS.namespace]: null }); return ( toolbar.hasActiveFilters() || scope.hasActiveNamespace(); - const resetFilters = () => - toolbar.resetFilters({ [KUBERNETES_QUERY_PARAMS.namespace]: null }); + const resetFilters = () => toolbar.resetFilters({ [KUBERNETES_QUERY_PARAMS.namespace]: null }); return ( buildKubernetesOverviewPosture(props.model())); + const needsAttention = () => posture().attentionResources > 0 || posture().attentionSignals > 0; + const workloadAttention = () => posture().podAttention + posture().deploymentAttention; + const headline = () => { + const resources = posture().attentionResources; + const signals = posture().attentionSignals; + if (resources > 0 && signals > 0) { + return `${resources} resource${resources === 1 ? '' : 's'} and ${signals} signal${signals === 1 ? '' : 's'} need review`; + } + if (resources > 0) { + return `${resources} resource${resources === 1 ? '' : 's'} ${resources === 1 ? 'needs' : 'need'} review`; + } + return `${signals} health signal${signals === 1 ? '' : 's'} ${signals === 1 ? 'needs' : 'need'} review`; + }; + return ( + + 0 || posture().criticalIncidents > 0 + ? 'danger' + : 'warning' + } + metrics={[ + { label: 'nodes', value: posture().nodeAttention }, + { label: 'workloads', value: workloadAttention() }, + { label: 'signals', value: posture().attentionSignals }, + ]} + actions={ + <> + 0}> + + Review nodes + + + 0}> + + Review workloads + + + > + } + /> + 0}> - + + + ); diff --git a/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts b/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts index d2d70635d..3fd648b55 100644 --- a/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts +++ b/frontend-modern/src/features/kubernetes/__tests__/kubernetesPageModel.test.ts @@ -3,6 +3,7 @@ import type { Resource } from '@/types/resource'; import { KUBERNETES_TAB_SPECS, buildKubernetesClusterChildCounts, + buildKubernetesOverviewPosture, buildKubernetesIncidentRows, buildKubernetesPageModel, compareKubernetesControllers, @@ -93,6 +94,36 @@ describe('kubernetesPageModel', () => { ]); }); + it('summarizes actionable Kubernetes overview posture from native resource states', () => { + const model = buildKubernetesPageModel([ + makeResource({ id: 'cluster-1', type: 'k8s-cluster' }), + makeResource({ id: 'node-ready', type: 'k8s-node', kubernetes: { ready: true } }), + makeResource({ id: 'node-down', type: 'k8s-node', kubernetes: { ready: false } }), + makeResource({ + id: 'pod-pending', + type: 'pod', + kubernetes: { podPhase: 'Pending' }, + }), + makeResource({ + id: 'deployment-short', + type: 'k8s-deployment', + kubernetes: { desiredReplicas: 3, readyReplicas: 2 }, + incidents: [{ code: 'k8s_under_replicated', severity: 'warning', summary: '2 / 3 ready' }], + }), + ]); + + expect(buildKubernetesOverviewPosture(model)).toEqual({ + nodeAttention: 1, + podAttention: 1, + deploymentAttention: 1, + criticalResources: 1, + criticalIncidents: 0, + warningIncidents: 1, + attentionResources: 3, + attentionSignals: 1, + }); + }); + it('buckets clusters, nodes, workloads, services, storage, config, policy, autoscaling, and events', () => { const model = buildKubernetesPageModel([ makeResource({ id: 'cluster-1', type: 'k8s-cluster' }), @@ -395,7 +426,9 @@ describe('kubernetesPageModel', () => { it('falls back to resource.status when ready is undefined', () => { expect( - mapKubernetesNodeStatus(makeResource({ id: 'fallback-ok', type: 'k8s-node', status: 'online' })), + mapKubernetesNodeStatus( + makeResource({ id: 'fallback-ok', type: 'k8s-node', status: 'online' }), + ), ).toEqual({ variant: 'success', label: 'Ready' }); expect( mapKubernetesNodeStatus( @@ -439,8 +472,9 @@ describe('kubernetesPageModel', () => { kubernetes: { desiredReplicas: 5, readyReplicas: 2 }, } as const; expect( - mapKubernetesReplicaSetStatus(makeResource({ id: 'rs', type: 'k8s-replicaset', ...partial })) - .variant, + mapKubernetesReplicaSetStatus( + makeResource({ id: 'rs', type: 'k8s-replicaset', ...partial }), + ).variant, ).toBe('warning'); expect( mapKubernetesStatefulSetStatus( @@ -577,9 +611,11 @@ describe('kubernetesPageModel', () => { type: 'k8s-deployment', kubernetes: { desiredReplicas: 2, readyReplicas: 0 }, }); - expect( - [happy, partial, broken].sort(compareKubernetesDeployments).map((r) => r.id), - ).toEqual(['dep-broken', 'dep-partial', 'dep-happy']); + expect([happy, partial, broken].sort(compareKubernetesDeployments).map((r) => r.id)).toEqual([ + 'dep-broken', + 'dep-partial', + 'dep-happy', + ]); }); it('mixes controller kinds in a single attention-first order', () => { @@ -608,9 +644,9 @@ describe('kubernetesPageModel', () => { // fully-healthy rows — matches the rank ordering vSphere already uses for // its VM status table. expect( - [cronOk, jobFailed, dsMisscheduled, rsHappy].sort(compareKubernetesControllers).map( - (r) => r.id, - ), + [cronOk, jobFailed, dsMisscheduled, rsHappy] + .sort(compareKubernetesControllers) + .map((r) => r.id), ).toEqual(['job-fail', 'ds-mis', 'cron-ok', 'rs-ok']); }); @@ -831,9 +867,11 @@ describe('kubernetesPageModel', () => { ]; it('matches kubernetes.* fields the shared filter no longer carries', () => { - expect(filterKubernetesResources(rows, 'payments', 'all').map((r) => r.id).sort()).toEqual( - ['pod-checkout', 'svc-checkout'].sort(), - ); + expect( + filterKubernetesResources(rows, 'payments', 'all') + .map((r) => r.id) + .sort(), + ).toEqual(['pod-checkout', 'svc-checkout'].sort()); expect(filterKubernetesResources(rows, 'prod-cluster', 'all').map((r) => r.id)).toEqual([ 'pod-checkout', ]); @@ -1012,12 +1050,12 @@ describe('kubernetesPageModel', () => { ]); it('filters by severity bucket', () => { - expect( - filterKubernetesIncidents(incidents, '', 'critical').map((r) => r.resourceId), - ).toEqual(['pod-payments']); - expect( - filterKubernetesIncidents(incidents, '', 'warning').map((r) => r.resourceId), - ).toEqual(['dep-checkout']); + expect(filterKubernetesIncidents(incidents, '', 'critical').map((r) => r.resourceId)).toEqual( + ['pod-payments'], + ); + expect(filterKubernetesIncidents(incidents, '', 'warning').map((r) => r.resourceId)).toEqual([ + 'dep-checkout', + ]); }); it('matches resource name, code, cluster, and namespace', () => { diff --git a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts index a6ab3bb40..00b0f1747 100644 --- a/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts +++ b/frontend-modern/src/features/kubernetes/kubernetesPageModel.ts @@ -9,13 +9,7 @@ import { resolveResourcePlatformType } from '@/utils/sourcePlatforms'; import { matchesSearchTermSplit, splitSearchExclusions } from '@/utils/searchQuery'; export type KubernetesPageTabId = - | 'overview' - | 'nodes' - | 'workloads' - | 'services' - | 'storage' - | 'configuration' - | 'events'; + 'overview' | 'nodes' | 'workloads' | 'services' | 'storage' | 'configuration' | 'events'; export type KubernetesTabSpec = { id: KubernetesPageTabId; @@ -709,6 +703,63 @@ export type KubernetesPageModel = { incidents: KubernetesIncidentRow[]; }; +export type KubernetesOverviewPosture = { + nodeAttention: number; + podAttention: number; + deploymentAttention: number; + criticalResources: number; + criticalIncidents: number; + warningIncidents: number; + attentionResources: number; + attentionSignals: number; +}; + +const countKubernetesAttention = ( + resources: readonly Resource[], + mapper: (resource: Resource) => StatusIndicator, +): number => + resources.filter((resource) => { + const variant = mapper(resource).variant; + return variant === 'danger' || variant === 'warning'; + }).length; + +const countKubernetesDanger = ( + resources: readonly Resource[], + mapper: (resource: Resource) => StatusIndicator, +): number => resources.filter((resource) => mapper(resource).variant === 'danger').length; + +export function buildKubernetesOverviewPosture( + model: KubernetesPageModel, +): KubernetesOverviewPosture { + const nodeAttention = countKubernetesAttention(model.nodes, mapKubernetesNodeStatus); + const podAttention = countKubernetesAttention(model.pods, mapKubernetesPodStatus); + const deploymentAttention = countKubernetesAttention( + model.deployments, + mapKubernetesDeploymentStatus, + ); + const criticalResources = + countKubernetesDanger(model.nodes, mapKubernetesNodeStatus) + + countKubernetesDanger(model.pods, mapKubernetesPodStatus) + + countKubernetesDanger(model.deployments, mapKubernetesDeploymentStatus); + const criticalIncidents = model.incidents.filter( + (incident) => incident.severityBucket === 'critical', + ).length; + const warningIncidents = model.incidents.filter( + (incident) => incident.severityBucket === 'warning', + ).length; + + return { + nodeAttention, + podAttention, + deploymentAttention, + criticalResources, + criticalIncidents, + warningIncidents, + attentionResources: nodeAttention + podAttention + deploymentAttention, + attentionSignals: criticalIncidents + warningIncidents, + }; +} + export type KubernetesClusterChildCount = { total: number; // Rows whose status indicator is danger or warning. Healthy-for-display is diff --git a/frontend-modern/src/features/platformPage/PlatformAttentionSummary.tsx b/frontend-modern/src/features/platformPage/PlatformAttentionSummary.tsx new file mode 100644 index 000000000..226a6a1aa --- /dev/null +++ b/frontend-modern/src/features/platformPage/PlatformAttentionSummary.tsx @@ -0,0 +1,73 @@ +import { For, Show, type JSX } from 'solid-js'; +import { StatusDot } from '@/components/shared/StatusDot'; +import { TableCard } from '@/components/shared/TableCard'; + +export type PlatformAttentionSummaryTone = 'danger' | 'warning' | 'info'; + +export type PlatformAttentionSummaryMetric = { + label: string; + value: string | number; +}; + +const toneClasses: Record = { + danger: 'border-red-300 bg-red-50/70 dark:border-red-900/70 dark:bg-red-950/20', + warning: 'border-amber-300 bg-amber-50/70 dark:border-amber-900/70 dark:bg-amber-950/20', + info: 'border-blue-300 bg-blue-50/70 dark:border-blue-900/70 dark:bg-blue-950/20', +}; + +const toneVariant = (tone: PlatformAttentionSummaryTone) => + tone === 'danger' ? 'danger' : tone === 'warning' ? 'warning' : 'info'; + +export function PlatformAttentionSummary(props: { + title: string; + headline: string; + description: string; + tone: PlatformAttentionSummaryTone; + metrics?: readonly PlatformAttentionSummaryMetric[]; + actions?: JSX.Element; +}) { + return ( + + + + + + + {props.title} + + {props.headline} + + {props.description} + + + + + {(metric) => ( + + + {metric.value} + + {metric.label} + + )} + + + {props.actions} + + + + + ); +} + +export default PlatformAttentionSummary; diff --git a/frontend-modern/src/features/platformPage/__tests__/PlatformAttentionSummary.test.tsx b/frontend-modern/src/features/platformPage/__tests__/PlatformAttentionSummary.test.tsx new file mode 100644 index 000000000..3839083d2 --- /dev/null +++ b/frontend-modern/src/features/platformPage/__tests__/PlatformAttentionSummary.test.tsx @@ -0,0 +1,28 @@ +import { cleanup, render, screen } from '@solidjs/testing-library'; +import { afterEach, describe, expect, it } from 'vitest'; +import { PlatformAttentionSummary } from '../PlatformAttentionSummary'; + +afterEach(cleanup); + +describe('PlatformAttentionSummary', () => { + it('renders an actionable compact posture region', () => { + render(() => ( + Show attention} + /> + )); + + const region = screen.getByRole('region', { name: 'Platform attention' }); + expect(region).toHaveAttribute('data-platform-attention-summary', 'warning'); + expect(region).toHaveTextContent('2 resources need review'); + expect(screen.getByRole('button', { name: 'Show attention' })).toBeInTheDocument(); + }); +}); diff --git a/frontend-modern/src/features/platformPage/__tests__/platformAlertSeverityFilterOptions.test.tsx b/frontend-modern/src/features/platformPage/__tests__/platformAlertSeverityFilterOptions.test.tsx index c35012e89..ad0add1b1 100644 --- a/frontend-modern/src/features/platformPage/__tests__/platformAlertSeverityFilterOptions.test.tsx +++ b/frontend-modern/src/features/platformPage/__tests__/platformAlertSeverityFilterOptions.test.tsx @@ -32,4 +32,16 @@ describe('getPlatformAlertSeverityFilterOptions', () => { expect(dots[1]).toHaveClass('bg-amber-500'); expect(dots[2]).toHaveClass('bg-emerald-500'); }); + + it('adds the aggregate attention filter only when requested', () => { + const options = getPlatformAlertSeverityFilterOptions({ includeAttention: true }); + + expect(options.map((option) => option.value)).toEqual([ + 'all', + 'attention', + 'critical', + 'warning', + 'info', + ]); + }); }); diff --git a/frontend-modern/src/features/platformPage/platformAlertSeverityFilterOptions.tsx b/frontend-modern/src/features/platformPage/platformAlertSeverityFilterOptions.tsx index ba32ad958..6929cf0a8 100644 --- a/frontend-modern/src/features/platformPage/platformAlertSeverityFilterOptions.tsx +++ b/frontend-modern/src/features/platformPage/platformAlertSeverityFilterOptions.tsx @@ -1,7 +1,8 @@ import { filterChipStatusDot } from '@/components/shared/FilterBar'; import type { PlatformTableFilterOption } from '@/features/platformPage/sharedPlatformPage'; -export type PlatformAlertSeverityFilterValue = 'all' | 'critical' | 'warning' | 'info'; +export type PlatformAlertSeverityFilterValue = + 'all' | 'attention' | 'critical' | 'warning' | 'info'; const PLATFORM_ALERT_SEVERITY_FILTER_OPTIONS: PlatformTableFilterOption[] = [ @@ -26,8 +27,23 @@ const PLATFORM_ALERT_SEVERITY_FILTER_OPTIONS: PlatformTableFilterOption = + { + value: 'attention', + label: 'Attention', + tone: 'warning', + leading: filterChipStatusDot('bg-amber-500'), + }; + export function getPlatformAlertSeverityFilterOptions< TFilter extends PlatformAlertSeverityFilterValue, ->(): PlatformTableFilterOption[] { - return PLATFORM_ALERT_SEVERITY_FILTER_OPTIONS as PlatformTableFilterOption[]; +>(options: { includeAttention?: boolean } = {}): PlatformTableFilterOption[] { + const resolved = options.includeAttention + ? [ + PLATFORM_ALERT_SEVERITY_FILTER_OPTIONS[0], + PLATFORM_ALERT_ATTENTION_FILTER_OPTION, + ...PLATFORM_ALERT_SEVERITY_FILTER_OPTIONS.slice(1), + ] + : PLATFORM_ALERT_SEVERITY_FILTER_OPTIONS; + return resolved as PlatformTableFilterOption[]; } diff --git a/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx b/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx index fe7e865d7..54f853d7e 100644 --- a/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx +++ b/frontend-modern/src/features/truenas/TrueNASProtectionTable.tsx @@ -1,7 +1,9 @@ import { For, Show, createMemo, type Component, type JSX } from 'solid-js'; +import { Button } from '@/components/shared/Button'; import { InlineDetailTableRow } from '@/components/shared/InlineDetailTableRow'; import { StatusDot } from '@/components/shared/StatusDot'; import { TableCell, TableHead, TableRow } from '@/components/shared/Table'; +import { PlatformAttentionSummary } from '@/features/platformPage/PlatformAttentionSummary'; import { getRecoveryOutcomeBadgeClass, getRecoveryOutcomeLabel, @@ -30,10 +32,12 @@ import { import type { RecoveryPoint } from '@/types/recovery'; import { filterTrueNASProtectionPoints, + buildTrueNASProtectionPosture, mapTrueNASProtectionKind, mapTrueNASProtectionStatus, sortTrueNASProtectionPoints, type TrueNASProtectionKind, + type TrueNASProtectionStatusBucket, type TrueNASProtectionStatusFilter, } from './truenasPageModel'; import { @@ -48,6 +52,7 @@ import { const TRUENAS_PROTECTION_STATUS_OPTIONS: PlatformTableFilterOption[] = [ { value: 'all', label: 'All' }, + { value: 'attention', label: 'Attention', compactLabel: 'Issues', tone: 'warning' }, { value: 'success', label: 'Healthy', compactLabel: 'OK', tone: 'success' }, { value: 'warning', label: 'Warning', compactLabel: 'Warn', tone: 'warning' }, { value: 'failed', label: 'Failed', compactLabel: 'Fail', tone: 'danger' }, @@ -71,9 +76,7 @@ const kindLabel = (kind: TrueNASProtectionKind): string => { return 'Protection'; }; -const protectionVariant = ( - status: Exclude, -): StatusIndicatorVariant => { +const protectionVariant = (status: TrueNASProtectionStatusBucket): StatusIndicatorVariant => { switch (status) { case 'success': return 'success'; @@ -302,6 +305,7 @@ export const TrueNASProtectionTable: Component<{ showToolbar?: boolean; }> = (props) => { const rows = createMemo(() => sortTrueNASProtectionPoints(props.points)); + const posture = createMemo(() => buildTrueNASProtectionPosture(rows())); const detail = createPlatformResourceDetailState({ idPrefix: 'truenas-protection-detail' }); const tableState = createPlatformTableFilterState({ resources: rows, @@ -340,6 +344,44 @@ export const TrueNASProtectionTable: Component<{ } > + 0 || posture().running > 0}> + 0 + ? `${posture().attention} protection issue${posture().attention === 1 ? '' : 's'} ${posture().attention === 1 ? 'needs' : 'need'} review` + : `${posture().running} replication task${posture().running === 1 ? '' : 's'} running` + } + description={ + posture().attention > 0 + ? 'Review failed or warning replication outcomes first; successful snapshots remain available in the full event history.' + : 'No failed protection outcomes are reported. Monitor the active replication task until it completes.' + } + tone={ + posture().failed > 0 ? 'danger' : posture().attention > 0 ? 'warning' : 'info' + } + metrics={[ + { label: 'failed', value: posture().failed }, + { label: 'warning', value: posture().warning }, + { label: 'running', value: posture().running }, + ]} + actions={ + 0}> + + tableState.setStatus( + tableState.status() === 'attention' ? 'all' : 'attention', + ) + } + > + {tableState.status() === 'attention' ? 'Show all events' : 'Show issues'} + + + } + /> + { }); describe('TrueNASProtectionTable', () => { + it('summarizes protection issues and filters directly to attention outcomes', async () => { + render(() => ( + } + emptyTitle="No protection" + emptyDescription="No protection" + /> + )); + + expect(screen.getByRole('region', { name: 'Protection posture' })).toHaveTextContent( + '1 protection issue needs review', + ); + await fireEvent.click(screen.getByRole('button', { name: 'Show issues' })); + + expect(document.querySelectorAll('[data-truenas-protection-row]')).toHaveLength(1); + expect(document.querySelector('[data-truenas-protection-row="failed"]')).not.toBeNull(); + expect(screen.getByRole('button', { name: 'Show all events' })).toBeInTheDocument(); + }); + it('opens inline table details for a TrueNAS replication recovery point', async () => { const replication = makeRecoveryPoint({ id: 'replicate-tank-apps', diff --git a/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts b/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts index e967ca7c3..c3403d497 100644 --- a/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts +++ b/frontend-modern/src/features/truenas/__tests__/truenasPageModel.test.ts @@ -4,6 +4,7 @@ import type { Resource } from '@/types/resource'; import { TRUENAS_TAB_SPECS, buildTrueNASPageModel, + buildTrueNASProtectionPosture, buildTrueNASServiceRows, buildTrueNASStorageChildCounts, buildTrueNASStorageTopologyRows, @@ -84,9 +85,7 @@ describe('truenasPageModel', () => { expect(getTrueNASPageTabSpecs(systemOnlyModel).map((tab) => tab.id)).toEqual(['overview']); expect( - getTrueNASPageTabSpecs(inventoryModel, { hasProtectionInventory: true }).map( - (tab) => tab.id, - ), + getTrueNASPageTabSpecs(inventoryModel, { hasProtectionInventory: true }).map((tab) => tab.id), ).toEqual(['overview', 'storage', 'services', 'apps', 'vms', 'shares', 'protection']); }); @@ -856,6 +855,14 @@ describe('truenasPageModel', () => { expect(mapTrueNASProtectionKind(legacyReplication)).toBe('replication'); expect(mapTrueNASProtectionStatus(replication)).toBe('running'); expect(mapTrueNASProtectionStatus(legacyReplication)).toBe('warning'); + expect(buildTrueNASProtectionPosture([snapshot, replication, legacyReplication])).toEqual({ + healthy: 1, + warning: 1, + failed: 0, + running: 1, + unknown: 0, + attention: 1, + }); expect( filterTrueNASProtectionPoints( @@ -878,6 +885,13 @@ describe('truenasPageModel', () => { 'all', ).map((point) => point.id), ).toEqual(['replicate-tank-apps', 'legacy-task']); + expect( + filterTrueNASProtectionPoints( + [snapshot, replication, legacyReplication], + '', + 'attention', + ).map((point) => point.id), + ).toEqual(['legacy-task']); }); it('orders TrueNAS protection points by latest recovery timestamp', () => { diff --git a/frontend-modern/src/features/truenas/truenasPageModel.ts b/frontend-modern/src/features/truenas/truenasPageModel.ts index 012d0487c..56a4eacf6 100644 --- a/frontend-modern/src/features/truenas/truenasPageModel.ts +++ b/frontend-modern/src/features/truenas/truenasPageModel.ts @@ -10,13 +10,7 @@ import type { import type { RecoveryPoint } from '@/types/recovery'; export type TrueNASPageTabId = - | 'overview' - | 'storage' - | 'services' - | 'apps' - | 'vms' - | 'shares' - | 'protection'; + 'overview' | 'storage' | 'services' | 'apps' | 'vms' | 'shares' | 'protection'; export type TrueNASAppStatusFilter = 'all' | 'running' | 'attention' | 'stopped'; export type TrueNASServiceStatusFilter = 'all' | 'running' | 'attention' | 'stopped' | 'disabled'; export type TrueNASVMStatusFilter = 'all' | 'running' | 'attention' | 'stopped'; @@ -24,12 +18,11 @@ export type TrueNASShareStatusFilter = 'all' | 'active' | 'attention' | 'disable export type TrueNASIncidentSeverityFilter = 'all' | 'critical' | 'warning' | 'info'; export type TrueNASStorageStatusFilter = 'all' | 'healthy' | 'attention' | 'offline'; export type TrueNASProtectionStatusFilter = - | 'all' - | 'success' - | 'warning' - | 'failed' - | 'running' - | 'unknown'; + 'all' | 'attention' | 'success' | 'warning' | 'failed' | 'running' | 'unknown'; +export type TrueNASProtectionStatusBucket = Exclude< + TrueNASProtectionStatusFilter, + 'all' | 'attention' +>; export type TrueNASProtectionKind = 'snapshot' | 'replication' | 'other'; export type TrueNASTabSpec = { @@ -592,9 +585,7 @@ const normalize = (value: unknown): string => export const getTrueNASResourceDisplayStatus = (resource: Resource): string => hasImpairedResourceSource(resource, 'truenas') ? 'degraded' : resource.status; -const normalizeProtectionOutcome = ( - value: unknown, -): Exclude => { +const normalizeProtectionOutcome = (value: unknown): TrueNASProtectionStatusBucket => { const normalized = normalize(value); if (normalized === 'success' || normalized === 'ok') return 'success'; if (normalized === 'warning' || normalized === 'warn') return 'warning'; @@ -702,12 +693,39 @@ export function mapTrueNASStorageStatus( return 'unknown'; } -export function mapTrueNASProtectionStatus( - point: RecoveryPoint, -): Exclude { +export function mapTrueNASProtectionStatus(point: RecoveryPoint): TrueNASProtectionStatusBucket { return normalizeProtectionOutcome(point.outcome); } +export type TrueNASProtectionPosture = { + healthy: number; + warning: number; + failed: number; + running: number; + unknown: number; + attention: number; +}; + +export function buildTrueNASProtectionPosture( + points: readonly RecoveryPoint[], +): TrueNASProtectionPosture { + const posture: TrueNASProtectionPosture = { + healthy: 0, + warning: 0, + failed: 0, + running: 0, + unknown: 0, + attention: 0, + }; + for (const point of points) { + const status = mapTrueNASProtectionStatus(point); + if (status === 'success') posture.healthy += 1; + else posture[status] += 1; + } + posture.attention = posture.warning + posture.failed; + return posture; +} + export function mapTrueNASProtectionKind(point: RecoveryPoint): TrueNASProtectionKind { const kind = normalize(point.kind); const mode = normalize(point.mode); @@ -809,7 +827,11 @@ export function filterTrueNASProtectionPoints( ): RecoveryPoint[] { const needle = search.trim().toLowerCase(); return points.filter((point) => { - if (status !== 'all' && mapTrueNASProtectionStatus(point) !== status) return false; + const pointStatus = mapTrueNASProtectionStatus(point); + if (status === 'attention' && pointStatus !== 'warning' && pointStatus !== 'failed') { + return false; + } + if (status !== 'all' && status !== 'attention' && pointStatus !== status) return false; if (!needle) return true; if (mapTrueNASProtectionKind(point).includes(needle)) return true; return trueNASProtectionSearchTokens(point).join(' ').toLowerCase().includes(needle); diff --git a/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx b/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx index 98b786dfa..4bc1e054a 100644 --- a/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx +++ b/frontend-modern/src/features/vmware/VsphereAlertsTable.tsx @@ -1,4 +1,6 @@ import { For, Show, type Component, type JSX } from 'solid-js'; +import { Button } from '@/components/shared/Button'; +import { PlatformAttentionSummary } from '@/features/platformPage/PlatformAttentionSummary'; import { InlineDetailPanel, compactDetailRows, @@ -37,12 +39,13 @@ import { } from '@/utils/alertSeverityPresentation'; import { filterVmwareIncidents, + buildVmwareHealthPosture, type VmwareIncidentRow, type VmwareIncidentSeverityFilter, } from './vmwarePageModel'; const VSPHERE_INCIDENT_STATUS_OPTIONS = - getPlatformAlertSeverityFilterOptions(); + getPlatformAlertSeverityFilterOptions({ includeAttention: true }); type AlertDetailSection = DetailSection; @@ -119,6 +122,7 @@ export const VsphereAlertsTable: Component<{ filter: filterVmwareIncidents, }); const drawer = createPlatformResourceDetailState({ idPrefix: 'vsphere-alert-drawer' }); + const posture = () => buildVmwareHealthPosture(props.incidents); const filteredEmptyState = () => getAlertFilteredEmptyState('vSphere health signals', 'severity'); return ( @@ -133,6 +137,30 @@ export const VsphereAlertsTable: Component<{ } > + 0}> + 0 ? 'danger' : 'warning'} + metrics={[ + { label: 'critical', value: posture().critical }, + { label: 'warning', value: posture().warning }, + { label: 'resources', value: posture().affectedResources }, + ]} + actions={ + + tableState.setStatus(tableState.status() === 'attention' ? 'all' : 'attention') + } + > + {tableState.status() === 'attention' ? 'Show all signals' : 'Show attention'} + + } + /> + { ).toBeInTheDocument(); expect(screen.getByText('lab-vcenter')).toBeInTheDocument(); expect(screen.getByText('host-101')).toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'vSphere attention' })).toHaveTextContent( + '1 health signal needs review', + ); + + await fireEvent.click(screen.getByRole('button', { name: 'Show attention' })); + expect(screen.getByRole('button', { name: 'Show all signals' })).toBeInTheDocument(); const row = screen .getByText('Host host-101 has VMware alarm Host connection and power state (red)') diff --git a/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts b/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts index 0ae987da6..17b7d500a 100644 --- a/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts +++ b/frontend-modern/src/features/vmware/__tests__/vmwarePageModel.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import type { Resource } from '@/types/resource'; import { VMWARE_TAB_SPECS, + buildVmwareHealthPosture, buildVmwarePageModel, filterVmwareActivity, filterVmwareDatastores, @@ -47,7 +48,9 @@ describe('vmwarePageModel', () => { }); it('shows vSphere workflow tabs only when their inventory or signal exists', () => { - const hostOnlyModel = buildVmwarePageModel([makeResource({ id: 'esxi-host-1', type: 'agent' })]); + const hostOnlyModel = buildVmwarePageModel([ + makeResource({ id: 'esxi-host-1', type: 'agent' }), + ]); const fullModel = buildVmwarePageModel( [ makeResource({ id: 'esxi-host-1', type: 'agent' }), @@ -412,6 +415,14 @@ describe('vmwarePageModel', () => { expect( filterVmwareIncidents(rows, 'production', 'critical').map((row) => row.resourceId), ).toEqual(['host-alarm']); + expect(filterVmwareIncidents(rows, '', 'attention')).toHaveLength(2); + expect(buildVmwareHealthPosture(rows)).toEqual({ + critical: 1, + warning: 1, + info: 0, + attention: 2, + affectedResources: 2, + }); }); it('builds and filters vSphere activity from VMware resource changes', () => { diff --git a/frontend-modern/src/features/vmware/vmwarePageModel.ts b/frontend-modern/src/features/vmware/vmwarePageModel.ts index 7fbc13a2b..dc72da71c 100644 --- a/frontend-modern/src/features/vmware/vmwarePageModel.ts +++ b/frontend-modern/src/features/vmware/vmwarePageModel.ts @@ -5,21 +5,15 @@ import type { Resource, ResourceChange, ResourceIncident, ResourceType } from '@ export type VmwarePageTabId = 'overview' | 'storage' | 'networks' | 'health' | 'activity'; export type VmwareDatastoreStatusFilter = - | 'all' - | 'accessible' - | 'attention' - | 'inaccessible' - | 'maintenance' - | 'unknown'; + 'all' | 'accessible' | 'attention' | 'inaccessible' | 'maintenance' | 'unknown'; export type VmwareVirtualMachineStatusFilter = - | 'all' - | 'powered-on' - | 'attention' - | 'powered-off' - | 'suspended' - | 'unknown'; + 'all' | 'powered-on' | 'attention' | 'powered-off' | 'suspended' | 'unknown'; export type VmwareNetworkStatusFilter = 'all' | 'healthy' | 'attention' | 'unknown'; -export type VmwareIncidentSeverityFilter = 'all' | 'critical' | 'warning' | 'info'; +export type VmwareIncidentSeverityFilter = 'all' | 'attention' | 'critical' | 'warning' | 'info'; +export type VmwareIncidentSeverityBucket = Exclude< + VmwareIncidentSeverityFilter, + 'all' | 'attention' +>; export type VmwareActivityStatusFilter = 'all' | 'tasks' | 'events' | 'failed'; export type VmwareActivityKind = 'task' | 'event' | 'activity'; export type VmwareActivityStateBucket = 'success' | 'running' | 'failed' | 'unknown'; @@ -66,7 +60,7 @@ export type VmwareIncidentRow = { entityType: string; managedObjectId: string; severity: string; - severityBucket: Exclude; + severityBucket: VmwareIncidentSeverityBucket; code: string; source: string; summary: string; @@ -296,7 +290,7 @@ const incidentSeverityRank = (severity: string): number => { export function mapVmwareIncidentSeverity( severity: string | undefined, -): Exclude { +): VmwareIncidentSeverityBucket { const normalized = normalize(severity); if (['critical', 'crit', 'fatal', 'error', 'failed', 'failure', 'red'].includes(normalized)) { return 'critical'; @@ -306,7 +300,10 @@ export function mapVmwareIncidentSeverity( } export function normalizeVmwarePowerStateToken(value: string | undefined): string { - return (value || '').trim().toLowerCase().replace(/[\s_-]/g, ''); + return (value || '') + .trim() + .toLowerCase() + .replace(/[\s_-]/g, ''); } export function formatVmwarePowerState(value: string | undefined): string { @@ -514,6 +511,36 @@ export function buildVmwareIncidentRows(resources: Resource[]): VmwareIncidentRo }); } +export type VmwareHealthPosture = { + critical: number; + warning: number; + info: number; + attention: number; + affectedResources: number; +}; + +export function buildVmwareHealthPosture( + incidents: readonly VmwareIncidentRow[], +): VmwareHealthPosture { + const posture: VmwareHealthPosture = { + critical: 0, + warning: 0, + info: 0, + attention: 0, + affectedResources: 0, + }; + const affectedResources = new Set(); + for (const incident of incidents) { + posture[incident.severityBucket] += 1; + if (incident.severityBucket === 'critical' || incident.severityBucket === 'warning') { + posture.attention += 1; + affectedResources.add(incident.resourceId); + } + } + posture.affectedResources = affectedResources.size; + return posture; +} + const isVmwareActivityChange = (change: ResourceChange): boolean => { if (change.kind !== 'activity') return false; if (trimString(change.sourceAdapter) === 'vmware_adapter') return true; @@ -944,7 +971,16 @@ export function filterVmwareIncidents( ): VmwareIncidentRow[] { const needle = normalize(search); return incidents.filter((incident) => { - if (severity !== 'all' && incident.severityBucket !== severity) return false; + if ( + severity === 'attention' && + incident.severityBucket !== 'critical' && + incident.severityBucket !== 'warning' + ) { + return false; + } + if (severity !== 'all' && severity !== 'attention' && incident.severityBucket !== severity) { + return false; + } if (!needle) return true; return incidentSearchHaystack(incident).includes(needle); });
{props.description}