Remove the Home tab and page, keep the fleet health verdict engine

The fleet health Home surface reached main on 2026-09-01 by implementing
a July agent-handoff spec with no demand-ledger entry. Exercised live on
the 1,510-resource mock estate it rendered 572 tiles on first paint with
275 in "Needs attention", 232 of them backup age, against two real
criticals; per-node storage mounts, disks, and swarm secrets appeared as
fleet members with no host context; and the critical node tile opened
its platform overview with the node below the fold. No stable release
carries it yet, so remove the route, navigation tab, page, i18n keys,
Assistant page context, route preload, and e2e cases now rather than
spend lane turns polishing an ungraded bet.

The server-side ResourceHealth verdict, its API projection, and the
/api/state/summary verdicts and attention extension stay: they are one
canonical health boundary that #1478 asked for and that any future
surface should read. The spec is parked with the 2026-09-01 review kept
as the design record.

Browser proof: desktop and 375px shells show no Home entry, /home renders
the not-found surface and its Go to workspace button lands on the
default workspace, the narrow More sheet lists Settings only, no console
errors or horizontal overflow.
This commit is contained in:
rcourtman
2026-09-01 20:54:21 +01:00
parent 2f8a4ec629
commit feff95934c
22 changed files with 58 additions and 1354 deletions
@@ -1,7 +1,7 @@
# Home Status Wall Spec
Last updated: 2026-09-01 (review appended)
Status: IMPLEMENTED, REVISION REQUIRED (tab-reachable; default-route gate stays closed until R1 to R5 below land and are re-exercised in a browser)
Status: PARKED (not a current signal; reactivate only through a FEATURE_REQUESTS.md ledger entry). Last reviewed 2026-09-01. The tab, route, and page were removed from `main` on 2026-09-01 before any stable release carried them; the server-side verdict engine and the `/api/state/summary` extension from Workstream A stay. R1 to R6 below are the design record if this is ever revived.
Owner of record: Richard (product decisions), implementing agent (execution)
Related evidence: GitHub issues #1478, #1433, #1460
+44 -16
View File
@@ -1,36 +1,64 @@
{
"version": 1,
"base_sha": "b1240c6ca3b56bfa48317c5e1013eac150496b14",
"verified_at": "2026-09-01T14:51:26Z",
"base_sha": "2f8a4ec629b75c7ecefd38a9abc4b1511bc9a891",
"verified_at": "2026-09-01T19:54:19Z",
"result": "passed",
"changed_paths": [
"frontend-modern/src/utils/agentInstallCommand.ts"
"frontend-modern/src/App.tsx",
"frontend-modern/src/AppLayout.tsx",
"frontend-modern/src/components/shared/MobileNavBar.tsx",
"frontend-modern/src/components/shared/mobileNavBarModel.ts",
"frontend-modern/src/features/home/HomePageSurface.tsx",
"frontend-modern/src/features/home/homePageModel.ts",
"frontend-modern/src/i18n/messages.de.ts",
"frontend-modern/src/i18n/messages.es.ts",
"frontend-modern/src/i18n/messages.ts",
"frontend-modern/src/routing/navigation.ts",
"frontend-modern/src/routing/resourceLinks.ts",
"frontend-modern/src/routing/routePreload.ts",
"frontend-modern/src/utils/assistantPageContext.ts"
],
"content_sha256": {
"frontend-modern/src/utils/agentInstallCommand.ts": "5c9157c99245a5a202d4383f31c19e0115db7b488e1d920541b7d541aba5eb06"
"frontend-modern/src/App.tsx": "d5473f7838148edeaaf5564f01c166d97b6086232dcba70f6296b5c58e563a4b",
"frontend-modern/src/AppLayout.tsx": "db685903fef2a1509edd812acb3904f7ff3ffab0bbd1af2bff183c1b3ae8e373",
"frontend-modern/src/components/shared/MobileNavBar.tsx": "27358f3e77267cc111ce97b32d715b0069e2d1370ddd2b3ecc4f2d34a3e6dac5",
"frontend-modern/src/components/shared/mobileNavBarModel.ts": "ab9e69d379579d02e33a2abd1224d8c13e6543dd6aa0ad40cc1f65697a289d04",
"frontend-modern/src/features/home/HomePageSurface.tsx": "deleted",
"frontend-modern/src/features/home/homePageModel.ts": "deleted",
"frontend-modern/src/i18n/messages.de.ts": "602246d3d4ce11a1a8a914027d950a30ba1f2f25850c385836013853957c2d0c",
"frontend-modern/src/i18n/messages.es.ts": "ed5a749603efad29293cd6964215f6edc21a4ce14bca99a48429ddd120770263",
"frontend-modern/src/i18n/messages.ts": "43a757e00eaa7879c400895c9c59a930e31513dbf03e0ae6d60f7e72a6ad9962",
"frontend-modern/src/routing/navigation.ts": "8ae1ad012e60ef345ffb3d18bb66f5d8af056758109655a8ec362ebf4c7b9556",
"frontend-modern/src/routing/resourceLinks.ts": "dee9a426de785e23390ba49c9067f55c8f923cecfce1247ff18f9b1004e0cc90",
"frontend-modern/src/routing/routePreload.ts": "ee79d423db0afcf8d76d1d39da59a13cb2c98e908e516a016cbb425982b25873",
"frontend-modern/src/utils/assistantPageContext.ts": "bfca19b4e183777ee1535073d48cce92ce31980f4105bab337f33a28538a6a35"
},
"routes": [
"/settings/infrastructure"
"/",
"/home",
"/proxmox/overview"
],
"viewports": [
{
"width": 1440,
"height": 900
"width": 1280,
"height": 720
},
{
"width": 390,
"height": 844
"width": 375,
"height": 812
}
],
"states": [
"Add Pulse Agent flow after a fresh local install token was generated",
"default Linux install command with root and sudo elevation branches",
"desktop and narrow command layouts with no observed horizontal page overflow"
"signed-in desktop shell with primary tabs Proxmox, Docker, Kubernetes, TrueNAS, vSphere, Machines and no Home tab",
"/home renders the Page Not Found surface with 'No route matched /home' and a Go to workspace button",
"signed-in narrow shell on /proxmox/overview with the bottom navigation bar showing Proxmox, Alerts, Patrol, Actions, More and no Home entry",
"More navigation sheet open at narrow width listing Settings only",
"no horizontal page overflow at 375px; no console errors at either width"
],
"interactions": [
"opened Add Pulse Agent from Settings > Infrastructure",
"generated a fresh local agent token and dismissed the one-time token dialog",
"copied the generated install command at desktop and narrow widths",
"verified the copied command uses a private /tmp/pulse-agent-bootstrap.XXXXXX directory, --token-file, preflight, and sudo fallback"
"navigated to / and /home at desktop width and read the rendered nav and main headings",
"pressed Go to workspace on the /home not-found page and landed on /proxmox/overview",
"resized to 375x812, loaded /proxmox/overview, opened the More navigation sheet, closed it with Escape",
"confirmed the default landing route and nav order are unchanged from the parent revision apart from the removed Home entry"
]
}
-3
View File
@@ -42,7 +42,6 @@ import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { useKioskMode } from '@/hooks/useKioskMode';
import {
DOCKER_PATH,
HOME_PATH,
KUBERNETES_PATH,
PATROL_PATH,
PROXMOX_PATH,
@@ -101,7 +100,6 @@ function isWorkspaceEntryRoutePath(pathname: string): boolean {
const AlertsPage = lazy(() =>
import('./pages/Alerts').then((module) => ({ default: module.Alerts })),
);
const HomePage = lazy(() => import('./features/home/HomePageSurface'));
const ActionsPage = lazy(() => import('./pages/Actions'));
const SettingsPage = lazy(() => import('./components/Settings/Settings'));
const ProxmoxPage = lazy(() => import('./pages/Proxmox'));
@@ -627,7 +625,6 @@ function App() {
<Route path="/login" component={RuntimeHomePage} />
<Route path="/" component={RuntimeHomePage} />
<Route path="/infrastructure" component={RuntimeHomePage} />
<Route path={HOME_PATH} component={HomePage} />
<Route path={PROXMOX_PATH} component={ProxmoxPage} />
<Route path={`${PROXMOX_PATH}/*`} component={ProxmoxPage} />
<Route path={DOCKER_PATH} component={DockerPage} />
+5 -22
View File
@@ -8,7 +8,6 @@ import SettingsIcon from 'lucide-solid/icons/settings';
import Maximize2Icon from 'lucide-solid/icons/maximize-2';
import Minimize2Icon from 'lucide-solid/icons/minimize-2';
import SparklesIcon from 'lucide-solid/icons/sparkles';
import HouseIcon from 'lucide-solid/icons/house';
import { getPlatformIcon } from '@/features/platformPage/platformIcon';
import {
MobileNavBar,
@@ -43,7 +42,6 @@ import { getActiveTabForPath } from '@/routing/navigation';
import { preloadRouteModule } from '@/routing/routePreload';
import {
DOCKER_PATH,
HOME_PATH,
KUBERNETES_PATH,
PROXMOX_PATH,
STANDALONE_PATH,
@@ -68,7 +66,6 @@ import { presentationPolicyHidesUpgradePrompts } from '@/stores/sessionPresentat
import { getAssistantPageContext } from '@/utils/assistantPageContext';
import type { AppConnectionStatus } from '@/useAppRuntimeState';
import { buildInfrastructureWorkspacePath } from '@/components/Settings/infrastructureWorkspaceModel';
import { t } from '@/i18n';
const ROOT_PROXMOX_PATH = buildProxmoxPath();
const ROOT_DOCKER_PATH = buildDockerPath();
@@ -113,11 +110,9 @@ function resolvePrimaryNavigationRoute(tab: PrimaryTab, routeMemory: PrimaryRout
if (!tab.enabled) {
return tab.settingsRoute;
}
if (isPrimaryPlatformNavId(tab.id)) {
const remembered = routeMemory[tab.id];
if (remembered && routeBelongsToPrimaryTab(remembered, tab.id)) {
return remembered;
}
const remembered = routeMemory[tab.id as PrimaryPlatformNavId];
if (remembered && routeBelongsToPrimaryTab(remembered, tab.id as PrimaryPlatformNavId)) {
return remembered;
}
return tab.route;
}
@@ -302,7 +297,6 @@ export function AppLayout(props: AppLayoutProps) {
// identify the current Pulse surface instead of every page reading
// as the bare app name.
const tabTitleByActive: Record<NonNullable<ReturnType<typeof getActiveTabForPath>>, string> = {
home: 'Home',
proxmox: 'Proxmox',
docker: 'Docker',
kubernetes: 'Kubernetes',
@@ -505,17 +499,6 @@ export function AppLayout(props: AppLayoutProps) {
const isVisible = (id: PrimaryTab['id']) =>
primaryPlatformNavigationIsVisible(visible, id as PrimaryPlatformNavId);
const allPrimaryTabs: PrimaryTab[] = [
{
id: 'home',
label: t('home.title'),
route: HOME_PATH,
settingsRoute: HOME_PATH,
tooltip: t('home.nav.tooltip'),
enabled: true,
live: true,
icon: HouseIcon,
alwaysShow: true,
},
{
id: 'proxmox',
label: 'Proxmox',
@@ -959,7 +942,7 @@ export function AppLayout(props: AppLayoutProps) {
</span>
)}
{tab.breakdown && tab.breakdown.warning > 0 && (
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-900/100 dark:text-amber-950/100 bg-amber-200 dark:bg-amber-500 rounded-full">
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-900 dark:text-amber-100 bg-amber-200 dark:bg-amber-500 rounded-full">
{tab.breakdown.warning}
</span>
)}
@@ -967,7 +950,7 @@ export function AppLayout(props: AppLayoutProps) {
);
}
return (
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-900/100 dark:text-amber-950/100 bg-amber-200 dark:bg-amber-500 rounded-full">
<span class="inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 text-[10px] font-semibold text-amber-900 dark:text-amber-100 bg-amber-200 dark:bg-amber-500 rounded-full">
{total}
</span>
);
@@ -291,7 +291,7 @@ describe('AppLayout navigation icons', () => {
within(infrastructureGroup as HTMLElement)
.getAllByRole('link')
.map((tab) => tab.getAttribute('aria-label')),
).toEqual(['Home', 'Proxmox', 'Docker', 'vSphere', 'Machines']);
).toEqual(['Proxmox', 'Docker', 'vSphere', 'Machines']);
expect(
within(infrastructureGroup as HTMLElement).getByRole('link', { name: 'Machines' }),
).toBeTruthy();
@@ -313,14 +313,14 @@ describe('AppLayout navigation icons', () => {
const mobileNav = screen.getByRole('navigation', { name: 'Mobile navigation' });
fireEvent.click(
within(mobileNav).getByRole('button', { name: 'Switch platform, current Home' }),
within(mobileNav).getByRole('button', { name: 'Switch platform, current Proxmox' }),
);
const platformMenu = screen.getByRole('menu', { name: 'Switch platform' });
expect(
within(platformMenu)
.getAllByRole('menuitem')
.map((item) => item.getAttribute('data-tab-id')),
).toEqual(['home', 'proxmox', 'docker', 'vmware', 'standalone']);
).toEqual(['proxmox', 'docker', 'vmware', 'standalone']);
});
it('does not expose cached platform evidence before navigation admission resolves', () => {
@@ -340,10 +340,7 @@ describe('AppLayout navigation icons', () => {
const desktopNav = screen.getByRole('navigation', { name: 'Primary navigation' });
const infrastructureGroup = desktopNav.querySelector('[aria-label="Infrastructure"]');
expect(infrastructureGroup).toBeTruthy();
expect(
within(infrastructureGroup as HTMLElement).getByRole('link', { name: 'Home' }),
).toHaveAttribute('href', '/home');
expect(within(infrastructureGroup as HTMLElement).getAllByRole('link')).toHaveLength(1);
expect(within(infrastructureGroup as HTMLElement).queryByRole('link')).toBeNull();
});
it('restores the previous Proxmox route state when returning from another platform tab', async () => {
@@ -55,7 +55,7 @@ function MobileNavDestinationContent(props: {
</span>
</Show>
<Show when={badges().warning > 0}>
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-900/100">
<span class="inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-900">
{badges().warning}
</span>
</Show>
@@ -66,7 +66,7 @@ function MobileNavDestinationContent(props: {
{(count) => (
<span
aria-hidden="true"
class="absolute -right-2 -top-1 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-900/100"
class="absolute -right-2 -top-1 inline-flex h-4 min-w-[16px] items-center justify-center rounded-full bg-amber-200 px-1 text-[10px] font-semibold text-amber-900"
>
{count()}
</span>
@@ -48,7 +48,6 @@ export type MobileNavBarLayout = {
};
const MOBILE_NAV_PRIMARY_PRIORITY = [
'home',
'proxmox',
'docker',
'kubernetes',
@@ -1,327 +0,0 @@
import { A } from '@solidjs/router';
import { For, Show, createMemo, createSignal } from 'solid-js';
import ChevronDownIcon from 'lucide-solid/icons/chevron-down';
import ChevronUpIcon from 'lucide-solid/icons/chevron-up';
import RefreshCwIcon from 'lucide-solid/icons/refresh-cw';
import { Button, ButtonLink } from '@/components/shared/Button';
import { LoadingSpinner } from '@/components/shared/LoadingSpinner';
import { PageHeader } from '@/components/shared/PageHeader';
import { StatusDot } from '@/components/shared/StatusDot';
import { useResources } from '@/hooks/useResources';
import { getActiveLocale, t } from '@/i18n';
import { buildInfrastructureWorkspacePath } from '@/components/Settings/infrastructureWorkspaceModel';
import type { ResourceHealthReason, ResourceHealthVerdict } from '@/types/resource';
import {
buildHomeAttentionTiles,
buildHomePosture,
buildHomeResourceGroups,
getHomeVerdictTone,
type HomePlatformKey,
type HomeResourceGroup,
type HomeResourceTile,
} from './homePageModel';
const VERDICT_CLASS: Record<ResourceHealthVerdict, string> = {
ok: 'border-emerald-300 bg-emerald-50 text-base-content dark:border-emerald-800 dark:bg-emerald-950/40',
attention:
'border-amber-400 bg-amber-50 text-base-content dark:border-amber-700 dark:bg-amber-950/40',
critical: 'border-red-400 bg-red-50 text-base-content dark:border-red-800 dark:bg-red-950/40',
stale:
'border-dashed border-slate-400 bg-slate-50 text-slate-800 dark:border-slate-600 dark:bg-slate-900/60 dark:text-slate-200',
off: 'border-border bg-surface text-muted',
unknown: 'border-dashed border-slate-400 bg-surface text-muted',
};
const platformLabel = (key: HomePlatformKey): string => {
switch (key) {
case 'proxmox':
return t('home.platform.proxmox');
case 'docker':
return t('home.platform.docker');
case 'kubernetes':
return t('home.platform.kubernetes');
case 'truenas':
return t('home.platform.truenas');
case 'vmware':
return t('home.platform.vmware');
case 'standalone':
return t('home.platform.standalone');
default:
return t('home.platform.other');
}
};
const verdictLabel = (verdict: ResourceHealthVerdict): string => {
switch (verdict) {
case 'ok':
return t('home.verdict.ok');
case 'attention':
return t('home.verdict.attention');
case 'critical':
return t('home.verdict.critical');
case 'stale':
return t('home.verdict.stale');
case 'off':
return t('home.verdict.off');
default:
return t('home.verdict.unknown');
}
};
const reasonLabel = (
reason: ResourceHealthReason | undefined,
verdict: ResourceHealthVerdict,
): string => {
if (!reason) return verdictLabel(verdict);
const detail = reason.detail ? ` ${reason.detail}` : '';
switch (reason.code) {
case 'critical_alert':
return t('home.reason.criticalAlert', { detail });
case 'warning_alert':
return t('home.reason.warningAlert', { detail });
case 'availability_failed':
return t('home.reason.availabilityFailed');
case 'offline':
return t('home.reason.offline');
case 'backup_stale':
return t('home.reason.backupStale', { detail });
case 'telemetry_stale':
return t('home.reason.telemetryStale', { detail });
case 'telemetry_missing':
return t('home.reason.telemetryMissing');
case 'powered_off':
return t('home.reason.poweredOff');
case 'degraded':
return t('home.reason.degraded');
default:
return verdictLabel(verdict);
}
};
function ResourceTile(props: { tile: HomeResourceTile }) {
const reason = () => reasonLabel(props.tile.reason, props.tile.verdict);
return (
<A
href={props.tile.href}
class={`group flex min-h-20 flex-col justify-between rounded-lg border p-3 transition-colors hover:brightness-[0.98] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 ${VERDICT_CLASS[props.tile.verdict]}`}
aria-label={t('home.tile.ariaLabel', {
name: props.tile.name,
status: verdictLabel(props.tile.verdict),
reason: reason(),
})}
>
<div class="flex min-w-0 items-start gap-2">
<StatusDot
variant={getHomeVerdictTone(props.tile.verdict)}
size="md"
class="mt-1.5"
ariaHidden
/>
<span class="min-w-0 break-words text-sm font-semibold leading-5">{props.tile.name}</span>
</div>
<span class="mt-2 pl-[18px] text-xs font-medium opacity-80">{reason()}</span>
</A>
);
}
function ResourceGroup(props: {
group: HomeResourceGroup;
expanded: () => boolean;
onToggle: () => void;
}) {
const resourceGridId = `home-group-${props.group.key}-resources`;
const displayedTiles = () =>
props.expanded() ? [...props.group.tiles, ...props.group.hiddenTiles] : props.group.tiles;
return (
<section class="space-y-3" aria-labelledby={`home-group-${props.group.key}`}>
<div class="flex items-end justify-between gap-3">
<h2 id={`home-group-${props.group.key}`} class="text-lg font-semibold">
{platformLabel(props.group.key)}
</h2>
<Show when={props.group.hiddenCount > 0}>
<button
type="button"
onClick={props.onToggle}
class="inline-flex min-h-11 items-center gap-1 rounded-md px-2 text-sm font-medium text-blue-600 hover:bg-surface-hover focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-blue-400"
aria-expanded={props.expanded()}
aria-controls={resourceGridId}
>
{props.expanded()
? t('home.group.showLess')
: t('home.group.showAll', { count: props.group.hiddenCount })}
<Show
when={props.expanded()}
fallback={<ChevronDownIcon class="h-4 w-4" aria-hidden="true" />}
>
<ChevronUpIcon class="h-4 w-4" aria-hidden="true" />
</Show>
</button>
</Show>
</div>
<div
id={resourceGridId}
class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
>
<For each={displayedTiles()}>{(tile) => <ResourceTile tile={tile} />}</For>
</div>
</section>
);
}
export default function HomePageSurface() {
const resources = useResources();
const [expandedGroups, setExpandedGroups] = createSignal<ReadonlySet<HomePlatformKey>>(new Set());
const posture = createMemo(() => buildHomePosture(resources.resources()));
const attentionTiles = createMemo(() => buildHomeAttentionTiles(resources.resources()));
const groups = createMemo(() => buildHomeResourceGroups(resources.resources()));
const refetch = () => resources.refetch().catch(() => undefined);
const newestTelemetry = createMemo(() => {
const timestamp = resources
.resources()
.reduce((latest, resource) => Math.max(latest, resource.lastSeen || 0), 0);
if (!timestamp) return t('home.updated.unknown');
return new Intl.DateTimeFormat(getActiveLocale(), {
dateStyle: 'medium',
timeStyle: 'short',
}).format(timestamp);
});
const postureText = createMemo(() => {
const value = posture();
if (value.total > 0 && value.ok === value.total) {
return t('home.posture.allHealthy', { total: value.total, updated: newestTelemetry() });
}
return t(
value.needsAttention === 1 ? 'home.posture.summary.singular' : 'home.posture.summary.plural',
{
attention: value.needsAttention,
healthy: value.ok,
total: value.total,
stale: value.stale,
unknown: value.unknown,
updated: newestTelemetry(),
},
);
});
const toggleGroup = (key: HomePlatformKey) => {
setExpandedGroups((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
return (
<div class="space-y-6">
<PageHeader
title={t('home.title')}
description={t('home.description')}
descriptionVisibility="always"
actions={
<Button
variant="ghost"
size="sm"
aria-label={t('home.refresh.ariaLabel')}
isLoading={resources.loading()}
onClick={() => void refetch()}
>
<RefreshCwIcon class="mr-2 h-4 w-4" aria-hidden="true" />
{t('home.refresh.label')}
</Button>
}
/>
<Show when={resources.loading() && resources.resources().length === 0}>
<div
class="flex items-center gap-2 rounded-lg border border-border bg-surface p-4 text-sm text-muted"
role="status"
>
<LoadingSpinner size="sm" />
{t('home.loading')}
</div>
</Show>
<Show when={resources.error() && resources.resources().length === 0}>
<div
class="rounded-lg border border-red-300 bg-red-50 p-4 text-red-900 dark:border-red-800 dark:bg-red-950/40 dark:text-red-100"
role="alert"
>
<p class="font-semibold">{t('home.error.title')}</p>
<p class="mt-1 text-sm">{t('home.error.description')}</p>
<Button class="mt-3" size="sm" onClick={() => void refetch()}>
{t('home.error.retry')}
</Button>
</div>
</Show>
<Show when={resources.error() && resources.resources().length > 0}>
<div
class="flex flex-col gap-3 rounded-lg border border-amber-300 bg-amber-50 p-4 text-base-content sm:flex-row sm:items-center sm:justify-between dark:border-amber-800 dark:bg-amber-950/40"
role="alert"
>
<div>
<p class="font-semibold">{t('home.error.cached.title')}</p>
<p class="mt-1 text-sm">{t('home.error.cached.description')}</p>
</div>
<Button
class="shrink-0 self-start sm:self-auto"
size="sm"
isLoading={resources.loading()}
onClick={() => void refetch()}
>
{t('home.error.retry')}
</Button>
</div>
</Show>
<Show when={!resources.loading() && !resources.error() && resources.resources().length === 0}>
<section
class="rounded-xl border border-border bg-surface p-6 text-center"
aria-labelledby="home-empty-title"
>
<h2 id="home-empty-title" class="text-lg font-semibold">
{t('home.empty.title')}
</h2>
<p class="mx-auto mt-2 max-w-xl text-sm text-muted">{t('home.empty.description')}</p>
<ButtonLink class="mt-4" href={buildInfrastructureWorkspacePath()}>
{t('home.empty.action')}
</ButtonLink>
</section>
</Show>
<Show when={resources.resources().length > 0}>
<p
class="border-y border-border py-3 text-sm font-medium text-base-content"
role="status"
aria-live="polite"
>
{postureText()}
</p>
<Show when={attentionTiles().length > 0}>
<section class="space-y-3" aria-labelledby="home-attention-title">
<div>
<h2 id="home-attention-title" class="text-lg font-semibold">
{t('home.attention.title')}
</h2>
<p class="text-sm text-muted">{t('home.attention.description')}</p>
</div>
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
<For each={attentionTiles()}>{(tile) => <ResourceTile tile={tile} />}</For>
</div>
</section>
</Show>
<For each={groups()}>
{(group) => (
<ResourceGroup
group={group}
expanded={() => expandedGroups().has(group.key)}
onToggle={() => toggleGroup(group.key)}
/>
)}
</For>
</Show>
</div>
);
}
@@ -1,139 +0,0 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@solidjs/testing-library';
import { Route, Router } from '@solidjs/router';
import { createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Resource } from '@/types/resource';
import HomePageSurface from '../HomePageSurface';
const resourcesMock = vi.hoisted(() => vi.fn());
vi.mock('@/hooks/useResources', () => ({
useResources: () => resourcesMock(),
}));
const resource = (overrides: Partial<Resource>): Resource => ({
id: 'host-1',
type: 'agent',
name: 'Host One',
displayName: 'Host One',
platformId: 'pve-1',
platformType: 'proxmox-pve',
sourceType: 'api',
status: 'online',
lastSeen: Date.now(),
health: { verdict: 'ok', reasons: [] },
...overrides,
});
const renderHome = (items: Resource[]) => {
resourcesMock.mockReturnValue({
resources: () => items,
loading: () => false,
error: () => undefined,
refetch: vi.fn(async () => items),
});
window.history.replaceState({}, '', '/home');
return render(() => (
<Router>
<Route path="/home" component={HomePageSurface} />
</Router>
));
};
describe('HomePageSurface', () => {
afterEach(() => {
cleanup();
resourcesMock.mockReset();
window.history.replaceState({}, '', '/');
});
it('presents attention before platform groups with descriptive resource links', () => {
renderHome([
resource({
id: 'critical-host',
name: 'Critical Host',
displayName: 'Critical Host',
health: { verdict: 'critical', reasons: [{ code: 'offline' }] },
}),
resource({ id: 'healthy-host', name: 'Healthy Host', displayName: 'Healthy Host' }),
]);
expect(screen.getByRole('heading', { level: 1, name: 'Home' })).toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Needs attention' })).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'Critical Host: Critical. Offline' }),
).toBeInTheDocument();
expect(
screen.getByRole('link', { name: 'Healthy Host: Healthy. Healthy' }),
).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Healthy Host: Healthy. Healthy' })).toHaveTextContent(
'Healthy',
);
});
it('warns when a refresh fails while retaining the last loaded resource state', async () => {
const [error, setError] = createSignal<unknown>();
const refetch = vi.fn(async () => {
const failure = new Error('refresh failed');
setError(failure);
throw failure;
});
resourcesMock.mockReturnValue({
resources: () => [resource({})],
loading: () => false,
error,
refetch,
});
window.history.replaceState({}, '', '/home');
render(() => (
<Router>
<Route path="/home" component={HomePageSurface} />
</Router>
));
await fireEvent.click(screen.getByRole('button', { name: 'Refresh fleet health' }));
await waitFor(() => expect(refetch).toHaveBeenCalledOnce());
expect(screen.getByRole('alert')).toHaveTextContent('Fleet health could not be refreshed');
expect(screen.getByRole('alert')).toHaveTextContent(
'Showing the last loaded data. Resource statuses may be out of date.',
);
expect(screen.getByRole('link', { name: 'Host One: Healthy. Healthy' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'Try again' })).toBeInTheDocument();
});
it('offers infrastructure onboarding rather than an unexplained blank page', () => {
renderHome([]);
expect(
screen.getByRole('heading', { name: 'Connect your first monitored system' }),
).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'Add infrastructure' })).toHaveAttribute(
'href',
'/settings/infrastructure',
);
});
it('identifies the resource region controlled by each group disclosure', async () => {
renderHome(
Array.from({ length: 61 }, (_, index) =>
resource({
id: `host-${index}`,
name: `Host ${index}`,
displayName: `Host ${index}`,
}),
),
);
const disclosure = screen.getByRole('button', { name: 'Show all (1)' });
expect(disclosure).toHaveAttribute('aria-expanded', 'false');
expect(disclosure).toHaveAttribute('aria-controls', 'home-group-proxmox-resources');
expect(document.getElementById('home-group-proxmox-resources')).toBeInTheDocument();
disclosure.focus();
await fireEvent.click(disclosure);
expect(disclosure).toHaveAttribute('aria-expanded', 'true');
expect(disclosure).toHaveFocus();
expect(screen.getByRole('link', { name: 'Host 60: Healthy. Healthy' })).toBeInTheDocument();
});
});
@@ -1,144 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { Resource, ResourceHealthVerdict } from '@/types/resource';
import {
HOME_HEALTHY_GROUP_LIMIT,
buildHomeAttentionTiles,
buildHomePosture,
buildHomeResourceGroups,
getHomeResourceHref,
getHomeVerdictTone,
} from '../homePageModel';
const resource = (
id: string,
verdict: ResourceHealthVerdict,
platformType: Resource['platformType'] = 'proxmox-pve',
): Resource => ({
id,
type: 'vm',
name: id,
displayName: id,
platformId: 'platform-1',
platformType,
sourceType: 'api',
status: verdict === 'off' ? 'stopped' : 'online',
lastSeen: Date.now(),
health: { verdict, reasons: verdict === 'ok' ? [] : [{ code: `${verdict}_reason` }] },
});
describe('homePageModel', () => {
it('counts every canonical verdict without treating powered-off workloads as failures', () => {
const posture = buildHomePosture([
resource('ok', 'ok'),
resource('critical', 'critical'),
resource('attention', 'attention'),
resource('stale', 'stale'),
resource('off', 'off'),
resource('unknown', 'unknown'),
]);
expect(posture).toMatchObject({
total: 6,
ok: 1,
critical: 1,
attention: 1,
stale: 1,
off: 1,
unknown: 1,
needsAttention: 2,
});
});
it('puts critical resources before attention resources and sorts names stably', () => {
const tiles = buildHomeAttentionTiles([
resource('z-warning', 'attention'),
resource('b-critical', 'critical'),
resource('a-critical', 'critical'),
]);
expect(tiles.map((tile) => tile.name)).toEqual(['a-critical', 'b-critical', 'z-warning']);
});
it('never hides stale or unknown resources behind the healthy group cap', () => {
const resources = Array.from({ length: HOME_HEALTHY_GROUP_LIMIT + 5 }, (_, index) =>
resource(`healthy-${index}`, 'ok'),
);
resources.push(resource('stale-first', 'stale'), resource('unknown-second', 'unknown'));
const [group] = buildHomeResourceGroups(resources);
expect(group?.tiles.slice(0, 2).map((tile) => tile.name)).toEqual([
'stale-first',
'unknown-second',
]);
expect(group?.hiddenCount).toBe(5);
expect(group?.hiddenTiles).toHaveLength(5);
});
it('groups resources in product navigation order and removes attention duplicates', () => {
const groups = buildHomeResourceGroups([
resource('vsphere', 'ok', 'vmware-vsphere'),
resource('docker', 'ok', 'docker'),
resource('urgent', 'critical', 'proxmox-pve'),
]);
expect(groups.map((group) => group.key)).toEqual(['docker', 'vmware']);
});
it('uses the shared status tones for every canonical verdict', () => {
expect(
['ok', 'attention', 'critical', 'stale', 'off', 'unknown'].map((verdict) =>
getHomeVerdictTone(verdict as ResourceHealthVerdict),
),
).toEqual(['success', 'warning', 'danger', 'muted', 'muted', 'muted']);
});
it('links workloads to valid existing platform surfaces', () => {
expect(getHomeResourceHref(resource('pve-vm', 'ok'))).toBe('/proxmox/overview?resource=pve-vm');
expect(
getHomeResourceHref({
...resource('docker-container', 'ok', 'docker'),
type: 'app-container',
}),
).toBe('/docker/overview?q=docker-container');
expect(getHomeResourceHref(resource('vsphere-vm', 'ok', 'vmware-vsphere'))).toBe(
'/vmware/overview?resource=vsphere-vm',
);
});
it('keeps Home investigation links scoped to the resource workflow', () => {
expect(
getHomeResourceHref({
...resource('docker-container:api', 'attention', 'docker'),
type: 'app-container',
docker: { hostname: 'edge host' },
}),
).toBe('/docker/overview?host=edge+host&q=docker-container%3Aapi');
expect(
getHomeResourceHref({
...resource('k8s:pod:api', 'critical', 'kubernetes'),
type: 'pod',
kubernetes: { clusterId: 'cluster-1', namespace: 'payments' },
}),
).toBe('/kubernetes/workloads?cluster=cluster-1&namespace=payments&q=k8s%3Apod%3Aapi');
expect(
getHomeResourceHref({
...resource('truenas-disk', 'stale', 'truenas'),
type: 'physical_disk',
}),
).toBe('/truenas/storage');
expect(
getHomeResourceHref({
...resource('k8s:service:api', 'ok', 'generic'),
type: 'k8s-service',
sources: [],
}),
).toBe('/kubernetes/services?q=k8s%3Aservice%3Aapi');
expect(
getHomeResourceHref({
...resource('machine:edge', 'ok', 'agent'),
type: 'agent',
sources: [],
}),
).toBe('/standalone/machines?q=machine%3Aedge');
});
});
@@ -1,312 +0,0 @@
import {
isStorage,
isWorkload,
type Resource,
type ResourceHealthReason,
type ResourceHealthVerdict,
} from '@/types/resource';
import type { StatusIndicatorVariant } from '@/utils/status';
import {
buildDockerPath,
buildDockerRouteSearch,
buildKubernetesPath,
buildKubernetesRouteSearch,
buildProxmoxPath,
buildStandalonePath,
buildStandaloneRouteSearch,
buildStorageRouteSearch,
buildTrueNASPath,
buildVmwarePath,
buildWorkloadsRouteSearch,
} from '@/routing/resourceLinks';
import { getPreferredInfrastructureDisplayName } from '@/utils/resourceIdentity';
export const HOME_HEALTHY_GROUP_LIMIT = 60;
export type HomePlatformKey =
'proxmox' | 'docker' | 'kubernetes' | 'truenas' | 'vmware' | 'standalone' | 'other';
export type HomeResourceTile = {
resource: Resource;
name: string;
verdict: ResourceHealthVerdict;
reason?: ResourceHealthReason;
href: string;
};
export type HomeResourceGroup = {
key: HomePlatformKey;
tiles: HomeResourceTile[];
hiddenTiles: HomeResourceTile[];
hiddenCount: number;
};
export type HomePosture = Record<ResourceHealthVerdict, number> & {
total: number;
needsAttention: number;
};
const PLATFORM_ORDER: readonly HomePlatformKey[] = [
'proxmox',
'docker',
'kubernetes',
'truenas',
'vmware',
'standalone',
'other',
];
const VERDICT_RANK: Record<ResourceHealthVerdict, number> = {
critical: 5,
attention: 4,
stale: 3,
unknown: 2,
off: 1,
ok: 0,
};
const DOCKER_TAB_BY_TYPE: Readonly<Record<string, string>> = {
'docker-image': 'images',
'docker-volume': 'storage',
'docker-network': 'networks',
'docker-service': 'swarm',
'docker-task': 'swarm',
'docker-swarm-node': 'swarm',
'docker-secret': 'swarm',
'docker-config': 'swarm',
};
const KUBERNETES_TAB_BY_TYPE: Readonly<Record<string, string>> = {
'k8s-node': 'nodes',
pod: 'workloads',
'k8s-deployment': 'workloads',
'k8s-replicaset': 'workloads',
'k8s-statefulset': 'workloads',
'k8s-daemonset': 'workloads',
'k8s-job': 'workloads',
'k8s-cronjob': 'workloads',
'k8s-horizontal-pod-autoscaler': 'workloads',
'k8s-service': 'services',
'k8s-ingress': 'services',
'k8s-endpoint-slice': 'services',
'k8s-persistent-volume': 'storage',
'k8s-persistent-volume-claim': 'storage',
'k8s-storage-class': 'storage',
'k8s-namespace': 'configuration',
'k8s-configmap': 'configuration',
'k8s-secret': 'configuration',
'k8s-serviceaccount': 'configuration',
'k8s-role': 'configuration',
'k8s-cluster-role': 'configuration',
'k8s-role-binding': 'configuration',
'k8s-cluster-role-binding': 'configuration',
'k8s-resource-quota': 'configuration',
'k8s-limit-range': 'configuration',
'k8s-network-policy': 'configuration',
'k8s-pod-disruption-budget': 'configuration',
'k8s-event': 'events',
};
const TRUENAS_TAB_BY_TYPE: Readonly<Record<string, string>> = {
storage: 'storage',
pool: 'storage',
dataset: 'storage',
physical_disk: 'storage',
'app-container': 'apps',
vm: 'vms',
'network-share': 'shares',
};
const getKubernetesRouteSearch = (resource: Resource): string =>
buildKubernetesRouteSearch({
cluster: resource.kubernetes?.clusterId ?? resource.kubernetes?.clusterName,
namespace: resource.kubernetes?.namespace,
query: resource.id,
});
const getDockerRouteSearch = (resource: Resource, includeQuery: boolean): string =>
buildDockerRouteSearch({
host: resource.docker?.hostname,
query: includeQuery ? resource.id : undefined,
});
export function getHomeVerdictTone(verdict: ResourceHealthVerdict): StatusIndicatorVariant {
switch (verdict) {
case 'ok':
return 'success';
case 'critical':
return 'danger';
case 'attention':
return 'warning';
default:
return 'muted';
}
}
export function getHomePlatformKey(resource: Resource): HomePlatformKey {
const platform = String(resource.platformType || '').toLowerCase();
const sources = (resource.sources ?? []).map((source) => source.toLowerCase());
if (
platform.includes('proxmox') ||
sources.some((source) => ['proxmox', 'pbs', 'pmg'].includes(source))
)
return 'proxmox';
if (platform.includes('docker') || platform.includes('podman') || sources.includes('docker'))
return 'docker';
if (
platform.includes('kubernetes') ||
sources.includes('kubernetes') ||
resource.type === 'pod' ||
resource.type.startsWith('k8s-')
)
return 'kubernetes';
if (platform.includes('truenas') || sources.includes('truenas')) return 'truenas';
if (platform.includes('vmware') || platform.includes('vsphere') || sources.includes('vmware'))
return 'vmware';
if (
platform.includes('standalone') ||
platform === 'agent' ||
platform.includes('availability') ||
sources.includes('agent') ||
sources.includes('availability')
)
return 'standalone';
return 'other';
}
export function getHomeResourceHref(resource: Resource): string {
const platform = getHomePlatformKey(resource);
const buildPlatformPath = (tab = 'overview') => {
switch (platform) {
case 'proxmox':
return buildProxmoxPath(tab);
case 'docker':
return buildDockerPath(tab);
case 'kubernetes':
return buildKubernetesPath(tab);
case 'truenas':
return buildTrueNASPath(tab);
case 'vmware':
return buildVmwarePath(tab);
case 'standalone':
return buildStandalonePath(tab === 'overview' ? 'machines' : tab);
default:
return '/alerts/overview';
}
};
if (platform === 'docker') {
const tab = DOCKER_TAB_BY_TYPE[resource.type] ?? 'overview';
const isOverviewResource = tab === 'overview';
const search = isOverviewResource
? getDockerRouteSearch(resource, resource.type === 'app-container')
: '';
return `${buildDockerPath(tab)}${search}`;
}
if (platform === 'kubernetes') {
const tab = KUBERNETES_TAB_BY_TYPE[resource.type] ?? 'overview';
return `${buildKubernetesPath(tab)}${getKubernetesRouteSearch(resource)}`;
}
if (platform === 'truenas') {
return buildTrueNASPath(TRUENAS_TAB_BY_TYPE[resource.type] ?? 'overview');
}
if (isWorkload(resource)) {
const search = buildWorkloadsRouteSearch({ resource: resource.id });
switch (platform) {
case 'proxmox':
return `${buildProxmoxPath('overview')}${search}`;
case 'vmware':
return `${buildVmwarePath('overview')}${search}`;
case 'standalone':
return `${buildStandalonePath('machines')}${buildStandaloneRouteSearch({ query: resource.id })}`;
default:
return '/alerts/overview';
}
}
if (isStorage(resource)) {
if (platform === 'standalone') return buildStandalonePath('machines');
if (platform === 'other') return '/alerts/overview';
return `${buildPlatformPath('storage')}${buildStorageRouteSearch({ resource: resource.id })}`;
}
if (resource.type === 'network-endpoint') {
return buildStandalonePath('availability');
}
if (platform === 'standalone') {
return `${buildStandalonePath('machines')}${buildStandaloneRouteSearch({ query: resource.id })}`;
}
return buildPlatformPath();
}
export function toHomeResourceTile(resource: Resource): HomeResourceTile {
const verdict = resource.health?.verdict ?? 'unknown';
return {
resource,
name: getPreferredInfrastructureDisplayName(resource),
verdict,
reason: resource.health?.reasons?.[0],
href: getHomeResourceHref(resource),
};
}
export function compareHomeTiles(left: HomeResourceTile, right: HomeResourceTile): number {
const verdictDifference = VERDICT_RANK[right.verdict] - VERDICT_RANK[left.verdict];
if (verdictDifference !== 0) return verdictDifference;
return left.name.localeCompare(right.name, undefined, { sensitivity: 'base', numeric: true });
}
export function buildHomePosture(resources: readonly Resource[]): HomePosture {
const posture: HomePosture = {
ok: 0,
attention: 0,
critical: 0,
stale: 0,
off: 0,
unknown: 0,
total: resources.length,
needsAttention: 0,
};
resources.forEach((resource) => {
const verdict = resource.health?.verdict ?? 'unknown';
posture[verdict] += 1;
if (verdict === 'critical' || verdict === 'attention') posture.needsAttention += 1;
});
return posture;
}
export function buildHomeAttentionTiles(resources: readonly Resource[]): HomeResourceTile[] {
return resources
.map(toHomeResourceTile)
.filter((tile) => tile.verdict === 'critical' || tile.verdict === 'attention')
.sort(compareHomeTiles);
}
export function buildHomeResourceGroups(resources: readonly Resource[]): HomeResourceGroup[] {
const grouped = new Map<HomePlatformKey, HomeResourceTile[]>();
resources.forEach((resource) => {
const tile = toHomeResourceTile(resource);
if (tile.verdict === 'critical' || tile.verdict === 'attention') return;
const key = getHomePlatformKey(resource);
grouped.set(key, [...(grouped.get(key) ?? []), tile]);
});
return PLATFORM_ORDER.flatMap((key) => {
const tiles = (grouped.get(key) ?? []).sort(compareHomeTiles);
if (tiles.length === 0) return [];
const neverHidden = tiles.filter((tile) => tile.verdict !== 'ok' && tile.verdict !== 'off');
const calm = tiles.filter((tile) => tile.verdict === 'ok' || tile.verdict === 'off');
const visibleCalm = calm.slice(0, HOME_HEALTHY_GROUP_LIMIT);
const hiddenTiles = calm.slice(HOME_HEALTHY_GROUP_LIMIT);
return [
{
key,
tiles: [...neverHidden, ...visibleCalm],
hiddenTiles,
hiddenCount: hiddenTiles.length,
},
];
});
}
-51
View File
@@ -1,57 +1,6 @@
import type { I18nCatalog } from './messages';
export const DE_MESSAGE_OVERRIDES = {
'home.title': 'Start',
'home.description': 'Bestaetigte Integritaet aller ueberwachten Ressourcen.',
'home.nav.tooltip': 'Bestaetigten Zustand der gesamten Flotte anzeigen',
'home.refresh.label': 'Aktualisieren',
'home.refresh.ariaLabel': 'Flottenzustand aktualisieren',
'home.loading': 'Flottenzustand wird geladen...',
'home.error.title': 'Flottenzustand ist nicht verfuegbar',
'home.error.description': 'Pulse konnte den aktuellen Ressourcenzustand nicht laden.',
'home.error.cached.title': 'Flottenzustand konnte nicht aktualisiert werden',
'home.error.cached.description':
'Zuletzt geladene Daten werden angezeigt. Ressourcenstatus koennen veraltet sein.',
'home.error.retry': 'Erneut versuchen',
'home.empty.title': 'Erstes ueberwachtes System verbinden',
'home.empty.description':
'Fuegen Sie eine Plattform-API, einen Pulse Agent oder eine Verfuegbarkeitspruefung hinzu, um den Ressourcenzustand zu bestaetigen.',
'home.empty.action': 'Infrastruktur hinzufuegen',
'home.updated.unknown': 'unbekannt',
'home.posture.allHealthy':
'Alle {total} Ressourcen als fehlerfrei bestaetigt · neueste Telemetrie {updated}',
'home.posture.summary.singular':
'1 Ressource erfordert Aufmerksamkeit · {healthy} von {total} als fehlerfrei bestaetigt · {stale} veraltet · {unknown} unbekannt · neueste Telemetrie {updated}',
'home.posture.summary.plural':
'{attention} Ressourcen erfordern Aufmerksamkeit · {healthy} von {total} als fehlerfrei bestaetigt · {stale} veraltet · {unknown} unbekannt · neueste Telemetrie {updated}',
'home.attention.title': 'Aufmerksamkeit erforderlich',
'home.attention.description':
'Kritische Ressourcen stehen zuerst. Oeffnen Sie eine Ressource zur Untersuchung.',
'home.platform.proxmox': 'Proxmox',
'home.platform.docker': 'Docker und Podman',
'home.platform.kubernetes': 'Kubernetes',
'home.platform.truenas': 'TrueNAS',
'home.platform.vmware': 'vSphere',
'home.platform.standalone': 'Maschinen und Pruefungen',
'home.platform.other': 'Andere Ressourcen',
'home.verdict.ok': 'Fehlerfrei',
'home.verdict.attention': 'Aufmerksamkeit erforderlich',
'home.verdict.critical': 'Kritisch',
'home.verdict.stale': 'Veraltet',
'home.verdict.off': 'Ausgeschaltet',
'home.verdict.unknown': 'Unbekannt',
'home.reason.criticalAlert': 'Kritische Warnung{detail}',
'home.reason.warningAlert': 'Warnung{detail}',
'home.reason.availabilityFailed': 'Verfuegbarkeitspruefung fehlgeschlagen',
'home.reason.offline': 'Offline',
'home.reason.backupStale': 'Sicherung veraltet{detail}',
'home.reason.telemetryStale': 'Telemetrie veraltet{detail}',
'home.reason.telemetryMissing': 'Telemetrie fehlt',
'home.reason.poweredOff': 'Ausgeschaltet',
'home.reason.degraded': 'Beeintraechtigt',
'home.tile.ariaLabel': '{name}: {status}. {reason}',
'home.group.showAll': 'Alle anzeigen ({count})',
'home.group.showLess': 'Weniger anzeigen',
'pricing.handoff.description.afterLink': '.',
'pricing.handoff.description.beforeLink': 'Wenn die Weiterleitung nicht automatisch startet,',
'pricing.handoff.link.publicPricing': 'weiter zur oeffentlichen Preisseite',
-51
View File
@@ -1,57 +1,6 @@
import type { I18nCatalog } from './messages';
export const ES_MESSAGE_OVERRIDES = {
'home.title': 'Inicio',
'home.description': 'Estado verificado de cada recurso monitorizado.',
'home.nav.tooltip': 'Ver el estado verificado de toda la flota',
'home.refresh.label': 'Actualizar',
'home.refresh.ariaLabel': 'Actualizar estado de la flota',
'home.loading': 'Cargando estado de la flota...',
'home.error.title': 'El estado de la flota no esta disponible',
'home.error.description': 'Pulse no pudo cargar el estado actual de los recursos.',
'home.error.cached.title': 'No se pudo actualizar el estado de la flota',
'home.error.cached.description':
'Se muestran los ultimos datos cargados. Los estados de los recursos pueden estar desactualizados.',
'home.error.retry': 'Intentar de nuevo',
'home.empty.title': 'Conecta tu primer sistema monitorizado',
'home.empty.description':
'Agrega una API de plataforma, Pulse Agent o una comprobacion de disponibilidad para comenzar a verificar los recursos.',
'home.empty.action': 'Agregar infraestructura',
'home.updated.unknown': 'desconocido',
'home.posture.allHealthy':
'Los {total} recursos tienen un estado saludable verificado · telemetria mas reciente {updated}',
'home.posture.summary.singular':
'1 necesita atencion · {healthy} de {total} con estado saludable verificado · {stale} obsoleto · {unknown} desconocido · telemetria mas reciente {updated}',
'home.posture.summary.plural':
'{attention} necesitan atencion · {healthy} de {total} con estado saludable verificado · {stale} obsoletos · {unknown} desconocidos · telemetria mas reciente {updated}',
'home.attention.title': 'Necesita atencion',
'home.attention.description':
'Los recursos criticos aparecen primero. Abre un recurso para investigarlo.',
'home.platform.proxmox': 'Proxmox',
'home.platform.docker': 'Docker y Podman',
'home.platform.kubernetes': 'Kubernetes',
'home.platform.truenas': 'TrueNAS',
'home.platform.vmware': 'vSphere',
'home.platform.standalone': 'Maquinas y comprobaciones',
'home.platform.other': 'Otros recursos',
'home.verdict.ok': 'Saludable',
'home.verdict.attention': 'Necesita atencion',
'home.verdict.critical': 'Critico',
'home.verdict.stale': 'Obsoleto',
'home.verdict.off': 'Apagado',
'home.verdict.unknown': 'Desconocido',
'home.reason.criticalAlert': 'Alerta critica{detail}',
'home.reason.warningAlert': 'Alerta de advertencia{detail}',
'home.reason.availabilityFailed': 'La comprobacion de disponibilidad fallo',
'home.reason.offline': 'Sin conexion',
'home.reason.backupStale': 'Copia de seguridad obsoleta{detail}',
'home.reason.telemetryStale': 'Telemetria obsoleta{detail}',
'home.reason.telemetryMissing': 'Falta telemetria',
'home.reason.poweredOff': 'Apagado',
'home.reason.degraded': 'Degradado',
'home.tile.ariaLabel': '{name}: {status}. {reason}',
'home.group.showAll': 'Mostrar todo ({count})',
'home.group.showLess': 'Mostrar menos',
'pricing.handoff.description.afterLink': '.',
'pricing.handoff.description.beforeLink': 'Si la redireccion no comienza automaticamente,',
'pricing.handoff.link.publicPricing': 'continua al sitio publico de precios',
-49
View File
@@ -1,55 +1,6 @@
import type { SupportedLocale } from './locales';
export const EN_MESSAGES = {
'home.title': 'Home',
'home.description': 'Verified health across every monitored resource.',
'home.nav.tooltip': 'See verified health across the whole fleet',
'home.refresh.label': 'Refresh',
'home.refresh.ariaLabel': 'Refresh fleet health',
'home.loading': 'Loading fleet health...',
'home.error.title': 'Fleet health is unavailable',
'home.error.description': 'Pulse could not load the current resource state.',
'home.error.cached.title': 'Fleet health could not be refreshed',
'home.error.cached.description':
'Showing the last loaded data. Resource statuses may be out of date.',
'home.error.retry': 'Try again',
'home.empty.title': 'Connect your first monitored system',
'home.empty.description':
'Add a platform API, Pulse Agent, or availability check to start verifying resource health.',
'home.empty.action': 'Add infrastructure',
'home.updated.unknown': 'unknown',
'home.posture.allHealthy': 'All {total} resources verified healthy · newest telemetry {updated}',
'home.posture.summary.singular':
'1 needs attention · {healthy} of {total} verified healthy · {stale} stale · {unknown} unknown · newest telemetry {updated}',
'home.posture.summary.plural':
'{attention} need attention · {healthy} of {total} verified healthy · {stale} stale · {unknown} unknown · newest telemetry {updated}',
'home.attention.title': 'Needs attention',
'home.attention.description': 'Critical resources appear first. Open a resource to investigate.',
'home.platform.proxmox': 'Proxmox',
'home.platform.docker': 'Docker and Podman',
'home.platform.kubernetes': 'Kubernetes',
'home.platform.truenas': 'TrueNAS',
'home.platform.vmware': 'vSphere',
'home.platform.standalone': 'Machines and checks',
'home.platform.other': 'Other resources',
'home.verdict.ok': 'Healthy',
'home.verdict.attention': 'Needs attention',
'home.verdict.critical': 'Critical',
'home.verdict.stale': 'Stale',
'home.verdict.off': 'Powered off',
'home.verdict.unknown': 'Unknown',
'home.reason.criticalAlert': 'Critical alert{detail}',
'home.reason.warningAlert': 'Warning alert{detail}',
'home.reason.availabilityFailed': 'Availability check failed',
'home.reason.offline': 'Offline',
'home.reason.backupStale': 'Backup stale{detail}',
'home.reason.telemetryStale': 'Telemetry stale{detail}',
'home.reason.telemetryMissing': 'Telemetry missing',
'home.reason.poweredOff': 'Powered off',
'home.reason.degraded': 'Degraded',
'home.tile.ariaLabel': '{name}: {status}. {reason}',
'home.group.showAll': 'Show all ({count})',
'home.group.showLess': 'Show less',
'runtimeHome.openingWorkspace': 'Opening workspace...',
'pricing.handoff.description.afterLink': '.',
'pricing.handoff.description.beforeLink': 'If the handoff does not start automatically,',
@@ -7,7 +7,7 @@ describe('navigation routing helpers', () => {
expect(getActiveTabForPath('/dashboard')).toBeNull();
expect(getActiveTabForPath('/dashboard/explore')).toBeNull();
expect(getActiveTabForPath('/explore')).toBeNull();
expect(getActiveTabForPath('/home')).toBe('home');
expect(getActiveTabForPath('/home')).toBeNull();
expect(getActiveTabForPath('/standalone')).toBe('standalone');
expect(getActiveTabForPath('/standalone/machines')).toBe('standalone');
expect(getActiveTabForPath('/agents')).toBeNull();
@@ -50,8 +50,6 @@ import {
buildPatrolOperationsLoopPath,
buildDockerPath,
buildDockerRouteSearch,
buildKubernetesRouteSearch,
buildStandaloneRouteSearch,
buildKubernetesPath,
buildRecoveryRouteSearch,
buildProxmoxPath,
@@ -192,7 +190,6 @@ describe('resource link routing contract', () => {
expect(STANDALONE_QUERY_PARAMS).toEqual({ query: 'q', status: 'status', view: 'view' });
expect(buildStandalonePath()).toBe('/standalone/machines');
expect(buildStandalonePath('')).toBe('/standalone');
expect(buildStandaloneRouteSearch({ query: ' agent:edge one ' })).toBe('?q=agent%3Aedge+one');
expect(DOCKER_PATH).toBe('/docker');
expect(buildDockerPath()).toBe('/docker/overview');
@@ -203,9 +200,6 @@ describe('resource link routing contract', () => {
'?host=frigate.mist-stork.ts.net',
);
expect(buildDockerRouteSearch({ host: ' host with spaces ' })).toBe('?host=host+with+spaces');
expect(buildDockerRouteSearch({ host: 'edge', query: 'container:api' })).toBe(
'?host=edge&q=container%3Aapi',
);
expect(buildDockerRouteSearch({ host: '' })).toBe('');
expect(KUBERNETES_PATH).toBe('/kubernetes');
@@ -215,13 +209,6 @@ describe('resource link routing contract', () => {
expect(buildKubernetesPath('configuration')).toBe('/kubernetes/configuration');
expect(buildKubernetesPath('pods')).toBe('/kubernetes/pods');
expect(KUBERNETES_QUERY_PARAMS.cluster).toBe('cluster');
expect(
buildKubernetesRouteSearch({
cluster: 'cluster-1',
namespace: 'payments',
query: 'pod:api',
}),
).toBe('?cluster=cluster-1&namespace=payments&q=pod%3Aapi');
expect(TRUENAS_PATH).toBe('/truenas');
expect(buildTrueNASPath()).toBe('/truenas/overview');
@@ -1,6 +1,5 @@
import {
DOCKER_PATH,
HOME_PATH,
ACTIONS_PATH,
KUBERNETES_PATH,
PATROL_PATH,
@@ -11,7 +10,6 @@ import {
} from './resourceLinks';
export type AppTabId =
| 'home'
| 'standalone'
| 'proxmox'
| 'docker'
@@ -26,7 +24,6 @@ export type AppTabId =
export type ActiveAppTabId = AppTabId | null;
export function getActiveTabForPath(path: string): ActiveAppTabId {
if (path === HOME_PATH || path.startsWith(`${HOME_PATH}/`)) return 'home';
if (path.startsWith(PROXMOX_PATH)) return 'proxmox';
if (path.startsWith(DOCKER_PATH)) return 'docker';
if (path.startsWith(KUBERNETES_PATH)) return 'kubernetes';
@@ -16,7 +16,6 @@ export const WORKLOADS_QUERY_PARAMS = {
summaryGroup: 'summaryGroup',
} as const;
export const HOME_PATH = '/home';
export const STANDALONE_PATH = '/standalone';
export const STANDALONE_DEFAULT_TAB = 'machines';
export const STANDALONE_QUERY_PARAMS = {
@@ -162,17 +161,6 @@ type WorkloadsLinkOptions = {
type DockerLinkOptions = {
host?: string | null;
query?: string | null;
};
type KubernetesLinkOptions = {
cluster?: string | null;
namespace?: string | null;
query?: string | null;
};
type StandaloneLinkOptions = {
query?: string | null;
};
type StorageLinkOptions = {
@@ -337,13 +325,6 @@ export const buildStandalonePath = (tab: string = STANDALONE_DEFAULT_TAB): strin
return normalized ? `${STANDALONE_PATH}/${normalized}` : STANDALONE_PATH;
};
export const buildStandaloneRouteSearch = (options: StandaloneLinkOptions = {}): string => {
const params = new URLSearchParams();
const query = normalizeQueryValue(options.query);
if (query) params.set(STANDALONE_QUERY_PARAMS.query, query);
return serializedRouteSearch(params);
};
export const buildDockerPath = (tab: string = DOCKER_DEFAULT_TAB): string => {
const normalized = tab.trim().replace(/^\/+|\/+$/g, '');
return normalized ? `${DOCKER_PATH}/${normalized}` : DOCKER_PATH;
@@ -352,9 +333,7 @@ export const buildDockerPath = (tab: string = DOCKER_DEFAULT_TAB): string => {
export const buildDockerRouteSearch = (options: DockerLinkOptions = {}): string => {
const params = new URLSearchParams();
const host = normalizeQueryValue(options.host);
const query = normalizeQueryValue(options.query);
if (host) params.set(DOCKER_QUERY_PARAMS.host, host);
if (query) params.set(DOCKER_QUERY_PARAMS.query, query);
return serializedRouteSearch(params);
};
@@ -363,17 +342,6 @@ export const buildKubernetesPath = (tab: string = KUBERNETES_DEFAULT_TAB): strin
return normalized ? `${KUBERNETES_PATH}/${normalized}` : KUBERNETES_PATH;
};
export const buildKubernetesRouteSearch = (options: KubernetesLinkOptions = {}): string => {
const params = new URLSearchParams();
const cluster = normalizeQueryValue(options.cluster);
const namespace = normalizeQueryValue(options.namespace);
const query = normalizeQueryValue(options.query);
if (cluster) params.set(KUBERNETES_QUERY_PARAMS.cluster, cluster);
if (namespace) params.set(KUBERNETES_QUERY_PARAMS.namespace, namespace);
if (query) params.set(KUBERNETES_QUERY_PARAMS.query, query);
return serializedRouteSearch(params);
};
export const buildTrueNASPath = (tab: string = TRUENAS_DEFAULT_TAB): string => {
const normalized = tab.trim().replace(/^\/+|\/+$/g, '');
return normalized ? `${TRUENAS_PATH}/${normalized}` : TRUENAS_PATH;
@@ -1,7 +1,6 @@
import {
ACTIONS_PATH,
DOCKER_PATH,
HOME_PATH,
KUBERNETES_PATH,
PATROL_PATH,
PROXMOX_PATH,
@@ -32,11 +31,6 @@ function normalizeRoute(route: string): string {
}
const ROUTE_PRELOADERS: readonly RoutePreloader[] = [
{
id: 'home',
matches: (route) => route === HOME_PATH,
preload: () => import('@/features/home/HomePageSurface').then(() => undefined),
},
{
id: 'proxmox',
matches: (route) => route === PROXMOX_PATH || route.startsWith(`${PROXMOX_PATH}/`),
@@ -41,33 +41,6 @@ describe('assistantPageContext', () => {
expect(presentation.context.briefing?.title).toBe('Patrol attached');
});
it('attaches the fleet-wide Home context to Assistant launches', () => {
const presentation = getAssistantPageContext('/home');
expect(presentation).toMatchObject({
ariaLabel: 'Ask Pulse Assistant about Home',
commandDescription: 'Use the current Home view as context',
commandLabel: 'Ask about Home',
label: 'Home',
title: 'Ask Pulse Assistant about Home',
});
expect(presentation.context).toMatchObject({
targetType: 'pulse-view',
targetId: '/home',
context: {
name: 'Home',
route: '/home',
surface: 'home',
},
briefing: {
sourceLabel: 'Current view',
title: 'Home attached',
subject: 'Home view',
statusLabel: 'Context only',
},
});
});
it('falls back to current-view context for unowned routes', () => {
const presentation = getAssistantPageContext('custom/report');
@@ -82,7 +55,7 @@ describe('assistantPageContext', () => {
});
it('does not revive retired dashboard or Explore surfaces as Assistant context', () => {
const retiredRoutes = ['/dashboard', '/dashboard/explore', '/explore'];
const retiredRoutes = ['/dashboard', '/dashboard/explore', '/explore', '/home'];
for (const route of retiredRoutes) {
const presentation = getAssistantPageContext(route);
@@ -11,7 +11,6 @@ export interface AssistantPageContextPresentation {
}
const VIEW_LABELS: Record<Exclude<ActiveAppTabId, null>, string> = {
home: 'Home',
alerts: 'Alerts',
actions: 'Actions',
ai: 'Patrol',
@@ -145,151 +145,6 @@ test("Actions remains named, directly reachable, keyboard accessible, and free o
});
});
test("Home status wall is readable and motionless across desktop and phone widths", async ({
page,
}, testInfo) => {
const lastSeen = "2026-08-31T22:30:00Z";
const resource = (
id: string,
name: string,
type: string,
sources: string[],
verdict: string,
reason?: { code: string; detail?: string },
) => ({
id,
name,
type,
sources,
status:
verdict === "off"
? "stopped"
: verdict === "critical"
? "offline"
: "online",
lastSeen,
health: { verdict, reasons: reason ? [reason] : [] },
});
const resources = [
resource("node-down", "PVE node down", "agent", ["proxmox"], "critical", {
code: "offline",
}),
resource("vm-warning", "Billing VM", "vm", ["proxmox"], "attention", {
code: "warning_alert",
detail: "memory",
}),
resource("agent-stale", "Remote agent", "agent", ["agent"], "stale", {
code: "telemetry_stale",
detail: "12m",
}),
resource(
"container-off",
"Batch worker",
"app-container",
["docker"],
"off",
{
code: "powered_off",
},
),
resource(
"check-down",
"Customer portal",
"network-endpoint",
["availability"],
"critical",
{ code: "availability_failed" },
),
...Array.from({ length: 65 }, (_, index) =>
resource(
`healthy-${index + 1}`,
`Healthy VM ${index + 1}`,
"vm",
["proxmox"],
"ok",
),
),
];
let resourceRequestsFail = false;
await page.route("**/api/resources?*", (route) => {
if (resourceRequestsFail) {
return route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ error: "resource refresh unavailable" }),
});
}
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
data: resources,
meta: { totalPages: 1 },
aggregations: { total: resources.length },
}),
});
});
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/home", { waitUntil: "domcontentloaded" });
await expect(
page.getByRole("heading", { level: 1, name: "Home" }),
).toBeVisible();
await expect(
page.getByRole("status").filter({ hasText: "3 need attention" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: /PVE node down: Critical/ }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "Batch worker: Powered off. Powered off" }),
).toHaveAttribute("href", "/docker/overview?q=container-off");
await expect(
page.getByRole("link", {
name: "Remote agent: Stale. Telemetry stale 12m",
}),
).toHaveAttribute("href", "/standalone/machines?q=agent-stale");
await expect(
page.getByRole("link", {
name: "Healthy VM 1: Healthy. Healthy",
exact: true,
}),
).toContainText("Healthy");
const showAll = page.getByRole("button", { name: "Show all (5)" });
await expect(showAll).toBeVisible();
await showAll.click();
await expect(page.getByRole("button", { name: "Show less" })).toBeVisible();
resourceRequestsFail = true;
await page.getByRole("button", { name: "Refresh fleet health" }).click();
await expect(page.getByRole("alert")).toContainText(
"Fleet health could not be refreshed",
);
await expect(
page.getByRole("link", { name: /PVE node down: Critical/ }),
).toBeVisible();
expect(await scanForWcagViolations(page)).toEqual([]);
expect(await scanForUnexpectedReducedMotion(page)).toEqual([]);
await testInfo.attach("home-desktop", {
body: await page.screenshot({ fullPage: true }),
contentType: "image/png",
});
await page.setViewportSize({ width: 390, height: 844 });
expect(await scanForWcagViolations(page)).toEqual([]);
expect(
await page.evaluate(
() =>
document.documentElement.scrollWidth >
document.documentElement.clientWidth,
),
).toBeFalsy();
await testInfo.attach("home-phone", {
body: await page.screenshot({ fullPage: true }),
contentType: "image/png",
});
});
test("representative authenticated surfaces have no automatically detectable WCAG A/AA violations", async ({
page,
}) => {