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')}