mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 10:35:51 +00:00
Gate platform navigation by resource evidence
This commit is contained in:
@@ -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` /
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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();
|
||||
|
||||
+87
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user