Improve platform attention workflows

This commit is contained in:
rcourtman
2026-07-10 00:00:07 +01:00
parent 44da863e1a
commit 85b2bc4008
25 changed files with 729 additions and 139 deletions
+7 -1
View File
@@ -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,
});
@@ -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(
'<Route path={`${ROOT_PATROL_PATH}/*`} component={AIIntelligencePage} />',
);
@@ -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(
@@ -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) => {
@@ -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<string | null>(null);
const [selectedPlatform, setSelectedPlatform] = createSignal<string | null>(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();
@@ -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 });
}
});
@@ -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<DockerNativeTableProps> = (props) => {
const updateToneClass: Record<DockerImageUpdateTone, string> = {
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<DockerNativeTableProps> = (props) => {
<PlatformTableToolbar
search={tableState.search}
onSearchChange={tableState.setSearch}
searchPlaceholder="Search images"
searchPlaceholder="Search image, host, or update state"
status={tableState.status()}
onStatusChange={tableState.setStatus}
statusOptions={PLATFORM_HEALTH_FILTER_OPTIONS}
@@ -74,32 +86,25 @@ export const DockerImagesTable: Component<DockerNativeTableProps> = (props) => {
>
<PlatformTableShell
title={props.title ?? 'Images'}
tableClass="min-w-full table-fixed text-xs md:min-w-[1120px]"
tableClass="min-w-full table-fixed text-xs md:min-w-[880px]"
header={
<>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[24%]`}>
<TableHead class={`${getPlatformTableHeadClassForKind('name')} md:w-[30%]`}>
Image
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[22%]`}>
Tags
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[18%]`}>
Host
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('text')} md:w-[24%]`}>
Used by
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[24%]`}
>
Digests
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[10%]`}
class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[12%]`}
>
Size
</TableHead>
<TableHead class={`${getPlatformTableHeadClassForKind('numeric-value')} md:w-[8%]`}>
In Use
</TableHead>
<TableHead
class={`${getPlatformTableHeadClassForKind('text')} hidden md:table-cell md:w-[12%]`}
>
Host
<TableHead class={`${getPlatformTableHeadClassForKind('badge')} md:w-[16%]`}>
Update check
</TableHead>
</>
}
@@ -107,6 +112,11 @@ export const DockerImagesTable: Component<DockerNativeTableProps> = (props) => {
<>
<For each={tableState.filtered()}>
{(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<DockerNativeTableProps> = (props) => {
<TableCell
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span
class="inline-block max-w-[18rem] truncate"
title={dockerJoinValues(resource.docker?.repoTags)}
>
{dockerJoinValues(resource.docker?.repoTags)}
</span>
{dockerHostName(resource)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
class={`${getPlatformTableCellClassForKind('text')} text-base-content`}
>
<span
class="inline-block max-w-[22rem] truncate"
title={dockerJoinValues(resource.docker?.repoDigests)}
class="inline-block max-w-[20rem] truncate"
title={operational().consumerSummary}
>
{dockerJoinValues(resource.docker?.repoDigests)}
{operational().consumerSummary}
</span>
</TableCell>
<TableCell
@@ -156,22 +161,20 @@ export const DockerImagesTable: Component<DockerNativeTableProps> = (props) => {
>
{dockerByteValue(resource.docker?.sizeBytes)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('numeric-value')} text-base-content`}
>
{dockerNumberValue(resource.docker?.imageContainers)}
</TableCell>
<TableCell
class={`${getPlatformTableCellClassForKind('text')} hidden text-base-content md:table-cell`}
>
{dockerHostName(resource)}
<TableCell class={getPlatformTableCellClassForKind('badge')}>
<span
class={`inline-flex rounded-full px-2 py-0.5 text-[10px] font-medium ${updateToneClass[operational().updateTone]}`}
title={operational().updateDetail}
>
{operational().updateLabel}
</span>
</TableCell>
</TableRow>
<PlatformResourceDetailTableRow
resource={resource}
open={isExpanded()}
detailRowId={detailRowId()}
colSpan={6}
colSpan={5}
resolveResourceLabel={resolveResourceLabel}
onClose={() => drawer.close(resource)}
/>
@@ -161,6 +161,7 @@ export function DockerPageSurface() {
<Show when={activeTab() === 'images'}>
<DockerImagesTable
resources={model().images}
relatedContainers={model().containers}
emptyIcon={dockerIcon()}
emptyTitle="No images"
emptyDescription="Images appear here when a Docker or Podman host reports local image inventory."
@@ -752,6 +752,21 @@ describe('Docker native tables', () => {
},
}),
]}
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={<span />}
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();
});
@@ -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<string> =>
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<string>): 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<typeof state> => 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',
};
}
@@ -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 (
<Show
@@ -424,8 +426,7 @@ function KubernetesServices(props: { model: KubernetesPageModel }) {
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 (
<Show
@@ -495,8 +496,7 @@ function KubernetesConfiguration(props: { model: KubernetesPageModel }) {
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 (
<Show
@@ -553,8 +553,54 @@ function KubernetesConfiguration(props: { model: KubernetesPageModel }) {
}
function KubernetesOverview(props: KubernetesOverviewProps) {
const posture = createMemo(() => 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 (
<div class="space-y-4">
<Show when={needsAttention()}>
<PlatformAttentionSummary
title="Kubernetes attention"
headline={headline()}
description="Start with unavailable nodes, pending pods, or deployments below their desired replica count. Inventory remains available below."
tone={
posture().criticalResources > 0 || posture().criticalIncidents > 0
? 'danger'
: 'warning'
}
metrics={[
{ label: 'nodes', value: posture().nodeAttention },
{ label: 'workloads', value: workloadAttention() },
{ label: 'signals', value: posture().attentionSignals },
]}
actions={
<>
<Show when={posture().nodeAttention > 0}>
<ButtonLink href={buildKubernetesPath('nodes')} variant="secondary" size="sm">
Review nodes
</ButtonLink>
</Show>
<Show when={workloadAttention() > 0}>
<ButtonLink href={buildKubernetesPath('workloads')} variant="secondary" size="sm">
Review workloads
</ButtonLink>
</Show>
</>
}
/>
</Show>
<KubernetesClustersTable
clusters={props.model().clusters}
scope={props.model().resources}
@@ -564,12 +610,14 @@ function KubernetesOverview(props: KubernetesOverviewProps) {
showToolbar={false}
/>
<Show when={props.model().incidents.length > 0}>
<KubernetesAlertsTable
incidents={props.model().incidents}
emptyIcon={k8sIcon()}
emptyTitle="No active Kubernetes alerts"
emptyDescription="Kubernetes health alerts appear here when the Pulse alert engine reports active workload, node, or cluster incidents."
/>
<div id="kubernetes-health-signals">
<KubernetesAlertsTable
incidents={props.model().incidents}
emptyIcon={k8sIcon()}
emptyTitle="No active Kubernetes alerts"
emptyDescription="Kubernetes health alerts appear here when the Pulse alert engine reports active workload, node, or cluster incidents."
/>
</div>
</Show>
</div>
);
@@ -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', () => {
@@ -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
@@ -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<PlatformAttentionSummaryTone, string> = {
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 (
<TableCard
class={`border ${toneClasses[props.tone]}`}
role="region"
aria-label={props.title}
data-platform-attention-summary={props.tone}
>
<div class="flex flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="flex items-center gap-2">
<StatusDot
size="sm"
variant={toneVariant(props.tone)}
title={props.headline}
ariaHidden
/>
<span class="text-[11px] font-semibold uppercase tracking-wide text-muted">
{props.title}
</span>
<span class="text-sm font-semibold text-base-content">{props.headline}</span>
</div>
<p class="mt-1 text-xs leading-5 text-muted">{props.description}</p>
</div>
<div class="flex flex-wrap items-center gap-3 sm:justify-end">
<For each={props.metrics ?? []}>
{(metric) => (
<div class="min-w-14 text-right">
<div class="text-sm font-semibold tabular-nums text-base-content">
{metric.value}
</div>
<div class="text-[10px] uppercase tracking-wide text-muted">{metric.label}</div>
</div>
)}
</For>
<Show when={props.actions}>
<div class="flex flex-wrap items-center gap-2">{props.actions}</div>
</Show>
</div>
</div>
</TableCard>
);
}
export default PlatformAttentionSummary;
@@ -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(() => (
<PlatformAttentionSummary
title="Platform attention"
headline="2 resources need review"
description="Review the affected resources first."
tone="warning"
metrics={[
{ label: 'critical', value: 0 },
{ label: 'warning', value: 2 },
]}
actions={<button type="button">Show attention</button>}
/>
));
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();
});
});
@@ -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',
]);
});
});
@@ -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<PlatformAlertSeverityFilterValue>[] =
[
@@ -26,8 +27,23 @@ const PLATFORM_ALERT_SEVERITY_FILTER_OPTIONS: PlatformTableFilterOption<Platform
},
];
const PLATFORM_ALERT_ATTENTION_FILTER_OPTION: PlatformTableFilterOption<PlatformAlertSeverityFilterValue> =
{
value: 'attention',
label: 'Attention',
tone: 'warning',
leading: filterChipStatusDot('bg-amber-500'),
};
export function getPlatformAlertSeverityFilterOptions<
TFilter extends PlatformAlertSeverityFilterValue,
>(): PlatformTableFilterOption<TFilter>[] {
return PLATFORM_ALERT_SEVERITY_FILTER_OPTIONS as PlatformTableFilterOption<TFilter>[];
>(options: { includeAttention?: boolean } = {}): PlatformTableFilterOption<TFilter>[] {
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<TFilter>[];
}
@@ -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<TrueNASProtectionStatusFilter>[] =
[
{ 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<TrueNASProtectionStatusFilter, 'all'>,
): 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<{
}
>
<div class="space-y-3">
<Show when={posture().attention > 0 || posture().running > 0}>
<PlatformAttentionSummary
title="Protection posture"
headline={
posture().attention > 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={
<Show when={posture().attention > 0}>
<Button
variant="secondary"
size="sm"
onClick={() =>
tableState.setStatus(
tableState.status() === 'attention' ? 'all' : 'attention',
)
}
>
{tableState.status() === 'attention' ? 'Show all events' : 'Show issues'}
</Button>
</Show>
}
/>
</Show>
<Show when={props.showToolbar !== false}>
<PlatformTableToolbar
search={tableState.search}
@@ -19,6 +19,40 @@ afterEach(() => {
});
describe('TrueNASProtectionTable', () => {
it('summarizes protection issues and filters directly to attention outcomes', async () => {
render(() => (
<TrueNASProtectionTable
points={[
makeRecoveryPoint({ id: 'healthy', kind: 'snapshot', mode: 'snapshot' }),
makeRecoveryPoint({
id: 'failed',
kind: 'backup',
mode: 'remote',
outcome: 'failed',
}),
makeRecoveryPoint({
id: 'running',
kind: 'backup',
mode: 'remote',
outcome: 'running',
}),
]}
emptyIcon={<span />}
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',
@@ -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', () => {
@@ -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<TrueNASProtectionStatusFilter, 'all'> => {
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<TrueNASProtectionStatusFilter, 'all'> {
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);
@@ -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<VmwareIncidentSeverityFilter>();
getPlatformAlertSeverityFilterOptions<VmwareIncidentSeverityFilter>({ 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<{
}
>
<div class="space-y-3">
<Show when={posture().attention > 0}>
<PlatformAttentionSummary
title="vSphere attention"
headline={`${posture().attention} health signal${posture().attention === 1 ? '' : 's'} ${posture().attention === 1 ? 'needs' : 'need'} review`}
description="Review affected resources before opening lower-priority informational signals. Provider identifiers remain available in each row's detail."
tone={posture().critical > 0 ? 'danger' : 'warning'}
metrics={[
{ label: 'critical', value: posture().critical },
{ label: 'warning', value: posture().warning },
{ label: 'resources', value: posture().affectedResources },
]}
actions={
<Button
variant="secondary"
size="sm"
onClick={() =>
tableState.setStatus(tableState.status() === 'attention' ? 'all' : 'attention')
}
>
{tableState.status() === 'attention' ? 'Show all signals' : 'Show attention'}
</Button>
}
/>
</Show>
<Show when={props.showToolbar !== false}>
<PlatformTableToolbar
search={tableState.search}
@@ -69,6 +69,12 @@ describe('VsphereAlertsTable', () => {
).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)')
@@ -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', () => {
@@ -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<VmwareIncidentSeverityFilter, 'all'>;
severityBucket: VmwareIncidentSeverityBucket;
code: string;
source: string;
summary: string;
@@ -296,7 +290,7 @@ const incidentSeverityRank = (severity: string): number => {
export function mapVmwareIncidentSeverity(
severity: string | undefined,
): Exclude<VmwareIncidentSeverityFilter, 'all'> {
): 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<string>();
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);
});