mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Improve platform attention workflows
This commit is contained in:
@@ -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} />',
|
||||
);
|
||||
|
||||
+4
@@ -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(
|
||||
|
||||
+3
-1
@@ -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();
|
||||
});
|
||||
});
|
||||
+12
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user