diff --git a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md index a18b6d541..19d425cf3 100644 --- a/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md +++ b/docs/release-control/v6/internal/PLATFORM_SUPPORT_MODEL.md @@ -38,6 +38,14 @@ through platform-by-platform improvisation. A profile may describe supported Pulse Agent deployment on a specific host or appliance family, but it does not promote that family into `PLATFORM_TYPE_KEYS` or first-class platform status. +9. Product navigation is support-and-evidence gated. + First-class platform navigation, command/search destinations, keyboard + shortcuts, landing fallbacks, and platform-section tabs must derive from + this support model plus current runtime resource or capability evidence. + Supported surfaces must appear when evidence proves the platform or + capability is present. Admitted-only, presentation-only, unsupported, or + absent capability surfaces must be hidden rather than kept as disabled + placeholders. ## Platform Categories diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 8c04b38e6..d71095526 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -144,6 +144,10 @@ runtime cost control, and shared AI transport surfaces. 1. Update this contract when canonical AI runtime or transport entry points move, including transport-level provider request-shape changes such as OpenAI-compatible `tool_choice` handling, runtime-failure classification splits (for example separating tool-choice request rejection, no tool-capable endpoint, and generic model-level lack of tool support into distinct causes), Patrol-specific verification surfaces such as `POST /api/ai/patrol/preflight` that exercise the full chat-completions path with a minimal tool definition rather than only listing models, Patrol-preflight cache observability where the AI Service caches the most recent preflight outcome (success, soft warning, or classified failure) and the AI settings response surfaces it as `patrol_preflight` so the UI can hydrate a "last verified" indicator without forcing operators to re-run preflight on every page load, the auto-trigger contract on `HandleUpdateAISettings` where the save handler runs `TriggerPatrolPreflightAsync` only when the change actually moved Patrol transport (model swap, provider key for that model changed, or assistant just enabled with a Patrol model) so routine settings saves do not burn provider tokens, the startup-seed contract where the AI Service handler dispatches the same async preflight on Pulse boot when assistant is enabled and a Patrol model is configured so the cache is populated for the first `/api/settings/ai` poll after a restart instead of blanking back to "never verified", the readiness-integration contract where the `tools` check in the Patrol readiness payload consults the cached preflight and surfaces the classified evidence (success, soft warning, or failure with classified summary plus "last preflight ") for the configured provider+model when available (falling back to the static `PatrolToolReadinessForModel` classifier only when the cache is empty or holds a result for a different model), the stateless-Patrol-input contract where `ExecutePatrolStream` must pass only the current run's user prompt into the agentic loop rather than reloading the persisted `patrol-main` session history (so a prior run that ended with orphan `tool_calls` cannot poison every subsequent run with malformed conversation structure), and the deterministic-resolve-gate contract where the `patrol_resolve_finding` tool adapter rejects LLM-driven resolves of event/persistent category findings (`backup`, `reliability`, `security`, `general`) when a deterministic verifier exists for the finding's key and that verifier either still detects the failure signal **or returns an inconclusive result** — preventing the LLM from optimistically resolving a finding its current investigation simply didn't re-surface, which was the source of the "Backup failed" flap (detected → auto-resolved → re-detected ten times in a day before this gate). The fail-closed-on-inconclusive policy treats verifier errors (timeouts, executor unavailability, transport faults) as "we don't know" rather than "go ahead": resolution of an event/persistent finding is effectively permanent (next detection registers as a regression and inflates counters), so the safe default is to refuse and require either a successful re-verification or operator action, the assessment-recovery contract where the overall-health "Recent Patrol errors" coverage factor in `summarizeRecentPatrolCoverage` suppresses the score penalty once three consecutive trailing successful full Patrol runs exist at the most-recent end of the recent-runs window — so the grade reflects current reality after a Patrol-affecting bug is fixed rather than dragging stale failures forward for the ~9 hours it takes scheduled runs to age them out of the trailing-10 ratio, the orphan-tool-call-repair contract where `convertToProviderMessages` injects synthetic is_error tool result messages for any `tool_call_id` in an assistant message that has no matching downstream tool result, so a chat session that ended mid-tool-call (network drop, ctx timeout, browser crash) cannot poison its next message with the structural-violation error the provider rejects — the synthetic content is marked is_error=true and explains the interruption so the model can retry the call or proceed without the data, and the patrol-session-bound contract where `ExecutePatrolStream` calls `SessionStore.TrimMessages` after persisting each run's messages to cap the patrol-main session at 200 messages (roughly two recent runs' worth) — without the bound the file grew unbounded at every scheduled run, reaching 16 MB and 3,593 messages within a month and making every `AddMessage` rewrite linearly more expensive; the canonical Patrol forensic log is the `PatrolRunRecord` history surfaced at `/api/ai/patrol/runs`, not the chat-session-shaped file 2. Keep AI runtime and shared API proof routing aligned in `registry.json` 3. Preserve explicit coverage for chat, Patrol, remediation, and cost-control behavior when AI runtime changes. Interactive Assistant and Patrol tool selection must remain model-owned: Pulse may provide governed context, tools, approval state, resource-resolution facts, safety policy, and neutral resource-scoped action history, but it must not add prompt-keyword routers, expected-tool retries, auto-recovery tool calls, keyword-matched prior-fix suggestions, or Pulse-authored remediation/finding fallbacks that choose the next investigative or corrective action for the model. + Assistant FSM gates remain safety boundaries after the model chooses a tool: + repeated model attempts must not waive post-write verification or allow a + new state-changing tool before the model has supplied current verification + evidence through an allowed read/resolve path. Patrol runtime failures are part of that runtime contract: provider, model, tool-calling, auth, quota, rate-limit, context-window, and connectivity failures must be classified in `internal/ai/` before they reach operators, @@ -626,8 +630,12 @@ runtime cost control, and shared AI transport surfaces. Primary nav moved to platform-first on 2026-05-16 through `frontend-modern/src/App.tsx` and `frontend-modern/src/AppLayout.tsx`: the top -of the app exposes the five canonical platform pages (Proxmox, Docker, -Kubernetes, TrueNAS, vSphere) plus Alerts, Patrol, and Settings. The legacy +of the app may expose canonical platform pages (Proxmox, Docker, Kubernetes, +TrueNAS, vSphere) plus Alerts, Patrol, and Settings, but platform destinations +must pass the shared support-and-resource-evidence gate before they appear in +navigation, command palette entries, keyboard shortcuts, or landing +fallbacks. Admitted-only or absent platform surfaces must stay hidden from the +AI-adjacent shell and must not be kept as disabled placeholders. The legacy `/infrastructure`, `/workloads`, `/storage`, `/recovery`, and `/ceph` route shells were retired alongside their page wrappers. The AI Chat launcher, the Patrol surfaces, and the `AssistantHandoffPayload` deep links must use diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 65d0e275c..2141c2b92 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -490,14 +490,14 @@ or other self-hosted uncapped continuity plans. (Proxmox, Docker, Kubernetes, TrueNAS, vSphere) using the canonical path constants from `frontend-modern/src/routing/resourceLinks.ts`. The `PlatformTab` list in `AppLayout.tsx` must enumerate exactly those - supported families and must keep `alwaysShow: true` for every supported - family so first-run operators can discover what Pulse monitors; the - `enabled` and `live` flags are derived from canonical resource presence - in `state.resources` so unconnected platforms render in a disabled tone - and surface their own empty-state setup affordances inside the platform - page itself rather than being hidden from nav. Infrastructure, Workloads, - Storage, and Recovery are not duplicated as equal primary tabs in that - list; their tables are reused inside the platform pages via embedded + supported families, but product navigation is support-and-evidence gated: + `enabled`, `live`, and inclusion in the rendered primary navigation derive + from the governed support manifest plus canonical resource evidence in + `state.resources`. Admitted-only, presentation-only, unsupported, or + absent platform families must not render as disabled placeholders in the + paid or hosted app shell. Infrastructure, Workloads, Storage, and Recovery + are not duplicated as equal primary tabs in that list; their tables are + reused inside the platform pages via embedded `tableOnly` surfaces, and their routes remain wired in `App.tsx` purely for deep-link compatibility. Each platform page must remain chrome-only: routing plus sub-tab navigation that embeds the canonical diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index e8b951c08..8fe00cb7a 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -944,11 +944,13 @@ AI runtime. primitives live under `frontend-modern/src/features/platformPage/` so the chrome stays reusable across families. `frontend-modern/src/AppLayout.tsx` may extend the `PlatformTab` list with - new family entries; those entries must keep `alwaysShow: true` for - supported families so first-run operators discover the full platform set, - while `enabled` and `live` derive from the family's canonical resource - presence in `state.resources` so unconnected platforms render in a - disabled tone with the platform page's own empty-state setup affordance. + new family entries, but primary navigation is a support-and-evidence-gated + surface: rendered platform tabs, command/search destinations, keyboard + shortcuts, and authenticated landing fallbacks must derive from the + governed support manifest plus current runtime resource evidence. + Supported platform families appear when evidence proves they are present; + admitted-only, presentation-only, unsupported, or absent families stay + hidden rather than rendering as disabled placeholders. The `MOBILE_NAV_PLATFORM_PRIORITY` ordering in `frontend-modern/src/components/shared/mobileNavBarModel.ts` mirrors that platform-first set so mobile and desktop primary navigation stay @@ -1383,9 +1385,10 @@ replaced with `nav-proxmox`, `nav-docker`, `nav-kubernetes`, `nav-truenas`, `nav-kubernetes-pods` entry that lands on `/kubernetes/pods`. The shell preload set and `getActiveTabForPath` matcher no longer recognize the legacy top-level routes. New palette commands and shortcut chords must therefore -anchor on canonical platform routes; do not reintroduce a top-level -Infrastructure / Workloads / Storage / Recovery entry by reanimating the -legacy paths. +anchor on canonical platform routes and must flow through the same platform +visibility model as primary navigation; do not reintroduce hidden platform +families or top-level Infrastructure / Workloads / Storage / Recovery entries +by reanimating the legacy paths. The shared table chrome now allows `TableCardHeader` to expose a right-aligned action slot, currently used by the Workloads/Proxmox metric display control. diff --git a/docs/release-control/v6/internal/subsystems/storage-recovery.md b/docs/release-control/v6/internal/subsystems/storage-recovery.md index 4deeb26a0..eebac4dfa 100644 --- a/docs/release-control/v6/internal/subsystems/storage-recovery.md +++ b/docs/release-control/v6/internal/subsystems/storage-recovery.md @@ -214,7 +214,13 @@ bypass the API fail-closed execution gate. second-row rail as sort, charts, and reset, while Node, Source, Role, and Group remain menu/chip filters because their option sets are scope- or data-driven. Subtabs (Pools / Physical Disks) sit above the bar as - navigation, not filters. Storage summary chart visibility remains a + navigation, not filters. Platform-page storage and recovery section tabs + are still capability-gated navigation: Proxmox Storage, + Backups, Ceph, Replication, and adjacent recovery sections may appear only + when the platform page model has matching storage, backup, Ceph, + replication, or provider evidence. Empty capability tabs must not remain as + disabled placeholders on platform pages. + Storage summary chart visibility remains a page-level display preference, but it now rides the shared `FilterBar.viewOptionsTrailing` slot together with the sort key/direction controls. The charts toggle must read as an explicit `Show charts` / diff --git a/frontend-modern/src/App.tsx b/frontend-modern/src/App.tsx index 3a84c21cb..912f603ef 100644 --- a/frontend-modern/src/App.tsx +++ b/frontend-modern/src/App.tsx @@ -11,13 +11,25 @@ import { DemoBanner } from './components/DemoBanner'; import { GitHubStarBanner } from './components/GitHubStarBanner'; // Modals are only mounted when opened, so their code can stay out of the // entry bundle until first use (same pattern as AIChat below). -const KeyboardShortcutsModal = lazy(() => import('./components/shared/KeyboardShortcutsModal').then((m) => ({ default: m.KeyboardShortcutsModal }))); -const CommandPaletteModal = lazy(() => import('./components/shared/CommandPaletteModal').then((m) => ({ default: m.CommandPaletteModal }))); +const KeyboardShortcutsModal = lazy(() => + import('./components/shared/KeyboardShortcutsModal').then((m) => ({ + default: m.KeyboardShortcutsModal, + })), +); +const CommandPaletteModal = lazy(() => + import('./components/shared/CommandPaletteModal').then((m) => ({ + default: m.CommandPaletteModal, + })), +); import { dialogStackHasBlockingDialog } from './components/shared/useDialogState'; import { createTooltipSystem } from './components/shared/Tooltip'; -const TokenRevealDialog = lazy(() => import('./components/TokenRevealDialog').then((m) => ({ default: m.TokenRevealDialog }))); +const TokenRevealDialog = lazy(() => + import('./components/TokenRevealDialog').then((m) => ({ default: m.TokenRevealDialog })), +); import { tokenRevealStore } from './stores/tokenReveal'; -const UpdateProgressModal = lazy(() => import('./components/UpdateProgressModal').then((m) => ({ default: m.UpdateProgressModal }))); +const UpdateProgressModal = lazy(() => + import('./components/UpdateProgressModal').then((m) => ({ default: m.UpdateProgressModal })), +); import { UpdatesAPI, type UpdateStatus } from './api/updates'; // AIChat is the side-panel chat UI plus its store deps (markdown rendering, // tool-call formatting, prompt scaffolding). Lazy-load behind aiChatStore.isOpen @@ -33,6 +45,11 @@ import { PROXMOX_PATH, TRUENAS_PATH, VMWARE_PATH, + buildDockerPath, + buildKubernetesPath, + buildProxmoxPath, + buildTrueNASPath, + buildVmwarePath, } from './routing/resourceLinks'; import { APP_SHELL_ROUTE_PRELOAD_PATHS, preloadRouteModule } from '@/routing/routePreload'; import { AppLayout } from '@/AppLayout'; @@ -43,6 +60,12 @@ import { readPendingAppShellRestoreTop, } from '@/utils/appShellScrollRestoration'; import { DarkModeContext, WebSocketContext, useWebSocket } from '@/contexts/appRuntime'; +import { + buildPrimaryPlatformNavigationVisibility, + selectFirstVisiblePrimaryPlatformNavigationId, + type PlatformNavigationVisibility, + type PrimaryPlatformNavId, +} from '@/features/platformNavigation/platformNavigationModel'; function isPublicRoutePath(pathname: string): boolean { // Public routes must be viewable without authentication. @@ -72,6 +95,23 @@ const SetupCompletionPreviewPage = lazy(() => ); const ROOT_PATROL_PATH = PATROL_PATH; +const PRIMARY_PLATFORM_ROUTE_BY_ID: Record = { + proxmox: buildProxmoxPath(), + docker: buildDockerPath(), + kubernetes: buildKubernetesPath(), + truenas: buildTrueNASPath(), + vmware: buildVmwarePath(), +}; + +function getDefaultWorkspaceRoute( + visibility: PlatformNavigationVisibility, + hasSettingsAccess: boolean, +): string { + const platformId = selectFirstVisiblePrimaryPlatformNavigationId(visibility); + if (platformId) return PRIMARY_PLATFORM_ROUTE_BY_ID[platformId]; + return hasSettingsAccess ? '/settings/infrastructure' : '/alerts'; +} + async function preloadAppShellRoutes() { await Promise.all( APP_SHELL_ROUTE_PRELOAD_PATHS.map(async (route) => { @@ -208,8 +248,22 @@ function App() { const [pendingAppShellRestoreTop, setPendingAppShellRestoreTop] = createSignal( null, ); + const navigate = useNavigate(); const location = useLocation(); const isPublicRoute = createMemo(() => isPublicRoutePath(location.pathname)); + const platformNavigationVisibility = createMemo(() => + buildPrimaryPlatformNavigationVisibility(runtime.state().resources || []), + ); + const platformNavigationResolved = createMemo(() => { + const store = runtime.enhancedStore(); + return Boolean(store?.initialDataReceived?.()); + }); + const hasSettingsAccess = createMemo(() => { + const scopes = runtime.securityStatus()?.tokenScopes; + return ( + !scopes || scopes.length === 0 || scopes.includes('*') || scopes.includes('settings:read') + ); + }); let appShellRoutePreloadCleanup: (() => void) | undefined; let appShellRoutesPreloadScheduled = false; @@ -222,6 +276,16 @@ function App() { } }); + createEffect(() => { + if (runtime.isLoading() || runtime.needsAuth() || isPublicRoute()) return; + const normalizedPath = location.pathname.replace(/\/+$/, '') || '/'; + if (normalizedPath !== '/' && normalizedPath !== '/login') return; + if (!platformNavigationResolved()) return; + navigate(getDefaultWorkspaceRoute(platformNavigationVisibility(), hasSettingsAccess()), { + replace: true, + }); + }); + createEffect(() => { const shell = appScrollShellRef(); const restoreTop = pendingAppShellRestoreTop(); @@ -306,6 +370,7 @@ function App() { useKeyboardShortcuts({ enabled: () => !runtime.needsAuth(), + platformVisibility: platformNavigationVisibility, isShortcutsOpen: shortcutsOpen, isCommandPaletteOpen: commandPaletteOpen, onToggleShortcuts: () => { @@ -422,12 +487,14 @@ function App() { setShortcutsOpen(false)} + platformVisibility={platformNavigationVisibility} /> setCommandPaletteOpen(false)} + platformVisibility={platformNavigationVisibility} /> diff --git a/frontend-modern/src/AppLayout.tsx b/frontend-modern/src/AppLayout.tsx index d3cf847c7..fb4af7083 100644 --- a/frontend-modern/src/AppLayout.tsx +++ b/frontend-modern/src/AppLayout.tsx @@ -11,12 +11,17 @@ import ShipWheelIcon from 'lucide-solid/icons/ship-wheel'; import DatabaseIcon from 'lucide-solid/icons/database'; import CpuIcon from 'lucide-solid/icons/cpu'; import { ProxmoxIcon } from '@/components/icons/ProxmoxIcon'; -import { normalizeSourcePlatformQueryValue } from '@/utils/sourcePlatforms'; import { MobileNavBar, type MobileNavBarPlatformTab as PlatformTab, type MobileNavBarUtilityTab as UtilityTab, } from '@/components/shared/MobileNavBar'; +import { + buildPrimaryPlatformNavigationVisibility, + primaryPlatformNavigationIsVisible, + selectFirstVisiblePrimaryPlatformNavigationId, + type PrimaryPlatformNavId, +} from '@/features/platformNavigation/platformNavigationModel'; import { dialogStackHasBlockingDialog } from '@/components/shared/useDialogState'; import { OrgSwitcher } from '@/components/OrgSwitcher'; import { PulsePatrolLogo } from '@/components/Brand/PulsePatrolLogo'; @@ -49,6 +54,7 @@ const ROOT_DOCKER_PATH = buildDockerPath(); const ROOT_KUBERNETES_PATH = buildKubernetesPath(); const ROOT_TRUENAS_PATH = buildTrueNASPath(); const ROOT_VMWARE_PATH = buildVmwarePath(); +const ROOT_ALERTS_PATH = '/alerts'; const NAV_TAB_ICON_CLASS = 'w-4 h-4 shrink-0'; const AI_CHAT_LAUNCHER_BUTTON_CLASS = 'fixed right-4 bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] z-40 flex h-11 w-11 items-center justify-center rounded-full border border-border bg-surface text-blue-600 shadow-lg transition-colors duration-200 hover:bg-surface-hover hover:text-blue-700 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-500 dark:text-blue-400 dark:hover:text-blue-300 lg:right-0 lg:top-1/2 lg:bottom-auto lg:h-auto lg:w-auto lg:min-h-9 lg:min-w-10 lg:-translate-y-1/2 lg:rounded-l-lg lg:rounded-r-none lg:border-r-0 lg:px-2.5 lg:py-2.5 lg:shadow-none'; @@ -215,6 +221,23 @@ export function AppLayout(props: AppLayoutProps) { setKioskMode(!kioskMode()); }; + const platformNavigationVisibility = createMemo(() => + buildPrimaryPlatformNavigationVisibility(props.state().resources || []), + ); + const primaryPlatformRouteById: Record = { + proxmox: ROOT_PROXMOX_PATH, + docker: ROOT_DOCKER_PATH, + kubernetes: ROOT_KUBERNETES_PATH, + truenas: ROOT_TRUENAS_PATH, + vmware: ROOT_VMWARE_PATH, + }; + const primaryWorkspacePath = createMemo(() => { + const platformId = selectFirstVisiblePrimaryPlatformNavigationId( + platformNavigationVisibility(), + ); + return platformId ? primaryPlatformRouteById[platformId] : ROOT_ALERTS_PATH; + }); + createEffect(() => { if (kioskMode()) { setHeaderVisible(true); @@ -254,8 +277,9 @@ export function AppLayout(props: AppLayoutProps) { !normalizedPath.startsWith('/alerts/overview/') && !normalizedPath.startsWith('/alerts/history/'); - if ((isBlocked || isAlertConfigTab) && normalizedPath !== ROOT_PROXMOX_PATH) { - navigate(ROOT_PROXMOX_PATH, { replace: true }); + const targetPath = primaryWorkspacePath(); + if ((isBlocked || isAlertConfigTab) && normalizedPath !== targetPath) { + navigate(targetPath, { replace: true }); } }); @@ -291,25 +315,17 @@ export function AppLayout(props: AppLayoutProps) { const getActiveTabDesktop = () => getActiveTabForPath(location.pathname); const getActiveTabMobile = () => getActiveTabForPath(location.pathname); - const platformPresence = createMemo(() => { - const presence = new Set(); - for (const resource of props.state().resources || []) { - const key = normalizeSourcePlatformQueryValue(resource.platformType || ''); - if (key) presence.add(key); - } - return presence; - }); - - // Primary nav is platform-first. Every supported platform family is - // always visible so first-run users can discover what Pulse monitors; - // platforms with no connected resources still render their canonical - // empty state inside the platform page itself. Infrastructure / Workloads - // / Storage / Recovery are NOT duplicated as equal primary tabs — their - // tables are reused inside each platform page via embedded tableOnly - // surfaces, and their routes remain wired in App.tsx purely for - // route-compatibility with existing deep links. + // Primary nav is platform-first and resource-admitted. A platform tab only + // appears when the support manifest says the platform is supported and the + // current resource snapshot proves that platform is actually present. + // Infrastructure / Workloads / Storage / Recovery are NOT duplicated as + // equal primary tabs — their tables are reused inside each platform page + // via embedded tableOnly surfaces, and their routes remain wired in App.tsx + // purely for route-compatibility with existing deep links. const platformTabs = createMemo(() => { - const presence = platformPresence(); + const visible = platformNavigationVisibility(); + const isVisible = (id: PlatformTab['id']) => + primaryPlatformNavigationIsVisible(visible, id as PrimaryPlatformNavId); const allPlatforms: PlatformTab[] = [ { id: 'proxmox', @@ -317,10 +333,10 @@ export function AppLayout(props: AppLayoutProps) { route: ROOT_PROXMOX_PATH, settingsRoute: '/settings/infrastructure/platforms/proxmox/pve', tooltip: 'Proxmox VE, Backup Server, Mail Gateway, storage, backups, and guests', - enabled: presence.has('proxmox-pve'), - live: presence.has('proxmox-pve'), + enabled: isVisible('proxmox'), + live: isVisible('proxmox'), icon: ProxmoxIcon, - alwaysShow: true, + alwaysShow: false, }, { id: 'docker', @@ -328,10 +344,10 @@ export function AppLayout(props: AppLayoutProps) { route: ROOT_DOCKER_PATH, settingsRoute: '/settings/workloads/docker', tooltip: 'Docker and Podman hosts, containers, and Swarm services', - enabled: presence.has('docker'), - live: presence.has('docker'), + enabled: isVisible('docker'), + live: isVisible('docker'), icon: ContainerIcon, - alwaysShow: true, + alwaysShow: false, }, { id: 'kubernetes', @@ -339,10 +355,10 @@ export function AppLayout(props: AppLayoutProps) { route: ROOT_KUBERNETES_PATH, settingsRoute: '/settings', tooltip: 'Kubernetes clusters, nodes, pods, deployments, and services', - enabled: presence.has('kubernetes'), - live: presence.has('kubernetes'), + enabled: isVisible('kubernetes'), + live: isVisible('kubernetes'), icon: ShipWheelIcon, - alwaysShow: true, + alwaysShow: false, }, { id: 'truenas', @@ -350,10 +366,10 @@ export function AppLayout(props: AppLayoutProps) { route: ROOT_TRUENAS_PATH, settingsRoute: '/settings/infrastructure', tooltip: 'TrueNAS hosts, storage, and apps', - enabled: presence.has('truenas'), - live: presence.has('truenas'), + enabled: isVisible('truenas'), + live: isVisible('truenas'), icon: DatabaseIcon, - alwaysShow: true, + alwaysShow: false, }, { id: 'vmware', @@ -361,10 +377,10 @@ export function AppLayout(props: AppLayoutProps) { route: ROOT_VMWARE_PATH, settingsRoute: '/settings/infrastructure', tooltip: 'VMware vSphere hosts, virtual machines, and datastores', - enabled: presence.has('vmware-vsphere'), - live: presence.has('vmware-vsphere'), + enabled: isVisible('vmware'), + live: isVisible('vmware'), icon: CpuIcon, - alwaysShow: true, + alwaysShow: false, }, ]; diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index 14bc95bcc..4a0aaccf3 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -73,6 +73,11 @@ describe('App architecture', () => { expect(appLayoutSource).toContain("id: 'vmware',"); // Platform-first primary nav: Infrastructure / Workloads / Storage / // Recovery are not duplicated as equal primary PlatformTab entries. + expect(appSource).toContain('getDefaultWorkspaceRoute'); + expect(appSource).toContain('platformNavigationResolved'); + expect(appSource).toContain('buildPrimaryPlatformNavigationVisibility'); + expect(appLayoutSource).toContain('buildPrimaryPlatformNavigationVisibility'); + expect(appLayoutSource).toContain('primaryPlatformNavigationIsVisible'); expect(appLayoutSource).not.toContain("id: 'infrastructure',"); expect(appLayoutSource).not.toContain("id: 'workloads',"); expect(appLayoutSource).not.toContain('buildStorageRecoveryTabSpecs('); diff --git a/frontend-modern/src/__tests__/AppLayout.test.tsx b/frontend-modern/src/__tests__/AppLayout.test.tsx index 0a0544240..737797361 100644 --- a/frontend-modern/src/__tests__/AppLayout.test.tsx +++ b/frontend-modern/src/__tests__/AppLayout.test.tsx @@ -2,6 +2,7 @@ import { cleanup, render, screen, within } from '@solidjs/testing-library'; import { Route, Router } from '@solidjs/router'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { State } from '@/types/api'; +import type { Resource } from '@/types/resource'; import { AppLayout } from '@/AppLayout'; import { aiChatStore } from '@/stores/aiChat'; @@ -20,7 +21,21 @@ describe('AppLayout navigation icons', () => { cleanup(); }); - const renderLayout = () => + const makeResource = (overrides: Partial): Resource => + ({ + id: overrides.id ?? 'resource-1', + name: overrides.name ?? overrides.id ?? 'resource-1', + displayName: overrides.displayName ?? overrides.name ?? overrides.id ?? 'resource-1', + type: overrides.type ?? 'agent', + platformId: overrides.platformId ?? 'platform-1', + platformType: overrides.platformType ?? 'agent', + sourceType: overrides.sourceType ?? 'api', + status: overrides.status ?? 'online', + lastSeen: overrides.lastSeen ?? 1_700_000_000_000, + ...overrides, + }) as Resource; + + const renderLayout = (resources: Resource[] = []) => render(() => ( { state={() => ({ activeAlerts: [{ id: 'alert-1', level: 'warning', acknowledged: false }], + resources, }) as unknown as State } tokenScopes={() => ['settings:read']} @@ -99,6 +115,34 @@ describe('AppLayout navigation icons', () => { expect(container).toHaveTextContent('Infrastructure body'); }); + it('hides platform tabs without supported infrastructure evidence', () => { + renderLayout([ + makeResource({ id: 'pve-1', type: 'agent', platformType: 'proxmox-pve' }), + makeResource({ id: 'docker-1', type: 'docker-host', platformType: 'docker' }), + makeResource({ id: 'vcenter-1', type: 'vm', platformType: 'vmware-vsphere' }), + ]); + + const desktopNav = screen.getByRole('tablist', { name: 'Primary navigation' }); + const infrastructureGroup = desktopNav.querySelector('[aria-label="Infrastructure"]'); + expect(infrastructureGroup).toBeTruthy(); + + expect( + within(infrastructureGroup as HTMLElement).getByRole('tab', { name: 'Proxmox' }), + ).toBeTruthy(); + expect( + within(infrastructureGroup as HTMLElement).getByRole('tab', { name: 'Docker' }), + ).toBeTruthy(); + expect( + within(infrastructureGroup as HTMLElement).queryByRole('tab', { name: 'Kubernetes' }), + ).toBeNull(); + expect( + within(infrastructureGroup as HTMLElement).queryByRole('tab', { name: 'TrueNAS' }), + ).toBeNull(); + expect( + within(infrastructureGroup as HTMLElement).queryByRole('tab', { name: 'vSphere' }), + ).toBeNull(); + }); + it('keeps connected brand motion on the logo while the wordmark stays static', () => { const { container } = renderLayout(); diff --git a/frontend-modern/src/components/shared/KeyboardShortcutsModal.tsx b/frontend-modern/src/components/shared/KeyboardShortcutsModal.tsx index 74cfbc336..13aeeec79 100644 --- a/frontend-modern/src/components/shared/KeyboardShortcutsModal.tsx +++ b/frontend-modern/src/components/shared/KeyboardShortcutsModal.tsx @@ -1,5 +1,9 @@ import { For, createMemo } from 'solid-js'; import { Dialog } from '@/components/shared/Dialog'; +import { + primaryPlatformNavigationIsVisible, + type PlatformNavigationVisibility, +} from '@/features/platformNavigation/platformNavigationModel'; interface ShortcutGroup { title: string; @@ -9,6 +13,7 @@ interface ShortcutGroup { interface KeyboardShortcutsModalProps { isOpen: boolean; onClose: () => void; + platformVisibility: () => PlatformNavigationVisibility; } const UNIFIED_NAV_SHORTCUTS: ShortcutGroup = { @@ -25,6 +30,14 @@ const UNIFIED_NAV_SHORTCUTS: ShortcutGroup = { ], }; +const NAV_PLATFORM_SHORTCUTS: Record = { + 'g then p': 'proxmox', + 'g then d': 'docker', + 'g then k': 'kubernetes', + 'g then n': 'truenas', + 'g then v': 'vmware', +}; + const SEARCH_SHORTCUTS: ShortcutGroup = { title: 'Search & Help', items: [ @@ -37,7 +50,13 @@ const SEARCH_SHORTCUTS: ShortcutGroup = { export function KeyboardShortcutsModal(props: KeyboardShortcutsModalProps) { const shortcutGroups = createMemo(() => { - return [UNIFIED_NAV_SHORTCUTS, SEARCH_SHORTCUTS]; + const platformVisibility = props.platformVisibility(); + const visibleNavigationItems = UNIFIED_NAV_SHORTCUTS.items.filter((item) => { + const platformId = NAV_PLATFORM_SHORTCUTS[item.keys]; + if (!platformId) return true; + return primaryPlatformNavigationIsVisible(platformVisibility, platformId); + }); + return [{ ...UNIFIED_NAV_SHORTCUTS, items: visibleNavigationItems }, SEARCH_SHORTCUTS]; }); return ( diff --git a/frontend-modern/src/components/shared/__tests__/CommandPaletteModal.test.tsx b/frontend-modern/src/components/shared/__tests__/CommandPaletteModal.test.tsx index 1828a1246..2aba3a452 100644 --- a/frontend-modern/src/components/shared/__tests__/CommandPaletteModal.test.tsx +++ b/frontend-modern/src/components/shared/__tests__/CommandPaletteModal.test.tsx @@ -1,9 +1,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { cleanup, fireEvent, render, screen } from '@solidjs/testing-library'; import type { JSX } from 'solid-js'; +import type { Resource } from '@/types/resource'; import commandPaletteModalSource from '@/components/shared/CommandPaletteModal.tsx?raw'; import commandPaletteModelSource from '@/components/shared/commandPaletteModel.ts?raw'; import commandPaletteStateSource from '@/components/shared/useCommandPaletteState.ts?raw'; +import { buildPrimaryPlatformNavigationVisibility } from '@/features/platformNavigation/platformNavigationModel'; const navigateMock = vi.fn(); @@ -18,6 +20,26 @@ vi.mock('@/components/shared/Dialog', () => ({ import { CommandPaletteModal } from '@/components/shared/CommandPaletteModal'; +const makeResource = (overrides: Partial): Resource => + ({ + id: overrides.id ?? 'resource-1', + name: overrides.name ?? overrides.id ?? 'resource-1', + displayName: overrides.displayName ?? overrides.name ?? overrides.id ?? 'resource-1', + type: overrides.type ?? 'agent', + platformId: overrides.platformId ?? 'platform-1', + platformType: overrides.platformType ?? 'agent', + sourceType: overrides.sourceType ?? 'api', + status: overrides.status ?? 'online', + lastSeen: overrides.lastSeen ?? 1_700_000_000_000, + ...overrides, + }) as Resource; + +const platformVisibility = () => + buildPrimaryPlatformNavigationVisibility([ + makeResource({ id: 'pve-1', type: 'agent', platformType: 'proxmox-pve' }), + makeResource({ id: 'k8s-1', type: 'k8s-cluster', platformType: 'kubernetes' }), + ]); + describe('CommandPaletteModal', () => { afterEach(() => { cleanup(); @@ -58,7 +80,13 @@ describe('CommandPaletteModal', () => { }); it('renders the platform entries and the dedicated Kubernetes pods command', () => { - render(() => ); + render(() => ( + + )); expect(screen.getByText('Go to Proxmox')).toBeInTheDocument(); expect(screen.getByText('Go to Kubernetes Pods')).toBeInTheDocument(); @@ -67,7 +95,13 @@ describe('CommandPaletteModal', () => { it('navigates to the Kubernetes pods sub-tab', async () => { const onClose = vi.fn(); - render(() => ); + render(() => ( + + )); await fireEvent.click(screen.getByText('Go to Kubernetes Pods')); @@ -77,7 +111,13 @@ describe('CommandPaletteModal', () => { it('uses the shared search input and keeps Enter selection behavior', async () => { const onClose = vi.fn(); - render(() => ); + render(() => ( + + )); const input = screen.getByPlaceholderText('Type a command or search...'); await fireEvent.input(input, { target: { value: 'kubernetes pods' } }); @@ -86,4 +126,25 @@ describe('CommandPaletteModal', () => { expect(navigateMock).toHaveBeenCalledWith('/kubernetes/pods'); expect(onClose).toHaveBeenCalledTimes(1); }); + + it('hides platform commands without supported infrastructure evidence', () => { + render(() => ( + + buildPrimaryPlatformNavigationVisibility([ + makeResource({ id: 'pve-1', type: 'agent', platformType: 'proxmox-pve' }), + makeResource({ id: 'vmware-1', type: 'vm', platformType: 'vmware-vsphere' }), + ]) + } + /> + )); + + expect(screen.getByText('Go to Proxmox')).toBeInTheDocument(); + expect(screen.queryByText('Go to Docker')).not.toBeInTheDocument(); + expect(screen.queryByText('Go to Kubernetes')).not.toBeInTheDocument(); + expect(screen.queryByText('Go to TrueNAS')).not.toBeInTheDocument(); + expect(screen.queryByText('Go to vSphere')).not.toBeInTheDocument(); + }); }); diff --git a/frontend-modern/src/components/shared/commandPaletteModel.ts b/frontend-modern/src/components/shared/commandPaletteModel.ts index 493640fe5..b04f3282c 100644 --- a/frontend-modern/src/components/shared/commandPaletteModel.ts +++ b/frontend-modern/src/components/shared/commandPaletteModel.ts @@ -1,6 +1,12 @@ +import { + primaryPlatformNavigationIsVisible, + type PlatformNavigationVisibility, +} from '@/features/platformNavigation/platformNavigationModel'; + export interface CommandPaletteModalProps { isOpen: boolean; onClose: () => void; + platformVisibility: () => PlatformNavigationVisibility; } export type CommandPaletteModalCommand = { @@ -23,56 +29,76 @@ export type CommandPaletteCommandPaths = { export function buildCommandPaletteCommands(options: { paths: CommandPaletteCommandPaths; + platformVisibility: PlatformNavigationVisibility; navigate: (path: string) => void; }): CommandPaletteModalCommand[] { - return [ - { + const commands: CommandPaletteModalCommand[] = []; + + if (primaryPlatformNavigationIsVisible(options.platformVisibility, 'proxmox')) { + commands.push({ id: 'nav-proxmox', label: 'Go to Proxmox', description: options.paths.proxmoxPath, shortcut: 'g p', keywords: ['proxmox', 'pve', 'pbs', 'pmg', 'mail', 'backups', 'ceph', 'vm', 'lxc'], action: () => options.navigate(options.paths.proxmoxPath), - }, - { + }); + } + + if (primaryPlatformNavigationIsVisible(options.platformVisibility, 'docker')) { + commands.push({ id: 'nav-docker', label: 'Go to Docker', description: options.paths.dockerPath, shortcut: 'g d', keywords: ['docker', 'podman', 'containers', 'compose', 'swarm', 'services'], action: () => options.navigate(options.paths.dockerPath), - }, - { - id: 'nav-kubernetes', - label: 'Go to Kubernetes', - description: options.paths.kubernetesPath, - shortcut: 'g k', - keywords: ['k8s', 'kubernetes', 'clusters', 'nodes', 'deployments'], - action: () => options.navigate(options.paths.kubernetesPath), - }, - { - id: 'nav-kubernetes-pods', - label: 'Go to Kubernetes Pods', - description: options.paths.kubernetesPodsPath, - keywords: ['k8s', 'kubernetes', 'pods', 'workloads'], - action: () => options.navigate(options.paths.kubernetesPodsPath), - }, - { + }); + } + + if (primaryPlatformNavigationIsVisible(options.platformVisibility, 'kubernetes')) { + commands.push( + { + id: 'nav-kubernetes', + label: 'Go to Kubernetes', + description: options.paths.kubernetesPath, + shortcut: 'g k', + keywords: ['k8s', 'kubernetes', 'clusters', 'nodes', 'deployments'], + action: () => options.navigate(options.paths.kubernetesPath), + }, + { + id: 'nav-kubernetes-pods', + label: 'Go to Kubernetes Pods', + description: options.paths.kubernetesPodsPath, + keywords: ['k8s', 'kubernetes', 'pods', 'workloads'], + action: () => options.navigate(options.paths.kubernetesPodsPath), + }, + ); + } + + if (primaryPlatformNavigationIsVisible(options.platformVisibility, 'truenas')) { + commands.push({ id: 'nav-truenas', label: 'Go to TrueNAS', description: options.paths.trueNasPath, shortcut: 'g n', keywords: ['truenas', 'storage', 'disks', 'apps'], action: () => options.navigate(options.paths.trueNasPath), - }, - { + }); + } + + if (primaryPlatformNavigationIsVisible(options.platformVisibility, 'vmware')) { + commands.push({ id: 'nav-vmware', label: 'Go to vSphere', description: options.paths.vmwarePath, shortcut: 'g v', keywords: ['vmware', 'vsphere', 'esxi', 'vms', 'datastores'], action: () => options.navigate(options.paths.vmwarePath), - }, + }); + } + + commands.push( { id: 'nav-alerts', label: 'Go to Alerts', @@ -97,7 +123,9 @@ export function buildCommandPaletteCommands(options: { keywords: ['preferences', 'config'], action: () => options.navigate('/settings'), }, - ]; + ); + + return commands; } export function normalizeCommandPaletteQuery(query: string): string { diff --git a/frontend-modern/src/components/shared/useCommandPaletteState.ts b/frontend-modern/src/components/shared/useCommandPaletteState.ts index 52dd6063b..6ad8da36c 100644 --- a/frontend-modern/src/components/shared/useCommandPaletteState.ts +++ b/frontend-modern/src/components/shared/useCommandPaletteState.ts @@ -14,10 +14,7 @@ import { type CommandPaletteModalProps, } from './commandPaletteModel'; -export type { - CommandPaletteModalCommand, - CommandPaletteModalProps, -} from './commandPaletteModel'; +export type { CommandPaletteModalCommand, CommandPaletteModalProps } from './commandPaletteModel'; export function useCommandPaletteState(props: CommandPaletteModalProps) { const navigate = useNavigate(); @@ -34,13 +31,12 @@ export function useCommandPaletteState(props: CommandPaletteModalProps) { trueNasPath: buildTrueNASPath(), vmwarePath: buildVmwarePath(), }, + platformVisibility: props.platformVisibility(), navigate, }), ); - const filteredCommands = createMemo(() => - filterCommandPaletteCommands(commands(), query()), - ); + const filteredCommands = createMemo(() => filterCommandPaletteCommands(commands(), query())); const handleSelect = (command: CommandPaletteModalCommand) => { command.action(); diff --git a/frontend-modern/src/features/platformNavigation/__tests__/platformNavigationModel.test.ts b/frontend-modern/src/features/platformNavigation/__tests__/platformNavigationModel.test.ts new file mode 100644 index 000000000..9c153deb5 --- /dev/null +++ b/frontend-modern/src/features/platformNavigation/__tests__/platformNavigationModel.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from 'vitest'; +import type { Resource } from '@/types/resource'; +import { + buildPrimaryPlatformNavigationVisibility, + collectResourcePlatformEvidence, + selectFirstVisiblePrimaryPlatformNavigationId, +} from '../platformNavigationModel'; + +const resource = (overrides: Partial): Resource => + ({ + id: overrides.id ?? 'resource-1', + name: overrides.name ?? overrides.id ?? 'resource-1', + displayName: overrides.displayName ?? overrides.name ?? overrides.id ?? 'resource-1', + type: overrides.type ?? 'agent', + platformId: overrides.platformId ?? 'platform-1', + platformType: overrides.platformType ?? 'agent', + sourceType: overrides.sourceType ?? 'api', + status: overrides.status ?? 'online', + lastSeen: overrides.lastSeen ?? 1_700_000_000_000, + ...overrides, + }) as Resource; + +describe('platformNavigationModel', () => { + it('shows primary platform destinations only when supported resource evidence is present', () => { + expect(buildPrimaryPlatformNavigationVisibility([])).toEqual({ + proxmox: false, + docker: false, + kubernetes: false, + truenas: false, + vmware: false, + }); + + expect( + buildPrimaryPlatformNavigationVisibility([ + resource({ id: 'pve-1', platformType: 'proxmox-pve', type: 'agent' }), + resource({ + id: 'pod-1', + platformType: 'agent', + type: 'pod', + platformData: { sources: ['kubernetes'] }, + }), + resource({ id: 'truenas-1', platformType: 'truenas', type: 'agent' }), + resource({ id: 'vcenter-1', platformType: 'vmware-vsphere', type: 'vm' }), + ]), + ).toEqual({ + proxmox: true, + docker: false, + kubernetes: true, + truenas: true, + vmware: false, + }); + }); + + it('derives Proxmox suite evidence from canonical PBS and PMG resource types', () => { + expect(collectResourcePlatformEvidence(resource({ id: 'pbs-1', type: 'pbs' }))).toContain( + 'proxmox-pbs', + ); + expect(collectResourcePlatformEvidence(resource({ id: 'pmg-1', type: 'pmg' }))).toContain( + 'proxmox-pmg', + ); + expect( + buildPrimaryPlatformNavigationVisibility([resource({ id: 'pmg-1', type: 'pmg' })]).proxmox, + ).toBe(true); + }); + + it('selects the first visible platform using the canonical primary navigation order', () => { + expect( + selectFirstVisiblePrimaryPlatformNavigationId({ + proxmox: false, + docker: false, + kubernetes: true, + truenas: true, + vmware: false, + }), + ).toBe('kubernetes'); + + expect( + selectFirstVisiblePrimaryPlatformNavigationId({ + proxmox: false, + docker: false, + kubernetes: false, + truenas: false, + vmware: false, + }), + ).toBeNull(); + }); +}); diff --git a/frontend-modern/src/features/platformNavigation/platformNavigationModel.ts b/frontend-modern/src/features/platformNavigation/platformNavigationModel.ts new file mode 100644 index 000000000..a7959757e --- /dev/null +++ b/frontend-modern/src/features/platformNavigation/platformNavigationModel.ts @@ -0,0 +1,187 @@ +import type { Accessor } from 'solid-js'; +import type { Resource, ResourceType } from '@/types/resource'; +import { + SUPPORTED_PLATFORM_IDS, + getSourcePlatformManifestEntry, + type SourcePlatformManifestEntry, +} from '@/utils/platformSupportManifest'; +import { normalizeSourcePlatformKey, resolveResourcePlatformType } from '@/utils/sourcePlatforms'; + +export type PrimaryPlatformNavId = 'proxmox' | 'docker' | 'kubernetes' | 'truenas' | 'vmware'; + +export type PlatformNavigationVisibility = Record; + +export type PlatformNavigationShortcut = { + key: string; + route: string; +}; + +export const PRIMARY_PLATFORM_NAV_IDS: readonly PrimaryPlatformNavId[] = [ + 'proxmox', + 'docker', + 'kubernetes', + 'truenas', + 'vmware', +] as const; + +export const PRIMARY_PLATFORM_NAV_PLATFORM_IDS: Record = { + proxmox: ['proxmox-pve', 'proxmox-pbs', 'proxmox-pmg'], + docker: ['docker'], + kubernetes: ['kubernetes'], + truenas: ['truenas'], + vmware: ['vmware-vsphere'], +}; + +const SUPPORTED_PLATFORM_ID_SET = new Set(SUPPORTED_PLATFORM_IDS); + +const KUBERNETES_RESOURCE_TYPES = new Set([ + 'k8s-cluster', + 'k8s-node', + 'pod', + 'k8s-deployment', + 'k8s-service', +]); +const DOCKER_RESOURCE_TYPES = new Set(['docker-host', 'docker-service']); + +const asRecord = (value: unknown): Record | null => + typeof value === 'object' && value !== null ? (value as Record) : null; + +const addManifestPlatformId = (ids: Set, value: string | null | undefined): void => { + const normalized = normalizeSourcePlatformKey(value); + if (!normalized || normalized === 'generic') return; + if (!getSourcePlatformManifestEntry(normalized)) return; + ids.add(normalized); +}; + +const addPlatformDataSources = (ids: Set, platformData: Record | null) => { + const sources = platformData?.sources; + if (!Array.isArray(sources)) return; + sources.forEach((source) => { + if (typeof source === 'string') addManifestPlatformId(ids, source); + }); +}; + +export function collectResourcePlatformEvidence(resource: Resource): string[] { + const ids = new Set(); + addManifestPlatformId(ids, resource.platformType); + addManifestPlatformId(ids, resolveResourcePlatformType(resource)); + resource.sources?.forEach((source) => addManifestPlatformId(ids, source)); + + const platformData = asRecord(resource.platformData); + addPlatformDataSources(ids, platformData); + + if (resource.type === 'pbs' || resource.pbs || asRecord(platformData?.pbs)) { + ids.add('proxmox-pbs'); + } + if (resource.type === 'pmg' || resource.pmg || asRecord(platformData?.pmg)) { + ids.add('proxmox-pmg'); + } + if (resource.proxmox || asRecord(platformData?.proxmox)) { + ids.add('proxmox-pve'); + } + if (resource.type === 'ceph' || resource.ceph || asRecord(platformData?.ceph)) { + ids.add('proxmox-pve'); + } + if (resource.vmware || asRecord(platformData?.vmware)) { + ids.add('vmware-vsphere'); + } + if (resource.kubernetes || asRecord(platformData?.kubernetes)) { + ids.add('kubernetes'); + } + if ( + resource.docker || + asRecord(platformData?.docker) || + DOCKER_RESOURCE_TYPES.has(resource.type) + ) { + ids.add('docker'); + } + if (KUBERNETES_RESOURCE_TYPES.has(resource.type)) { + ids.add('kubernetes'); + } + + return [...ids]; +} + +export function buildSupportedResourcePlatformSet(resources: readonly Resource[]): Set { + const present = new Set(); + for (const resource of resources) { + for (const platformId of collectResourcePlatformEvidence(resource)) { + const manifestEntry = getSourcePlatformManifestEntry( + platformId, + ) as SourcePlatformManifestEntry | null; + if (!manifestEntry || !SUPPORTED_PLATFORM_ID_SET.has(manifestEntry.id)) continue; + present.add(manifestEntry.id); + } + } + return present; +} + +export function buildPrimaryPlatformNavigationVisibility( + resources: readonly Resource[], +): PlatformNavigationVisibility { + const presentSupportedPlatforms = buildSupportedResourcePlatformSet(resources); + return { + proxmox: PRIMARY_PLATFORM_NAV_PLATFORM_IDS.proxmox.some((id) => + presentSupportedPlatforms.has(id), + ), + docker: PRIMARY_PLATFORM_NAV_PLATFORM_IDS.docker.some((id) => + presentSupportedPlatforms.has(id), + ), + kubernetes: PRIMARY_PLATFORM_NAV_PLATFORM_IDS.kubernetes.some((id) => + presentSupportedPlatforms.has(id), + ), + truenas: PRIMARY_PLATFORM_NAV_PLATFORM_IDS.truenas.some((id) => + presentSupportedPlatforms.has(id), + ), + vmware: PRIMARY_PLATFORM_NAV_PLATFORM_IDS.vmware.some((id) => + presentSupportedPlatforms.has(id), + ), + }; +} + +export function primaryPlatformNavigationIsVisible( + visibility: PlatformNavigationVisibility, + platformId: PrimaryPlatformNavId, +): boolean { + return visibility[platformId] === true; +} + +export function selectFirstVisiblePrimaryPlatformNavigationId( + visibility: PlatformNavigationVisibility, +): PrimaryPlatformNavId | null { + return ( + PRIMARY_PLATFORM_NAV_IDS.find((platformId) => + primaryPlatformNavigationIsVisible(visibility, platformId), + ) ?? null + ); +} + +export function filterPlatformNavigationShortcuts( + shortcuts: Record, + visibility: PlatformNavigationVisibility, +): Record { + const routes: Record = {}; + for (const [platformId, shortcut] of Object.entries(shortcuts)) { + if (!primaryPlatformNavigationIsVisible(visibility, platformId as PrimaryPlatformNavId)) { + continue; + } + routes[shortcut.key] = shortcut.route; + } + return routes; +} + +export function createEmptyPlatformNavigationVisibility(): PlatformNavigationVisibility { + return { + proxmox: false, + docker: false, + kubernetes: false, + truenas: false, + vmware: false, + }; +} + +export function platformNavigationVisibilityFromResources( + resources: Accessor, +): PlatformNavigationVisibility { + return buildPrimaryPlatformNavigationVisibility(resources()); +} diff --git a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx index 32a015d2c..f150ac83c 100644 --- a/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx +++ b/frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx @@ -28,6 +28,7 @@ import { useUnifiedResources } from '@/hooks/useUnifiedResources'; import { PROXMOX_TAB_SPECS, buildProxmoxPageModel, + buildVisibleProxmoxTabSpecs, type ProxmoxPageTabId, } from './proxmoxPageModel'; @@ -53,11 +54,16 @@ export function ProxmoxPageSurface() { cacheKey: 'proxmox-workspace', initialHydration: 'prefer-ws-then-rest', }); + const model = createMemo(() => buildProxmoxPageModel(resources())); + const visibleTabs = createMemo(() => buildVisibleProxmoxTabSpecs(model())); + const visibleTabIds = createMemo( + () => new Set(visibleTabs().map((tab) => tab.id)), + ); const activeTab = createMemo(() => { const segment = location.pathname.split('/').filter(Boolean)[1] as ProxmoxPageTabId | undefined; - return segment && VALID_TABS.has(segment) ? segment : 'overview'; + if (!segment || !VALID_TABS.has(segment)) return 'overview'; + return visibleTabIds().has(segment) ? segment : 'overview'; }); - const model = createMemo(() => buildProxmoxPageModel(resources())); // The hosts table at the top and the embedded WorkloadsSurface below share // the bars/sparklines toggle (and the sparkline history range that ships @@ -85,11 +91,7 @@ export function ProxmoxPageSurface() { return (
- + 0} diff --git a/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts b/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts index 0e6cad098..5a71f1891 100644 --- a/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts +++ b/frontend-modern/src/features/proxmox/__tests__/proxmoxPageModel.test.ts @@ -3,6 +3,7 @@ import type { Resource } from '@/types/resource'; import { PROXMOX_TAB_SPECS, buildProxmoxPageModel, + buildVisibleProxmoxTabSpecs, getResourceVersion, resolveProxmoxPlatformScope, } from '../proxmoxPageModel'; @@ -119,6 +120,35 @@ describe('proxmoxPageModel', () => { change: expect.objectContaining({ id: 'replication-1' }), }); expect(model.resources.map((resource) => resource.id)).not.toContain('docker-host'); + expect(buildVisibleProxmoxTabSpecs(model).map((tab) => tab.id)).toEqual([ + 'overview', + 'storage', + 'replication', + 'backups', + 'ceph', + 'mail', + ]); + }); + + it('hides Proxmox sub-tabs when the connected estate has no matching resource evidence', () => { + const model = buildProxmoxPageModel([ + makeResource({ + id: 'pve-node-1', + type: 'agent', + displayName: 'pve-node-1', + proxmox: { nodeName: 'pve-node-1', clusterName: 'alpha' }, + }), + makeResource({ + id: 'vm-101', + type: 'vm', + displayName: 'database', + parentName: 'pve-node-1', + status: 'running', + proxmox: { vmid: 101, nodeName: 'pve-node-1' }, + }), + ]); + + expect(buildVisibleProxmoxTabSpecs(model).map((tab) => tab.id)).toEqual(['overview']); }); it('resolves Proxmox suite scope from canonical platform hints', () => { diff --git a/frontend-modern/src/features/proxmox/proxmoxPageModel.ts b/frontend-modern/src/features/proxmox/proxmoxPageModel.ts index 340f70619..a67cdfc71 100644 --- a/frontend-modern/src/features/proxmox/proxmoxPageModel.ts +++ b/frontend-modern/src/features/proxmox/proxmoxPageModel.ts @@ -213,6 +213,30 @@ const hasReplicationSignal = (change: ResourceChange): boolean => { return haystack.includes('replication') || haystack.includes('replica'); }; +const hasBackupSignal = (resource: Resource): boolean => { + if (getResourceLastBackup(resource) !== null) return true; + const haystack = [ + resource.id, + resource.name, + resource.displayName, + ...(resource.tags ?? []), + ...(resource.recentChanges ?? []).flatMap((change) => [ + change.id, + change.kind, + change.sourceAdapter, + change.reason, + ...(change.relatedResources ?? []), + ]), + ] + .filter((value): value is string => typeof value === 'string') + .join(' ') + .toLowerCase(); + + return ( + haystack.includes('backup') || haystack.includes('snapshot') || haystack.includes('vzdump') + ); +}; + function buildReplicationChanges(resources: Resource[]): ProxmoxReplicationChange[] { return resources .flatMap((resource) => @@ -346,3 +370,25 @@ export function buildProxmoxPageModel(resources: Resource[]): ProxmoxPageModel { }, }; } + +export function buildVisibleProxmoxTabSpecs(model: ProxmoxPageModel): ProxmoxTabSpec[] { + const visible = new Set(['overview']); + + if (model.storage.length > 0 || model.physicalDisks.length > 0) { + visible.add('storage'); + } + if (model.replicationChanges.length > 0) { + visible.add('replication'); + } + if (model.resources.some(hasBackupSignal) || model.pbs.length > 0) { + visible.add('backups'); + } + if (model.ceph.length > 0) { + visible.add('ceph'); + } + if (model.pmg.length > 0) { + visible.add('mail'); + } + + return PROXMOX_TAB_SPECS.filter((tab) => visible.has(tab.id)); +} diff --git a/frontend-modern/src/hooks/useKeyboardShortcuts.ts b/frontend-modern/src/hooks/useKeyboardShortcuts.ts index c635d4ee9..08233fe78 100644 --- a/frontend-modern/src/hooks/useKeyboardShortcuts.ts +++ b/frontend-modern/src/hooks/useKeyboardShortcuts.ts @@ -7,6 +7,11 @@ import { buildTrueNASPath, buildVmwarePath, } from '@/routing/resourceLinks'; +import { + createEmptyPlatformNavigationVisibility, + filterPlatformNavigationShortcuts, + type PlatformNavigationVisibility, +} from '@/features/platformNavigation/platformNavigationModel'; import { focusActiveTypeToSearch } from '@/hooks/useTypeToSearch'; type KeyboardShortcutsOptions = { @@ -20,6 +25,7 @@ type KeyboardShortcutsOptions = { onCloseCommandPalette?: () => void; onToggleCommandPalette?: () => void; onFocusSearch?: () => boolean | void; + platformVisibility?: Accessor; }; const isEditableTarget = (target: EventTarget | null): boolean => { @@ -76,12 +82,20 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) { }; const getRoutes = (): Record => { + const platformVisibility = + options.platformVisibility?.() ?? createEmptyPlatformNavigationVisibility(); + const platformRoutes = filterPlatformNavigationShortcuts( + { + proxmox: { key: 'p', route: buildProxmoxPath() }, + docker: { key: 'd', route: buildDockerPath() }, + kubernetes: { key: 'k', route: buildKubernetesPath() }, + truenas: { key: 'n', route: buildTrueNASPath() }, + vmware: { key: 'v', route: buildVmwarePath() }, + }, + platformVisibility, + ); return { - p: buildProxmoxPath(), - d: buildDockerPath(), - k: buildKubernetesPath(), - n: buildTrueNASPath(), - v: buildVmwarePath(), + ...platformRoutes, a: '/alerts', r: '/patrol', t: '/settings', diff --git a/frontend-modern/src/pages/NotFound.tsx b/frontend-modern/src/pages/NotFound.tsx index e1e61acac..18d0a9079 100644 --- a/frontend-modern/src/pages/NotFound.tsx +++ b/frontend-modern/src/pages/NotFound.tsx @@ -4,7 +4,6 @@ import { useLocation, useNavigate } from '@solidjs/router'; import { Card } from '@/components/shared/Card'; import { EmptyState } from '@/components/shared/EmptyState'; import { PageHeader } from '@/components/shared/PageHeader'; -import { buildProxmoxPath } from '@/routing/resourceLinks'; const NotFound: Component = () => { const location = useLocation(); @@ -24,9 +23,9 @@ const NotFound: Component = () => {
} diff --git a/frontend-modern/src/pages/RuntimeHome.tsx b/frontend-modern/src/pages/RuntimeHome.tsx index e0b791d08..d4d643435 100644 --- a/frontend-modern/src/pages/RuntimeHome.tsx +++ b/frontend-modern/src/pages/RuntimeHome.tsx @@ -1,14 +1,3 @@ -import { useNavigate } from '@solidjs/router'; -import { createEffect } from 'solid-js'; -import { buildProxmoxPath } from '@/routing/resourceLinks'; - export default function RuntimeHome() { - const navigate = useNavigate(); - const destination = () => buildProxmoxPath(); - - createEffect(() => { - navigate(destination(), { replace: true }); - }); - return
Opening workspace...
; } diff --git a/frontend-modern/src/pages/__tests__/RuntimeHome.test.tsx b/frontend-modern/src/pages/__tests__/RuntimeHome.test.tsx index 6cd64f914..74a95a2c2 100644 --- a/frontend-modern/src/pages/__tests__/RuntimeHome.test.tsx +++ b/frontend-modern/src/pages/__tests__/RuntimeHome.test.tsx @@ -1,27 +1,11 @@ -import { render, waitFor } from '@solidjs/testing-library'; -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@solidjs/testing-library'; +import { describe, expect, it } from 'vitest'; import RuntimeHome from '@/pages/RuntimeHome'; -const navigateSpy = vi.hoisted(() => vi.fn()); - -vi.mock('@solidjs/router', async () => { - const actual = await vi.importActual('@solidjs/router'); - return { - ...actual, - useNavigate: () => navigateSpy, - }; -}); - describe('RuntimeHome', () => { - beforeEach(() => { - navigateSpy.mockReset(); - }); - - it('routes authenticated runtimes straight to the Proxmox platform page', async () => { + it('defers workspace routing to the authenticated app shell', () => { render(() => ); - await waitFor(() => { - expect(navigateSpy).toHaveBeenCalledWith('/proxmox/overview', { replace: true }); - }); + expect(screen.getByText('Opening workspace...')).toBeInTheDocument(); }); });