From feff95934caeec7c2a8abc2b3a8ac61a1efa2d24 Mon Sep 17 00:00:00 2001 From: rcourtman <8825017+rcourtman@users.noreply.github.com> Date: Tue, 1 Sep 2026 20:54:21 +0100 Subject: [PATCH] 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. --- .../v6/internal/HOME_STATUS_WALL_SPEC.md | 2 +- frontend-modern/browser-verification.json | 60 +++- frontend-modern/src/App.tsx | 3 - frontend-modern/src/AppLayout.tsx | 27 +- .../src/__tests__/AppLayout.test.tsx | 11 +- .../src/components/shared/MobileNavBar.tsx | 4 +- .../components/shared/mobileNavBarModel.ts | 1 - .../src/features/home/HomePageSurface.tsx | 327 ------------------ .../home/__tests__/HomePageSurface.test.tsx | 139 -------- .../home/__tests__/homePageModel.test.ts | 144 -------- .../src/features/home/homePageModel.ts | 312 ----------------- frontend-modern/src/i18n/messages.de.ts | 51 --- frontend-modern/src/i18n/messages.es.ts | 51 --- frontend-modern/src/i18n/messages.ts | 49 --- .../src/routing/__tests__/navigation.test.ts | 2 +- .../routing/__tests__/resourceLinks.test.ts | 13 - frontend-modern/src/routing/navigation.ts | 3 - frontend-modern/src/routing/resourceLinks.ts | 32 -- frontend-modern/src/routing/routePreload.ts | 6 - .../__tests__/assistantPageContext.test.ts | 29 +- .../src/utils/assistantPageContext.ts | 1 - .../83-product-trust-accessibility.spec.ts | 145 -------- 22 files changed, 58 insertions(+), 1354 deletions(-) delete mode 100644 frontend-modern/src/features/home/HomePageSurface.tsx delete mode 100644 frontend-modern/src/features/home/__tests__/HomePageSurface.test.tsx delete mode 100644 frontend-modern/src/features/home/__tests__/homePageModel.test.ts delete mode 100644 frontend-modern/src/features/home/homePageModel.ts diff --git a/docs/release-control/v6/internal/HOME_STATUS_WALL_SPEC.md b/docs/release-control/v6/internal/HOME_STATUS_WALL_SPEC.md index d1d4209a6..673b35f30 100644 --- a/docs/release-control/v6/internal/HOME_STATUS_WALL_SPEC.md +++ b/docs/release-control/v6/internal/HOME_STATUS_WALL_SPEC.md @@ -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 diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 9057f1ecf..0ab28a8fd 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -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" ] } diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 437842575..1050cf660 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -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() { - diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index 335fcdb86..2fae9e46d 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -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>, 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) { )} {tab.breakdown && tab.breakdown.warning > 0 && ( - + {tab.breakdown.warning} )} @@ -967,7 +950,7 @@ export function AppLayout(props: AppLayoutProps) { ); } return ( - + {total} ); diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index 5420d9507..821a963de 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -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 () => { diff --git a/frontend-modern/src/components/shared/MobileNavBar.tsx b/frontend-modern/src/components/shared/MobileNavBar.tsx index 13a96b861..923afc8a2 100644 --- a/frontend-modern/src/components/shared/MobileNavBar.tsx +++ b/frontend-modern/src/components/shared/MobileNavBar.tsx @@ -55,7 +55,7 @@ function MobileNavDestinationContent(props: { 0}> - + {badges().warning} @@ -66,7 +66,7 @@ function MobileNavDestinationContent(props: { {(count) => ( {count()} diff --git a/frontend-modern/src/components/shared/mobileNavBarModel.ts b/frontend-modern/src/components/shared/mobileNavBarModel.ts index 8e87990c1..1045a34a5 100644 --- a/frontend-modern/src/components/shared/mobileNavBarModel.ts +++ b/frontend-modern/src/components/shared/mobileNavBarModel.ts @@ -48,7 +48,6 @@ export type MobileNavBarLayout = { }; const MOBILE_NAV_PRIMARY_PRIORITY = [ - 'home', 'proxmox', 'docker', 'kubernetes', diff --git a/frontend-modern/src/features/home/HomePageSurface.tsx b/frontend-modern/src/features/home/HomePageSurface.tsx deleted file mode 100644 index c0fa504aa..000000000 --- a/frontend-modern/src/features/home/HomePageSurface.tsx +++ /dev/null @@ -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 = { - 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 ( - - - - {props.tile.name} - - {reason()} - - ); -} - -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 ( - - - - {platformLabel(props.group.key)} - - 0}> - - {props.expanded() - ? t('home.group.showLess') - : t('home.group.showAll', { count: props.group.hiddenCount })} - } - > - - - - - - - {(tile) => } - - - ); -} - -export default function HomePageSurface() { - const resources = useResources(); - const [expandedGroups, setExpandedGroups] = createSignal>(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 ( - - void refetch()} - > - - {t('home.refresh.label')} - - } - /> - - - - - {t('home.loading')} - - - - - - {t('home.error.title')} - {t('home.error.description')} - void refetch()}> - {t('home.error.retry')} - - - - - 0}> - - - {t('home.error.cached.title')} - {t('home.error.cached.description')} - - void refetch()} - > - {t('home.error.retry')} - - - - - - - - {t('home.empty.title')} - - {t('home.empty.description')} - - {t('home.empty.action')} - - - - - 0}> - - {postureText()} - - - 0}> - - - - {t('home.attention.title')} - - {t('home.attention.description')} - - - {(tile) => } - - - - - - {(group) => ( - expandedGroups().has(group.key)} - onToggle={() => toggleGroup(group.key)} - /> - )} - - - - ); -} diff --git a/frontend-modern/src/features/home/__tests__/HomePageSurface.test.tsx b/frontend-modern/src/features/home/__tests__/HomePageSurface.test.tsx deleted file mode 100644 index acf6952dc..000000000 --- a/frontend-modern/src/features/home/__tests__/HomePageSurface.test.tsx +++ /dev/null @@ -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 => ({ - 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(() => ( - - - - )); -}; - -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(); - 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(() => ( - - - - )); - - 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(); - }); -}); diff --git a/frontend-modern/src/features/home/__tests__/homePageModel.test.ts b/frontend-modern/src/features/home/__tests__/homePageModel.test.ts deleted file mode 100644 index ceba13235..000000000 --- a/frontend-modern/src/features/home/__tests__/homePageModel.test.ts +++ /dev/null @@ -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'); - }); -}); diff --git a/frontend-modern/src/features/home/homePageModel.ts b/frontend-modern/src/features/home/homePageModel.ts deleted file mode 100644 index 04b14e0f0..000000000 --- a/frontend-modern/src/features/home/homePageModel.ts +++ /dev/null @@ -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 & { - total: number; - needsAttention: number; -}; - -const PLATFORM_ORDER: readonly HomePlatformKey[] = [ - 'proxmox', - 'docker', - 'kubernetes', - 'truenas', - 'vmware', - 'standalone', - 'other', -]; - -const VERDICT_RANK: Record = { - critical: 5, - attention: 4, - stale: 3, - unknown: 2, - off: 1, - ok: 0, -}; - -const DOCKER_TAB_BY_TYPE: Readonly> = { - '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> = { - '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> = { - 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(); - 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, - }, - ]; - }); -} diff --git a/frontend-modern/src/i18n/messages.de.ts b/frontend-modern/src/i18n/messages.de.ts index cb7704c37..c21f497cb 100644 --- a/frontend-modern/src/i18n/messages.de.ts +++ b/frontend-modern/src/i18n/messages.de.ts @@ -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', diff --git a/frontend-modern/src/i18n/messages.es.ts b/frontend-modern/src/i18n/messages.es.ts index 161aa85ef..831df47cb 100644 --- a/frontend-modern/src/i18n/messages.es.ts +++ b/frontend-modern/src/i18n/messages.es.ts @@ -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', diff --git a/frontend-modern/src/i18n/messages.ts b/frontend-modern/src/i18n/messages.ts index a5d6236dc..fb414e7fe 100644 --- a/frontend-modern/src/i18n/messages.ts +++ b/frontend-modern/src/i18n/messages.ts @@ -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,', diff --git a/frontend-modern/src/routing/__tests__/navigation.test.ts b/frontend-modern/src/routing/__tests__/navigation.test.ts index 751a719ef..166265a5e 100644 --- a/frontend-modern/src/routing/__tests__/navigation.test.ts +++ b/frontend-modern/src/routing/__tests__/navigation.test.ts @@ -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(); diff --git a/frontend-modern/src/routing/__tests__/resourceLinks.test.ts b/frontend-modern/src/routing/__tests__/resourceLinks.test.ts index 163c61d10..a79c26e11 100644 --- a/frontend-modern/src/routing/__tests__/resourceLinks.test.ts +++ b/frontend-modern/src/routing/__tests__/resourceLinks.test.ts @@ -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'); diff --git a/frontend-modern/src/routing/navigation.ts b/frontend-modern/src/routing/navigation.ts index a3183de94..a371e32bd 100644 --- a/frontend-modern/src/routing/navigation.ts +++ b/frontend-modern/src/routing/navigation.ts @@ -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'; diff --git a/frontend-modern/src/routing/resourceLinks.ts b/frontend-modern/src/routing/resourceLinks.ts index 0c73a7363..d9e8e1310 100644 --- a/frontend-modern/src/routing/resourceLinks.ts +++ b/frontend-modern/src/routing/resourceLinks.ts @@ -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; diff --git a/frontend-modern/src/routing/routePreload.ts b/frontend-modern/src/routing/routePreload.ts index b91606d14..7c5a37b1c 100644 --- a/frontend-modern/src/routing/routePreload.ts +++ b/frontend-modern/src/routing/routePreload.ts @@ -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}/`), diff --git a/frontend-modern/src/utils/__tests__/assistantPageContext.test.ts b/frontend-modern/src/utils/__tests__/assistantPageContext.test.ts index f20ce7598..834acc3f4 100644 --- a/frontend-modern/src/utils/__tests__/assistantPageContext.test.ts +++ b/frontend-modern/src/utils/__tests__/assistantPageContext.test.ts @@ -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); diff --git a/frontend-modern/src/utils/assistantPageContext.ts b/frontend-modern/src/utils/assistantPageContext.ts index d3fcffe0f..525697cbe 100644 --- a/frontend-modern/src/utils/assistantPageContext.ts +++ b/frontend-modern/src/utils/assistantPageContext.ts @@ -11,7 +11,6 @@ export interface AssistantPageContextPresentation { } const VIEW_LABELS: Record, string> = { - home: 'Home', alerts: 'Alerts', actions: 'Actions', ai: 'Patrol', diff --git a/tests/integration/tests/83-product-trust-accessibility.spec.ts b/tests/integration/tests/83-product-trust-accessibility.spec.ts index 27cefbc5d..553474bb9 100644 --- a/tests/integration/tests/83-product-trust-accessibility.spec.ts +++ b/tests/integration/tests/83-product-trust-accessibility.spec.ts @@ -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, }) => {
{t('home.error.title')}
{t('home.error.description')}
{t('home.error.cached.title')}
{t('home.error.cached.description')}
{t('home.empty.description')}
- {postureText()} -
{t('home.attention.description')}