Gate platform navigation by resource evidence

This commit is contained in:
rcourtman
2026-05-17 18:16:15 +01:00
parent 2cb94d66f1
commit 251e8844dc
22 changed files with 741 additions and 142 deletions
@@ -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
@@ -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 <age>") 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
@@ -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
@@ -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.
@@ -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` /
+71 -4
View File
@@ -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<PrimaryPlatformNavId, string> = {
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<number | null>(
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() {
<KeyboardShortcutsModal
isOpen={shortcutsOpen()}
onClose={() => setShortcutsOpen(false)}
platformVisibility={platformNavigationVisibility}
/>
</Show>
<Show when={commandPaletteOpen()}>
<CommandPaletteModal
isOpen={commandPaletteOpen()}
onClose={() => setCommandPaletteOpen(false)}
platformVisibility={platformNavigationVisibility}
/>
</Show>
<Show when={tokenRevealStore.state() !== null}>
+52 -36
View File
@@ -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<PrimaryPlatformNavId, string> = {
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<string>();
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<PlatformTab[]>(() => {
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,
},
];
@@ -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(');
@@ -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>): 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(() => (
<Router>
<Route
@@ -49,6 +64,7 @@ describe('AppLayout navigation icons', () => {
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();
@@ -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<string, keyof PlatformNavigationVisibility> = {
'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<ShortcutGroup[]>(() => {
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 (
@@ -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>): 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(() => <CommandPaletteModal isOpen={true} onClose={vi.fn()} />);
render(() => (
<CommandPaletteModal
isOpen={true}
onClose={vi.fn()}
platformVisibility={platformVisibility}
/>
));
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(() => <CommandPaletteModal isOpen={true} onClose={onClose} />);
render(() => (
<CommandPaletteModal
isOpen={true}
onClose={onClose}
platformVisibility={platformVisibility}
/>
));
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(() => <CommandPaletteModal isOpen={true} onClose={onClose} />);
render(() => (
<CommandPaletteModal
isOpen={true}
onClose={onClose}
platformVisibility={platformVisibility}
/>
));
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(() => (
<CommandPaletteModal
isOpen={true}
onClose={vi.fn()}
platformVisibility={() =>
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();
});
});
@@ -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 {
@@ -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();
@@ -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>): 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();
});
});
@@ -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<PrimaryPlatformNavId, boolean>;
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<PrimaryPlatformNavId, readonly string[]> = {
proxmox: ['proxmox-pve', 'proxmox-pbs', 'proxmox-pmg'],
docker: ['docker'],
kubernetes: ['kubernetes'],
truenas: ['truenas'],
vmware: ['vmware-vsphere'],
};
const SUPPORTED_PLATFORM_ID_SET = new Set<string>(SUPPORTED_PLATFORM_IDS);
const KUBERNETES_RESOURCE_TYPES = new Set<ResourceType>([
'k8s-cluster',
'k8s-node',
'pod',
'k8s-deployment',
'k8s-service',
]);
const DOCKER_RESOURCE_TYPES = new Set<ResourceType>(['docker-host', 'docker-service']);
const asRecord = (value: unknown): Record<string, unknown> | null =>
typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : null;
const addManifestPlatformId = (ids: Set<string>, 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<string>, platformData: Record<string, unknown> | 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<string>();
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<string> {
const present = new Set<string>();
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<string, PlatformNavigationShortcut>,
visibility: PlatformNavigationVisibility,
): Record<string, string> {
const routes: Record<string, string> = {};
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<readonly Resource[]>,
): PlatformNavigationVisibility {
return buildPrimaryPlatformNavigationVisibility(resources());
}
@@ -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<ProxmoxPageTabId>(visibleTabs().map((tab) => tab.id)),
);
const activeTab = createMemo<ProxmoxPageTabId>(() => {
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 (
<div data-testid="proxmox-page" class="space-y-3">
<PlatformSectionTabs
tabs={PROXMOX_TAB_SPECS}
active={activeTab()}
ariaLabel="Proxmox sections"
/>
<PlatformSectionTabs tabs={visibleTabs()} active={activeTab()} ariaLabel="Proxmox sections" />
<Show
when={!loading() || model().resources.length > 0}
@@ -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', () => {
@@ -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<ProxmoxPageTabId>(['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));
}
@@ -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<PlatformNavigationVisibility>;
};
const isEditableTarget = (target: EventTarget | null): boolean => {
@@ -76,12 +82,20 @@ export function useKeyboardShortcuts(options: KeyboardShortcutsOptions = {}) {
};
const getRoutes = (): Record<string, string> => {
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',
+2 -3
View File
@@ -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 = () => {
<button
type="button"
class="inline-flex items-center gap-2 rounded-md bg-blue-600 px-3 py-1.5 text-xs font-semibold text-white shadow-sm hover:bg-blue-700"
onClick={() => navigate(buildProxmoxPath())}
onClick={() => navigate('/')}
>
Go to Proxmox
Go to workspace
</button>
</div>
}
-11
View File
@@ -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 <div class="px-4 py-6 text-sm text-muted">Opening workspace...</div>;
}
@@ -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<typeof import('@solidjs/router')>('@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(() => <RuntimeHome />);
await waitFor(() => {
expect(navigateSpy).toHaveBeenCalledWith('/proxmox/overview', { replace: true });
});
expect(screen.getByText('Opening workspace...')).toBeInTheDocument();
});
});