mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
}) => {
|
||||
|
||||
Reference in New Issue
Block a user