diff --git a/docs/features/fleet-secrets.mdx b/docs/features/fleet-secrets.mdx index 9b822ec2..0da3e39f 100644 --- a/docs/features/fleet-secrets.mdx +++ b/docs/features/fleet-secrets.mdx @@ -12,7 +12,7 @@ The unit of work is the **bundle**. One bundle has one current `kv` payload; pus -Fleet Secrets is an Admiral feature. Every action requires an admin user role. +Fleet Secrets is a limited-availability surface. When it is enabled on an instance, it requires an Admiral license and an admin user role. ## What Fleet Secrets covers (and what it doesn't) @@ -38,7 +38,7 @@ A **push** is a separate action. It reads the bundle's current version, walks ev | Requirement | Why it matters | |---|---| -| Admiral license on the control instance | Fleet Secrets is an Admiral feature | +| Admiral license on the control instance | Fleet Secrets requires Admiral when the surface is enabled | | Admin user role | Bundle CRUD and push run as the signed-in operator and write authored-by rows into the audit log | | At least one stack on at least one node | Pushes target an existing stack directory; the wizard does not create stacks | | The target stack's compose declares the env file via `env_file:` | The env-file dropdown in the push wizard reads `env_file:` entries from a representative node's compose; a stack with only an inline `environment:` block will not show up | @@ -46,7 +46,7 @@ A **push** is a separate action. It reads the bundle's current version, walks ev ## Create a bundle -1. Open **Fleet → Secrets**. +1. Open **Fleet → Secrets** (when that tab is available on the instance). 2. Click **New bundle**. 3. Give it a name. Names are 2-64 characters, alphanumerics plus space, dot, dash, and underscore, and must start and end with an alphanumeric. 4. Optionally add a description; the description is a free-text field and is shown in the bundle list. diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index d7cea249..834db864 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -38,7 +38,7 @@ A single rail summarises the state of every registered node so you can read the ### Tabs -The Fleet view is a tab strip. Every tier sees Overview, Status, Map, Deployments, and Actions. Snapshots appears for admins. Deployments is available on Community. Routing and Secrets appear when the active plan includes them. Federation is available on every tier. A vertical separator after **Status** divides the per-node monitoring tabs from the fleet-wide orchestration tabs. +The Fleet view is a tab strip. Every tier sees Overview, Status, Map, Deployments, Federation, and Actions. Snapshots appears for admins. Routing and Secrets are limited-availability fleet surfaces and are not part of the default tab strip. A vertical separator after **Status** divides the per-node monitoring tabs from the fleet-wide orchestration tabs. | Tab | Tier | What it does | |-----|------|--------------| @@ -47,10 +47,10 @@ The Fleet view is a tab strip. Every tier sees Overview, Status, Map, Deployment | **Status** | Community | One card per node summarising which automations and security features are configured. Covered below. | | **Map** | Community | A read-only map of how stacks, services, networks, volumes, and ports relate across the fleet, with anomaly flags. Covered below. | | **Deployments** | Community | Blueprint deployments and reconciler state. See [Blueprints](/features/blueprint-model). | -| **Routing** | Admiral | Cross-node service routing via Sencho Mesh. See [Sencho Mesh](/features/sencho-mesh). | +| **Routing** | Limited availability | Cross-node service routing via Sencho Mesh when that surface is enabled on the instance. See [Sencho Mesh](/features/sencho-mesh). | | **Federation** | Community | Cordon nodes and pin blueprints to specific hosts. See [Fleet Federation](/features/fleet-federation). | | **Actions** | Community (admin role) | Fleet-wide bulk operations: stop stacks by label, bulk-assign labels, prune Docker resources. See [Fleet Actions](/features/fleet-actions). | -| **Secrets** | Admiral | Encrypted env-var bundles you push to labeled nodes. See [Fleet Secrets](/features/fleet-secrets). | +| **Secrets** | Limited availability | Encrypted env-var bundles you push to labeled nodes when that surface is enabled on the instance. See [Fleet Secrets](/features/fleet-secrets). | ### Action buttons diff --git a/docs/features/host-console.mdx b/docs/features/host-console.mdx index 8012abe0..e4f66e9f 100644 --- a/docs/features/host-console.mdx +++ b/docs/features/host-console.mdx @@ -6,7 +6,7 @@ description: An interactive terminal on your host OS, directly in the browser - The **Console** tab opens a full interactive terminal session on the machine running Sencho. It behaves exactly like an SSH session, but without needing an SSH server, client, or key management. - The Host Console requires a **Sencho Admiral** license and the **admin** role. [Learn more about licensing](/features/licensing). + The Host Console is a limited-availability operator surface. When it is enabled on an instance, it requires an **Admiral** license and the **admin** role. [Learn more about licensing](/features/licensing). @@ -75,7 +75,7 @@ Environment variables whose names suggest secrets (passwords, tokens, keys, cred The Host Console is one of the most powerful features in Sencho and is treated as such: - **Admin role required.** Only users with the **admin** role can open a console session. -- **Admiral license required.** The Host Console is an Admiral feature. +- **Admiral license required when enabled.** The Host Console is a limited-availability surface that uses an Admiral license. - **Browser sessions only.** Console sessions are only available from a signed-in browser session, not from API tokens. - **Audited.** Every console session is recorded in the audit log. Opening and closing a session each write an entry capturing the user, node, client IP, and timestamp, so shell access is fully accountable. @@ -106,6 +106,6 @@ The Host Console is one of the most powerful features in Sencho and is treated a - The Console tab only appears for users with the **admin** role on an Admiral-licensed instance. Verify both conditions are met. If you recently activated an Admiral license, refresh the page. + Console appears only when the surface is enabled on the instance, and only for users with the **admin** role on an Admiral-licensed instance. Verify those conditions are met. If you recently activated an Admiral license, refresh the page. diff --git a/docs/features/licensing.mdx b/docs/features/licensing.mdx index f462382d..a5a4dddd 100644 --- a/docs/features/licensing.mdx +++ b/docs/features/licensing.mdx @@ -53,7 +53,7 @@ See [the pricing page](https://sencho.io/pricing) for current pricing. - **Governance:** advanced RBAC roles (Deployer, Node Admin, Auditor), scoped permissions per stack or node, and audit log export (CSV, JSON), anomaly detection, and configurable retention beyond the recent window - **Managed continuity:** Sencho Cloud Backup (a managed, off-site snapshot allowance) - **Directory integration:** LDAP / Active Directory authentication -- **Current plan availability:** some product surfaces (including Fleet Secrets, AWS ECR credentials, Sencho Mesh, and the Host Console) still require an Admiral plan today. That access rule is temporary availability, not the reason Admiral exists. +- **Current plan availability:** some product surfaces (including AWS ECR credentials) still require an Admiral plan today. That access rule is temporary availability, not the reason Admiral exists. Other operator surfaces may be limited-availability on a given instance and are documented on their own feature pages when enabled. ## Free trial diff --git a/docs/features/sencho-mesh.mdx b/docs/features/sencho-mesh.mdx index 1455a1bc..f86c5dad 100644 --- a/docs/features/sencho-mesh.mdx +++ b/docs/features/sencho-mesh.mdx @@ -4,7 +4,7 @@ description: Cross-node container networking. Reach any meshed service on any no --- - Sencho Mesh requires an [Admiral license](/features/licensing). + Sencho Mesh is a limited-availability surface. When it is enabled on an instance, it requires an [Admiral license](/features/licensing). Sencho Mesh gives a multi-node fleet the network topology of a single machine. Once a stack opts in, every service it exposes becomes reachable from any other meshed stack on the fleet by a stable hostname. Cross-node traffic rides the same authenticated channel Sencho already uses to manage the fleet, so a node behind NAT or a residential firewall participates exactly like a public VPS. @@ -33,7 +33,7 @@ The user-facing effect is `psql -h db.api.opsix.sencho` from a container on any ## Prerequisites -- An Admiral license on the central Sencho. +- Mesh enabled on the instance, with an Admiral license on the central Sencho. - At least one [enrolled remote node](/features/multi-node) (Pilot Agent or Distributed API Proxy). - Functioning `sencho_mesh` data plane on every node that should participate. The Routing tab shows a red banner if a node's data plane did not come up; the troubleshooting section below covers each cause. - Per-stack opt-in. Enabling mesh on a node does not automatically place every stack into the mesh; each stack is opted in individually. @@ -42,13 +42,13 @@ App-layer authentication is **not** in scope. Postgres still needs a password, R ## Enable the mesh -Mesh lives under **Fleet → Routing**. Managing it (the per-node mesh toggle and stack opt-in or opt-out) requires an administrator; non-admin users see the Routing tab as read-only. +When Mesh is enabled, it lives under **Fleet → Routing**. Managing it (the per-node mesh toggle and stack opt-in or opt-out) requires an administrator; non-admin users see the Routing tab as read-only. Sencho Mesh Routing tab Table view with node cards -1. Open **Fleet → Routing**. +1. Open **Fleet → Routing** (when that tab is available on the instance). 2. Flip the mesh toggle (`ON` / `OFF`) on each node that should participate. 3. Click **Add stack to mesh** on a node and confirm one or more stacks. @@ -328,6 +328,6 @@ These are the explicit boundaries of the v1 mesh. Steer blueprint placement across the same fleet Mesh networks together. - Mesh requires an Admiral plan today. See [Licensing](/features/licensing) for Community and Admiral plan details. + Mesh is limited-availability and, when enabled, requires an Admiral plan. See [Licensing](/features/licensing) for Community and Admiral plan details. diff --git a/docs/getting-started/introduction.mdx b/docs/getting-started/introduction.mdx index c6ac6442..fc5479ce 100644 --- a/docs/getting-started/introduction.mdx +++ b/docs/getting-started/introduction.mdx @@ -21,7 +21,7 @@ These are the main terms you will see across the app and docs: - **Resource**: A Docker image, volume, network, or unmanaged container. Sencho classifies resources as managed, external, system, unused, or protected so cleanup decisions are visible. - **Blueprint**: A fleet deployment model for keeping a compose template assigned to matching nodes. - **Pilot Agent**: An outbound connector for remote nodes that cannot accept inbound traffic. -- **Mesh**: Cross-node container networking for fleets that need stable service-to-service hostnames. +- **Limited-availability networking and secrets surfaces**: documented on their own feature pages when enabled on an instance. ## What you see after sign-in @@ -64,7 +64,7 @@ The **Fleet** view is the multi-node command center. The masthead summarizes onl The Fleet toolbar includes **Check Updates**, **Refresh**, and **Add node** for admins. The **Overview** tab supports search, sort, status filters, label filters, and a Grid or Topology view. Node cards show online state, resource use, container counts, version state, update actions, and direct drill-down into stacks on that node. -Beyond **Overview**, Fleet provides tabs for **Snapshots**, node **Status**, a dependency **Map**, blueprint **Deployments**, mesh **Routing**, **Federation**, fleet **Actions**, and **Secrets**. Routing and Secrets require Admiral. Federation placement (cordon and pin) is available on every tier. See [Licensing](/features/licensing) for the full tier breakdown. +Beyond **Overview**, Fleet provides tabs for **Snapshots**, node **Status**, a dependency **Map**, blueprint **Deployments**, **Federation**, and fleet **Actions**. Federation placement (cordon and pin) is available on every tier. Routing and Secrets are limited-availability tabs when enabled on an instance. See [Licensing](/features/licensing) for the full tier breakdown. ## Resources, templates, and logs diff --git a/docs/reference/security.mdx b/docs/reference/security.mdx index cf5fbe70..02f5dd6b 100644 --- a/docs/reference/security.mdx +++ b/docs/reference/security.mdx @@ -93,7 +93,7 @@ Every Sencho instance includes the foundational security stack. Advanced access- | Recent-activity audit log (14-day window) | ✓ | ✓ | | Scan policies (`block_on_deploy`) | | ✓ | | SARIF export | | ✓ | -| Fleet Secrets (encrypted env-var bundles) | | ✓ | +| Fleet Secrets (encrypted env-var bundles, limited availability) | | ✓ | | Advanced RBAC (Deployer, Node Admin, Auditor) | | ✓ | | Scoped permissions (per-stack, per-node) | | ✓ | | Audit log export, anomaly detection, and extended retention | | ✓ | diff --git a/frontend/src/components/EditorLayout/ViewRouter.tsx b/frontend/src/components/EditorLayout/ViewRouter.tsx index 27f1dd37..0c436ed4 100644 --- a/frontend/src/components/EditorLayout/ViewRouter.tsx +++ b/frontend/src/components/EditorLayout/ViewRouter.tsx @@ -1,6 +1,7 @@ import { Suspense, lazy, type ReactNode } from 'react'; import { Skeleton } from '@/components/ui/skeleton'; import { useAuth } from '@/context/AuthContext'; +import { useExperimental } from '@/hooks/useExperimental'; import { PaidGate } from '../PaidGate'; import { CapabilityGate } from '../CapabilityGate'; import { HubOnlyGate } from '../HubOnlyGate'; @@ -138,6 +139,7 @@ export function ViewRouter({ isFileLoading, }: ViewRouterProps): ReactNode { const { can } = useAuth(); + const { experimental, experimentalReady } = useExperimental(); if (activeView === 'settings') { return ( diff --git a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx index b4127414..b440f935 100644 --- a/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx +++ b/frontend/src/components/EditorLayout/__tests__/useViewNavigationState.test.tsx @@ -10,6 +10,11 @@ vi.mock('@/context/AuthContext'); vi.mock('@/context/LicenseContext'); vi.mock('@/context/NodeContext'); +const useExperimentalMock = vi.fn(() => ({ experimental: true, experimentalReady: true })); +vi.mock('@/hooks/useExperimental', () => ({ + useExperimental: () => useExperimentalMock(), +})); + function mockActiveNode(type: 'local' | 'remote' | null) { vi.mocked(NodeContext.useNodes).mockReturnValue({ activeNode: type === null ? null : { type, id: 1, name: 'n' }, @@ -71,6 +76,7 @@ describe('useViewNavigationState', () => { beforeEach(() => { mockCommunityUser(); mockActiveNode('local'); + useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true }); }); // ── initial state ────────────────────────────────────────────────────────── @@ -419,4 +425,54 @@ describe('useViewNavigationState', () => { expect(result.current.activeView).toBe('security'); expect(result.current.securityTab).toBe('overview'); }); + + // ── experimental discovery ───────────────────────────────────────────────── + + it('hides Console from nav for a paid admin when experimental discovery is off', () => { + mockPaidAdmin(); + useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true }); + const { result } = renderHook(() => useViewNavigationState()); + expect(result.current.navItems.map(i => i.value)).not.toContain('host-console'); + }); + + it('hides Console from nav while experimental metadata is still loading', () => { + mockPaidAdmin(); + useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false }); + const { result } = renderHook(() => useViewNavigationState()); + expect(result.current.navItems.map(i => i.value)).not.toContain('host-console'); + }); + + it('does not normalize a host-console deep link before experimental readiness', () => { + mockPaidAdmin(); + useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false }); + const onNavigateToDashboard = vi.fn(); + const { result } = renderHook(() => useViewNavigationState({ onNavigateToDashboard })); + act(() => result.current.setActiveView('host-console')); + expect(result.current.activeView).toBe('host-console'); + expect(onNavigateToDashboard).not.toHaveBeenCalled(); + }); + + it('keeps host-console selected when delayed experimental resolves enabled', () => { + mockPaidAdmin(); + useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false }); + const onNavigateToDashboard = vi.fn(); + const { result, rerender } = renderHook(() => useViewNavigationState({ onNavigateToDashboard })); + act(() => result.current.setActiveView('host-console')); + useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true }); + rerender(); + expect(result.current.activeView).toBe('host-console'); + expect(onNavigateToDashboard).not.toHaveBeenCalled(); + }); + + it('normalizes host-console once when experimental resolves disabled', () => { + mockPaidAdmin(); + useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false }); + const onNavigateToDashboard = vi.fn(); + const { result, rerender } = renderHook(() => useViewNavigationState({ onNavigateToDashboard })); + act(() => result.current.setActiveView('host-console')); + useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true }); + rerender(); + expect(result.current.activeView).toBe('dashboard'); + expect(onNavigateToDashboard).toHaveBeenCalled(); + }); }); diff --git a/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts b/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts index e56853bd..0e7b5a0f 100644 --- a/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useUrlSync.test.ts @@ -26,6 +26,8 @@ function makeReachCtx(over: Partial = {}): ReachabilityCont containerLabelsEnabled: true, permissionsStatus: 'ready', licenseStatus: 'ready', + experimental: true, + experimentalReady: true, ...over, }; } diff --git a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts index a5eb0db1..6d27e818 100644 --- a/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts +++ b/frontend/src/components/EditorLayout/hooks/useViewNavigationState.ts @@ -22,6 +22,7 @@ import { normalizeHiddenView, type ReachabilityContext, } from '@/lib/routing/reachability'; +import { useExperimental } from '@/hooks/useExperimental'; export type { ActiveView }; export { HUB_ONLY_VIEWS }; @@ -44,6 +45,7 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) const { isPaid, licenseStatus } = useLicense(); const { activeNode } = useNodes(); const isRemote = activeNode?.type === 'remote'; + const { experimental, experimentalReady } = useExperimental(); const initialRoute = readUrlRouteState(); @@ -65,7 +67,9 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) containerLabelsEnabled, permissionsStatus, licenseStatus, - }), [isAdmin, isPaid, can, isRemote, hasFleetCapability, containerLabelsEnabled, permissionsStatus, licenseStatus]); + experimental, + experimentalReady, + }), [isAdmin, isPaid, can, isRemote, hasFleetCapability, containerLabelsEnabled, permissionsStatus, licenseStatus, experimental, experimentalReady]); const handleOpenSettings = useCallback((section?: SectionId) => { if (section) setSettingsSection(section); @@ -141,14 +145,17 @@ export function useViewNavigationState(options?: UseViewNavigationStateOptions) if (!isViewHidden('scheduled-ops', reachCtx)) { items.push({ value: 'scheduled-ops', label: 'Schedules', icon: Clock }); } - if (!isViewHidden('host-console', reachCtx)) { + // Visual discovery fail-closed: omit Console until /meta settles and the + // flag is on. URL normalization still waits on experimentalReady inside + // isViewHidden so enabled deep links are not rewritten during cold load. + if (experimentalReady && experimental && !isViewHidden('host-console', reachCtx)) { items.push({ value: 'host-console', label: 'Console', icon: Terminal }); } if (!isViewHidden('audit-log', reachCtx)) { items.push({ value: 'audit-log', label: 'Audit', icon: ScrollText }); } return items; - }, [reachCtx]); + }, [reachCtx, experimentalReady, experimental]); useEffect(() => { if (!authzReady(reachCtx)) return; diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 0b834f15..0c8b66a2 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -22,6 +22,7 @@ import { springs } from '@/lib/motion'; import { useLicense } from '@/context/LicenseContext'; import { useAuth } from '@/context/AuthContext'; import { useNodes } from '@/context/NodeContext'; +import { useExperimental } from '@/hooks/useExperimental'; import { PaidGate } from './PaidGate'; import FleetSnapshots from './FleetSnapshots'; import { FleetConfiguration } from './fleet/FleetConfiguration'; @@ -58,10 +59,14 @@ export function FleetView({ fleetActiveTab: controlledTab, onFleetActiveTabChange, }: FleetViewProps) { - const { isPaid } = useLicense(); + const { isPaid, licenseStatus } = useLicense(); const { isAdmin } = useAuth(); const { hasCapability } = useNodes(); + const { experimental, experimentalReady } = useExperimental(); const containerLabelsEnabled = hasCapability('container-label-inventory'); + // Visual fail-closed while /meta loads; paid/admin gates still apply when on. + const canDiscoverRouting = experimentalReady && experimental && isPaid; + const canDiscoverSecrets = experimentalReady && experimental && isPaid && isAdmin; const { prefs, updatePrefs } = useFleetPreferences(); const updateStatus = useFleetUpdateStatus(); @@ -88,6 +93,32 @@ export function FleetView({ if (controlledTab === undefined) setInternalTab(tab); }; + // Fall back only after experimental readiness settles. When experimental is + // on, also wait for license (and admin for secrets) so a paid deep link is + // not rewritten to Overview while isPaid is still the cold-load false. + useEffect(() => { + if (!experimentalReady) return; + if (activeTab === 'routing') { + if (!experimental) { + setActiveTab('overview'); + return; + } + if (licenseStatus !== 'ready') return; + if (!isPaid) setActiveTab('overview'); + return; + } + if (activeTab === 'secrets') { + if (!experimental) { + setActiveTab('overview'); + return; + } + if (licenseStatus !== 'ready') return; + if (!isPaid || !isAdmin) setActiveTab('overview'); + } + // setActiveTab closes over onFleetActiveTabChange; listing deps explicitly. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [experimentalReady, experimental, licenseStatus, isPaid, isAdmin, activeTab]); + useEffect(() => { if (fleetUpdatesIntent) { setInitialUpdatesTab(fleetUpdatesIntent.tab); @@ -158,7 +189,7 @@ export function FleetView({ Deployments - {isPaid && ( + {canDiscoverRouting && ( Routing @@ -175,7 +206,7 @@ export function FleetView({ Actions - {isPaid && isAdmin && ( + {canDiscoverSecrets && ( Secrets @@ -281,7 +312,7 @@ export function FleetView({ - {isPaid && ( + {canDiscoverRouting && ( @@ -296,7 +327,7 @@ export function FleetView({ unfiltered node list rather than the overview-filtered view. */} - {isPaid && isAdmin && ( + {canDiscoverSecrets && ( diff --git a/frontend/src/components/__tests__/FleetView.experimental.test.tsx b/frontend/src/components/__tests__/FleetView.experimental.test.tsx new file mode 100644 index 00000000..20278936 --- /dev/null +++ b/frontend/src/components/__tests__/FleetView.experimental.test.tsx @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import { FleetView } from '../FleetView'; + +const useExperimentalMock = vi.fn(() => ({ experimental: true, experimentalReady: true })); +vi.mock('@/hooks/useExperimental', () => ({ + useExperimental: () => useExperimentalMock(), +})); + +vi.mock('@/context/LicenseContext', () => ({ + useLicense: () => ({ isPaid: true }), +})); +vi.mock('@/context/AuthContext', () => ({ + useAuth: () => ({ isAdmin: true }), +})); +vi.mock('@/context/NodeContext', () => ({ + useNodes: () => ({ hasCapability: () => false }), +})); + +vi.mock('../FleetView/hooks/useFleetPreferences', () => ({ + useFleetPreferences: () => ({ prefs: {}, updatePrefs: vi.fn() }), +})); +vi.mock('../FleetView/hooks/useFleetUpdateStatus', () => ({ + useFleetUpdateStatus: () => ({ + updateStatuses: [], + localUpdateConfirm: null, + setShowUpdateModal: vi.fn(), + fetchUpdateStatus: vi.fn(), + showUpdateModal: false, + checkingUpdates: false, + updatingNodeId: null, + reconnecting: false, + preUpdateStartedAt: null, + triggerNodeUpdate: vi.fn(), + retryNodeUpdate: vi.fn(), + dismissNodeUpdate: vi.fn(), + checkUpdates: vi.fn(), + confirmLocalUpdate: vi.fn(), + cancelLocalUpdate: vi.fn(), + }), +})); +vi.mock('../FleetView/hooks/useFleetOverview', () => ({ + useFleetOverview: () => ({ + nodes: [], + processedNodes: [], + allNodes: [], + topologyNodes: [], + viewMode: 'cards', + setViewMode: vi.fn(), + searchQuery: '', + setSearchQuery: vi.fn(), + fleetPalette: {}, + labelFilters: {}, + setLabelFilters: vi.fn(), + clearFilters: vi.fn(), + fleetStackLabelMap: {}, + updateStatusMap: {}, + mastheadStats: { + nodeCount: 0, + onlineCount: 0, + criticalCount: 0, + avgCpuNum: 0, + worstCpu: 0, + totalMemUsed: 0, + totalMemTotal: 0, + totalContainers: 0, + totalContainersAll: 0, + }, + lastSyncAt: null, + loading: false, + refreshing: false, + fetchOverview: vi.fn(), + }), +})); +vi.mock('../FleetView/hooks/useFleetPolling', () => ({ + useFleetPolling: () => {}, +})); +vi.mock('../FleetView/hooks/useFleetDossierExport', () => ({ + useFleetDossierExport: () => ({ exporting: false, exportDossier: vi.fn() }), +})); +vi.mock('@/hooks/useTopologyPreferences', () => ({ + useTopologyPreferences: () => ({ prefs: { mode: 'hub', positions: {} }, setMode: vi.fn(), setPositions: vi.fn() }), +})); +vi.mock('../nodes/useNodeActions', () => ({ + useNodeActions: () => ({ openEdit: vi.fn(), openDelete: vi.fn(), NodeActionModals: null }), +})); +vi.mock('../fleet/FleetMasthead', () => ({ FleetMasthead: () =>
})); +vi.mock('../FleetView/OverviewTab', () => ({ OverviewTab: () =>
})); +vi.mock('../FleetView/ReconnectingOverlay', () => ({ ReconnectingOverlay: () => null })); +vi.mock('../FleetView/NodeUpdatesSheet', () => ({ NodeUpdatesSheet: () => null })); +vi.mock('../FleetView/LocalUpdateConfirmDialog', () => ({ LocalUpdateConfirmDialog: () => null })); +vi.mock('../FleetSnapshots', () => ({ default: () => null })); +vi.mock('../fleet/FleetConfiguration', () => ({ FleetConfiguration: () => null })); +vi.mock('../fleet/RoutingTab', () => ({ RoutingTab: () =>
})); +vi.mock('../fleet/FederationTab', () => ({ FederationTab: () =>
})); +vi.mock('../blueprints/DeploymentsTab', () => ({ DeploymentsTab: () =>
})); +vi.mock('../fleet/FleetActions/FleetActionsTab', () => ({ FleetActionsTab: () =>
})); +vi.mock('../fleet/secrets/SecretsTab', () => ({ SecretsTab: () =>
})); +vi.mock('../fleet/DependencyMapTab', () => ({ DependencyMapTab: () => null })); +vi.mock('../fleet/ContainerLabelsTab', () => ({ ContainerLabelsTab: () => null })); +vi.mock('../PaidGate', () => ({ PaidGate: ({ children }: { children: React.ReactNode }) => <>{children} })); + +describe('FleetView experimental discovery', () => { + beforeEach(() => { + useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true }); + }); + + it('shows Routing and Secrets when experimental discovery is on for paid admin', () => { + render(); + expect(screen.getByRole('tab', { name: /routing/i })).toBeTruthy(); + expect(screen.getByRole('tab', { name: /secrets/i })).toBeTruthy(); + }); + + it('hides Routing and Secrets when experimental discovery is off', () => { + useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true }); + render(); + expect(screen.queryByRole('tab', { name: /routing/i })).toBeNull(); + expect(screen.queryByRole('tab', { name: /secrets/i })).toBeNull(); + expect(screen.getByRole('tab', { name: /deployments/i })).toBeTruthy(); + expect(screen.getByRole('tab', { name: /federation/i })).toBeTruthy(); + expect(screen.getByRole('tab', { name: /actions/i })).toBeTruthy(); + }); + + it('delayed experimental true preserves controlled routing without Overview callback', async () => { + useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false }); + const onTab = vi.fn(); + const { rerender } = render( + , + ); + expect(screen.queryByRole('tab', { name: /routing/i })).toBeNull(); + expect(onTab).not.toHaveBeenCalled(); + + useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true }); + rerender( + , + ); + await waitFor(() => expect(screen.getByRole('tab', { name: /routing/i })).toBeTruthy()); + expect(onTab).not.toHaveBeenCalled(); + }); + + it('resolved experimental false falls back controlled routing to overview once', async () => { + useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false }); + const onTab = vi.fn(); + const { rerender } = render( + , + ); + expect(onTab).not.toHaveBeenCalled(); + + useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true }); + rerender( + , + ); + await waitFor(() => expect(onTab).toHaveBeenCalledWith('overview')); + expect(onTab).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/components/dashboard/__tests__/useMeshDataPlane.test.tsx b/frontend/src/components/dashboard/__tests__/useMeshDataPlane.test.tsx index 0b6c052b..c7ef721d 100644 --- a/frontend/src/components/dashboard/__tests__/useMeshDataPlane.test.tsx +++ b/frontend/src/components/dashboard/__tests__/useMeshDataPlane.test.tsx @@ -12,6 +12,11 @@ vi.mock('@/context/LicenseContext', () => ({ useLicense: () => useLicenseMock(), })); +const useExperimentalMock = vi.fn(() => ({ experimental: true, experimentalReady: true })); +vi.mock('@/hooks/useExperimental', () => ({ + useExperimental: () => useExperimentalMock(), +})); + vi.mock('@/lib/utils', async () => { const actual = await vi.importActual('@/lib/utils'); return { @@ -39,6 +44,8 @@ function statusJson(status: number, payload: unknown = {}): Response { beforeEach(() => { apiFetchMock.mockReset(); useLicenseMock.mockReset(); + useExperimentalMock.mockReset(); + useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true }); }); afterEach(() => { @@ -91,4 +98,24 @@ describe('useMeshDataPlane', () => { expect(result.current.status).toBeNull(); expect(result.current.loading).toBe(false); }); +it('does not fetch when paid but experimental discovery is off', async () => { + useLicenseMock.mockReturnValue({ isPaid: true }); + useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true }); + const { result } = renderHook(() => useMeshDataPlane()); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + expect(apiFetchMock).not.toHaveBeenCalled(); + expect(result.current.status).toBeNull(); + expect(result.current.loading).toBe(false); + }); + + it('does not fetch while experimental metadata is still loading', async () => { + useLicenseMock.mockReturnValue({ isPaid: true }); + useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: false }); + const { result } = renderHook(() => useMeshDataPlane()); + await act(async () => { await Promise.resolve(); await Promise.resolve(); }); + + expect(apiFetchMock).not.toHaveBeenCalled(); + expect(result.current.status).toBeNull(); + }); }); diff --git a/frontend/src/components/dashboard/useMeshDataPlane.ts b/frontend/src/components/dashboard/useMeshDataPlane.ts index 21020351..76d2371e 100644 --- a/frontend/src/components/dashboard/useMeshDataPlane.ts +++ b/frontend/src/components/dashboard/useMeshDataPlane.ts @@ -1,6 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import { apiFetch } from '@/lib/api'; import { useLicense } from '@/context/LicenseContext'; +import { useExperimental } from '@/hooks/useExperimental'; import { visibilityInterval } from '@/lib/utils'; import type { MeshDataPlaneStatus } from '@/types/mesh'; @@ -11,14 +12,17 @@ export interface MeshDataPlaneResult { /** * Poll `/mesh/status` for the local data-plane health so dashboard surfaces - * can flag a down mesh without opening the Routing tab. The endpoint is - * paid-gated, so the hook short-circuits on the free tier (no request - * fired, no banner rendered). On the rare 403 from a paid tier (token - * race during downgrade) we leave `status` at null. 30 s cadence matches - * `useFleetHeartbeat` so the dashboard refresh feel is consistent. + * can flag a down mesh without opening the Routing tab. Discovery requires + * SENCHO_EXPERIMENTAL and an Admiral license; the hook short-circuits when + * either gate is off (no request fired, no banner rendered). On the rare + * 403 from a paid tier (token race during downgrade) we leave `status` at + * null. 30 s cadence matches `useFleetHeartbeat` so the dashboard refresh + * feel is consistent. */ export function useMeshDataPlane(): MeshDataPlaneResult { const { isPaid } = useLicense(); + const { experimental, experimentalReady } = useExperimental(); + const canDiscover = experimentalReady && experimental && isPaid; const [status, setStatus] = useState(null); const [loading, setLoading] = useState(true); @@ -41,14 +45,14 @@ export function useMeshDataPlane(): MeshDataPlaneResult { }, []); useEffect(() => { - if (!isPaid) { + if (!canDiscover) { setStatus(null); setLoading(false); return; } void fetchStatus(); return visibilityInterval(() => { void fetchStatus(); }, 30_000); - }, [isPaid, fetchStatus]); + }, [canDiscover, fetchStatus]); return { status, loading }; } diff --git a/frontend/src/components/settings/FleetMeshSection.tsx b/frontend/src/components/settings/FleetMeshSection.tsx index bb6d4739..3120f634 100644 --- a/frontend/src/components/settings/FleetMeshSection.tsx +++ b/frontend/src/components/settings/FleetMeshSection.tsx @@ -5,6 +5,7 @@ import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { useNodes } from '@/context/NodeContext'; import { useAuth } from '@/context/AuthContext'; +import { useExperimental } from '@/hooks/useExperimental'; import { DEFAULT_SETTINGS } from './types'; import type { PatchableSettings } from './types'; import { SettingsSection } from './SettingsSection'; @@ -27,6 +28,7 @@ function SectionSkeleton() { } type FleetMeshFields = Pick; +type SnapshotOnlyFields = Pick; const DEFAULT_FLEET_MESH: FleetMeshFields = { mesh_auto_recreate: DEFAULT_SETTINGS.mesh_auto_recreate, @@ -36,6 +38,8 @@ const DEFAULT_FLEET_MESH: FleetMeshFields = { export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) { const { activeNode } = useNodes(); const { isAdmin } = useAuth(); + const { experimental, experimentalReady } = useExperimental(); + const showMesh = experimentalReady && experimental; const readOnly = !isAdmin; const { settings, setSettings, dirtyCount, hasChanges, reset, markSaved } = useSettingsDirty({ ...DEFAULT_FLEET_MESH }); const [isLoading, setIsLoading] = useState(false); @@ -69,7 +73,7 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) { }; reset(safe); } catch (e) { - console.error('Failed to fetch fleet mesh settings', e); + console.error('Failed to fetch fleet settings', e); } finally { setIsLoading(false); } @@ -83,7 +87,12 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) { }; const saveSettings = async () => { - const submitted = { ...settings }; + // When Mesh discovery is off, never write mesh_auto_recreate: a failed + // settings read would otherwise push the default and overwrite a real + // Mesh config the operator cannot see. + const submitted: FleetMeshFields | SnapshotOnlyFields = showMesh + ? { ...settings } + : { snapshot_documentation: settings.snapshot_documentation }; setIsSaving(true); try { const res = await apiFetch('/settings', { @@ -95,7 +104,14 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) { toast.error(err?.error || err?.message || 'Failed to save settings.'); return; } - markSaved(submitted); + if (showMesh) { + markSaved(submitted as FleetMeshFields); + } else { + markSaved({ + ...settings, + snapshot_documentation: (submitted as SnapshotOnlyFields).snapshot_documentation, + }); + } toast.success('Fleet settings saved.'); } catch (e: unknown) { toast.error((e as Error)?.message || 'Something went wrong.'); @@ -108,17 +124,19 @@ export function FleetMeshSection({ onDirtyChange }: FleetMeshSectionProps) { return (
- - - onSettingChange('mesh_auto_recreate', next ? '1' : '0')} - /> - - + {showMesh && ( + + + onSettingChange('mesh_auto_recreate', next ? '1' : '0')} + /> + + + )} ({ useAuth: () => ({ isAdmin: true }) })) vi.mock('@/context/NodeContext', () => ({ useNodes: () => ({ activeNode: { id: 'local' } }) })); vi.mock('@/context/LicenseContext', () => ({ useLicense: vi.fn(() => ({ isPaid: true })) })); vi.mock('../MastheadStatsContext', () => ({ useMastheadStats: () => {} })); +const useExperimentalMock = vi.fn(() => ({ experimental: true, experimentalReady: true })); +vi.mock('@/hooks/useExperimental', () => ({ + useExperimental: () => useExperimentalMock(), +})); import { apiFetch } from '@/lib/api'; import { useLicense } from '@/context/LicenseContext'; @@ -43,6 +47,7 @@ const FULL_SETTINGS: Record = { prune_on_update: '1', reclaim_hero: '1', mesh_auto_recreate: '0', + snapshot_documentation: '0', metrics_retention_hours: '24', log_retention_days: '30', audit_retention_days: '90', @@ -64,6 +69,7 @@ beforeEach(() => { mockedFetch.mockReset(); mockedFetch.mockResolvedValue({ ok: true, json: async () => ({ ...FULL_SETTINGS }) }); mockedLicense.mockReturnValue({ isPaid: true }); + useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true }); }); describe('split section save payloads', () => { @@ -119,7 +125,8 @@ describe('split section save payloads', () => { expect(patchedKeys()).toEqual(['docker_janitor_gb', 'prune_on_update', 'reclaim_hero']); }); - it('FleetMeshSection patches only the fleet keys', async () => { + it('FleetMeshSection patches both fleet keys when experimental discovery is on', async () => { + useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true }); render(); const save = await screen.findByRole('button', { name: /save settings/i }); fireEvent.click(screen.getAllByRole('switch')[0]); // mesh_auto_recreate @@ -128,6 +135,19 @@ describe('split section save payloads', () => { expect(patchedKeys()).toEqual(['mesh_auto_recreate', 'snapshot_documentation']); }); + it('FleetMeshSection PATCHes only snapshot_documentation when experimental discovery is off', async () => { + useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true }); + render(); + const save = await screen.findByRole('button', { name: /save settings/i }); + expect(screen.queryByText(/Mesh data plane/i)).toBeNull(); + expect(screen.queryByText(/sencho_mesh/i)).toBeNull(); + expect(screen.getByText(/Documentation snapshots/i)).toBeTruthy(); + fireEvent.click(screen.getAllByRole('switch')[0]); // snapshot_documentation + fireEvent.click(save); + await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true)); + expect(patchedKeys()).toEqual(['snapshot_documentation']); + }); + it('DataRetentionSection patches only retention keys, never developer_mode', async () => { render(); const save = await screen.findByRole('button', { name: /save settings/i }); diff --git a/frontend/src/components/settings/__tests__/registry.test.ts b/frontend/src/components/settings/__tests__/registry.test.ts index 9fe4b4cc..e50d5c7f 100644 --- a/frontend/src/components/settings/__tests__/registry.test.ts +++ b/frontend/src/components/settings/__tests__/registry.test.ts @@ -55,9 +55,11 @@ describe('settings registry', () => { } }); - it('gates the Fleet Mesh section to admins so the sidebar entry and panel both hide', () => { + it('gates the Fleet section to admins so the sidebar entry and panel both hide', () => { const fleetMesh = SETTINGS_ITEMS.find(i => i.id === 'fleet-mesh'); expect(fleetMesh?.adminOnly).toBe(true); + expect(fleetMesh?.description?.toLowerCase()).not.toContain('mesh'); + expect(fleetMesh?.keywords?.some(k => /mesh|sencho_mesh|routing/i.test(k))).toBe(false); }); it('splits Developer into Developer Diagnostics and Data Retention under Operations', () => { diff --git a/frontend/src/components/settings/registry.ts b/frontend/src/components/settings/registry.ts index e404e235..f322f3a4 100644 --- a/frontend/src/components/settings/registry.ts +++ b/frontend/src/components/settings/registry.ts @@ -134,8 +134,8 @@ export const SETTINGS_ITEMS: readonly SettingsItemMeta[] = [ id: 'fleet-mesh', group: 'infrastructure', label: 'Fleet', - description: 'Cross-node service-mesh data plane and fleet-snapshot documentation capture.', - keywords: ['mesh', 'network', 'recreate', 'fleet', 'routing', 'data plane', 'sencho_mesh', 'snapshot', 'documentation', 'dossier'], + description: 'Fleet-snapshot documentation capture for Dossier notes.', + keywords: ['fleet', 'snapshot', 'documentation', 'dossier', 'notes'], tier: null, scope: 'node', adminOnly: true, diff --git a/frontend/src/hooks/__tests__/useExperimental.test.ts b/frontend/src/hooks/__tests__/useExperimental.test.ts new file mode 100644 index 00000000..43116be7 --- /dev/null +++ b/frontend/src/hooks/__tests__/useExperimental.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { renderHook, act, waitFor } from '@testing-library/react'; +import { + useExperimental, + __resetExperimentalCacheForTests, +} from '../useExperimental'; + +const apiFetchMock = vi.fn(); + +vi.mock('@/lib/api', () => ({ + apiFetch: (...args: unknown[]) => apiFetchMock(...args), +})); + +function okJson(payload: unknown): Response { + return new Response(JSON.stringify(payload), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function statusJson(status: number, payload: unknown = {}): Response { + return new Response(JSON.stringify(payload), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +beforeEach(() => { + __resetExperimentalCacheForTests(); + apiFetchMock.mockReset(); +}); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('useExperimental', () => { + it('stays unready then settles to true when /meta eventually returns experimental', async () => { + let resolve!: (value: Response) => void; + apiFetchMock.mockReturnValue(new Promise((r) => { resolve = r; })); + + const { result } = renderHook(() => useExperimental()); + expect(result.current.experimentalReady).toBe(false); + expect(result.current.experimental).toBe(false); + + await act(async () => { + resolve(okJson({ experimental: true })); + }); + + await waitFor(() => expect(result.current.experimentalReady).toBe(true)); + expect(result.current.experimental).toBe(true); + expect(apiFetchMock).toHaveBeenCalledWith('/meta', expect.objectContaining({ localOnly: true })); + }); + + it('settles fail-closed on non-OK /meta', async () => { + apiFetchMock.mockResolvedValue(statusJson(500, { error: 'boom' })); + const { result } = renderHook(() => useExperimental()); + await waitFor(() => expect(result.current.experimentalReady).toBe(true)); + expect(result.current.experimental).toBe(false); + }); + + it('settles false for malformed payloads that omit experimental true', async () => { + apiFetchMock.mockResolvedValue(okJson({ experimental: 'yes' })); + const { result } = renderHook(() => useExperimental()); + await waitFor(() => expect(result.current.experimentalReady).toBe(true)); + expect(result.current.experimental).toBe(false); + }); + + it('settles fail-closed when the request throws', async () => { + apiFetchMock.mockRejectedValue(new Error('network')); + const { result } = renderHook(() => useExperimental()); + await waitFor(() => expect(result.current.experimentalReady).toBe(true)); + expect(result.current.experimental).toBe(false); + }); + + it('dedupes concurrent callers onto one /meta fetch and shares the cache', async () => { + apiFetchMock.mockResolvedValue(okJson({ experimental: true })); + const a = renderHook(() => useExperimental()); + const b = renderHook(() => useExperimental()); + await waitFor(() => expect(a.result.current.experimentalReady).toBe(true)); + await waitFor(() => expect(b.result.current.experimentalReady).toBe(true)); + expect(apiFetchMock).toHaveBeenCalledTimes(1); + expect(a.result.current.experimental).toBe(true); + expect(b.result.current.experimental).toBe(true); + + const c = renderHook(() => useExperimental()); + expect(c.result.current.experimentalReady).toBe(true); + expect(c.result.current.experimental).toBe(true); + expect(apiFetchMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/hooks/useExperimental.ts b/frontend/src/hooks/useExperimental.ts index d03a0198..9ec8ed94 100644 --- a/frontend/src/hooks/useExperimental.ts +++ b/frontend/src/hooks/useExperimental.ts @@ -1,11 +1,18 @@ import { useEffect, useState } from 'react'; import { apiFetch } from '@/lib/api'; +export interface ExperimentalDiscovery { + /** True when SENCHO_EXPERIMENTAL === 'true' on the gateway. */ + experimental: boolean; + /** True once /meta has settled (success or fail-closed). */ + experimentalReady: boolean; +} + // Module-scope cache: read once at boot, do not invalidate. The // SENCHO_EXPERIMENTAL flag is read from the gateway node's process // env at request time, so it cannot flip mid-session without a // restart. If the initial fetch fails the value sticks at false until -// a full reload; that is acceptable for a dev-only flag. +// a full reload; that is acceptable for a discovery-only flag. let cached: boolean | null = null; let inflight: Promise | null = null; @@ -37,16 +44,28 @@ async function fetchExperimental(): Promise { return inflight; } -export function useExperimental(): boolean { - const [value, setValue] = useState(cached ?? false); +/** Test-only: reset the module cache between vitest cases. */ +export function __resetExperimentalCacheForTests(): void { + cached = null; + inflight = null; +} + +export function useExperimental(): ExperimentalDiscovery { + const [state, setState] = useState(() => + cached !== null + ? { experimental: cached, experimentalReady: true } + : { experimental: false, experimentalReady: false }, + ); + useEffect(() => { let active = true; fetchExperimental().then((next) => { - if (active) setValue(next); + if (active) setState({ experimental: next, experimentalReady: true }); }); return () => { active = false; }; }, []); - return value; + + return state; } diff --git a/frontend/src/lib/routing/reachability.test.ts b/frontend/src/lib/routing/reachability.test.ts index a4f363af..24058316 100644 --- a/frontend/src/lib/routing/reachability.test.ts +++ b/frontend/src/lib/routing/reachability.test.ts @@ -2,6 +2,8 @@ import { describe, it, expect } from 'vitest'; import { authzReady, isViewHidden, + isFleetTabHidden, + isSettingsSectionHidden, normalizeHiddenView, type ReachabilityContext, } from './reachability'; @@ -16,6 +18,8 @@ function ctx(over: Partial = {}): ReachabilityContext { containerLabelsEnabled: true, permissionsStatus: 'ready', licenseStatus: 'ready', + experimental: false, + experimentalReady: true, ...over, }; } @@ -46,7 +50,41 @@ describe('reachability', () => { }); it('preserves paid views when license metadata failed', () => { - const licenseError = ctx({ licenseStatus: 'error' }); + const licenseError = ctx({ licenseStatus: 'error', experimental: true }); expect(isViewHidden('host-console', licenseError)).toBe(false); }); + + it('does not apply experimental hide to host-console until experimentalReady', () => { + const loading = ctx({ experimental: false, experimentalReady: false, isPaid: true, isAdmin: true }); + expect(isViewHidden('host-console', loading)).toBe(false); + }); + + it('hides host-console when experimental is ready and off even for paid admin', () => { + const off = ctx({ experimental: false, experimentalReady: true, isPaid: true, isAdmin: true }); + expect(isViewHidden('host-console', off)).toBe(true); + expect(normalizeHiddenView('host-console', off)).toBe('dashboard'); + }); + + it('keeps host-console when experimental is on for paid admin', () => { + const on = ctx({ experimental: true, experimentalReady: true, isPaid: true, isAdmin: true }); + expect(isViewHidden('host-console', on)).toBe(false); + }); + + it('hides routing and secrets fleet tabs only after experimentalReady when off', () => { + const loading = ctx({ experimental: false, experimentalReady: false }); + expect(isFleetTabHidden('routing', loading)).toBe(false); + expect(isFleetTabHidden('secrets', loading)).toBe(false); + + const off = ctx({ experimental: false, experimentalReady: true }); + expect(isFleetTabHidden('routing', off)).toBe(true); + expect(isFleetTabHidden('secrets', off)).toBe(true); + expect(isFleetTabHidden('deployments', off)).toBe(false); + expect(isFleetTabHidden('federation', off)).toBe(false); + expect(isFleetTabHidden('actions', off)).toBe(false); + }); + + it('does not hide fleet-mesh settings for experimental off', () => { + const off = ctx({ experimental: false, experimentalReady: true, isAdmin: true }); + expect(isSettingsSectionHidden('fleet-mesh', off)).toBe(false); + }); }); diff --git a/frontend/src/lib/routing/reachability.ts b/frontend/src/lib/routing/reachability.ts index 8c766356..df3836eb 100644 --- a/frontend/src/lib/routing/reachability.ts +++ b/frontend/src/lib/routing/reachability.ts @@ -15,6 +15,10 @@ export interface ReachabilityContext { containerLabelsEnabled: boolean; permissionsStatus: ReadinessStatus; licenseStatus: ReadinessStatus; + /** Gateway SENCHO_EXPERIMENTAL discovery flag. */ + experimental: boolean; + /** True once /meta experimental has settled (success or fail-closed). */ + experimentalReady: boolean; } /** RBAC/tier gates apply only when permission and license metadata are ready. */ @@ -22,6 +26,15 @@ export function authzReady(ctx: ReachabilityContext): boolean { return ctx.permissionsStatus === 'ready' && ctx.licenseStatus === 'ready'; } +/** + * Experimental discovery gates apply only after /meta settles. Before that, + * treat surfaces as not-yet-hidden so URL sync does not rewrite enabled + * deep links during cold load. + */ +export function experimentalDiscoveryReady(ctx: ReachabilityContext): boolean { + return ctx.experimentalReady; +} + /** Role/tier hidden views normalize away only when permission and license metadata are ready. */ export function isViewHidden(view: ActiveView, ctx: ReachabilityContext): boolean { if (!authzReady(ctx)) return false; @@ -29,11 +42,17 @@ export function isViewHidden(view: ActiveView, ctx: ReachabilityContext): boolea if (!ctx.isAdmin && view === 'global-observability') return true; if (!ctx.isAdmin && (view === 'auto-updates' || view === 'scheduled-ops')) return true; if (!ctx.can('node:read') && view === 'fleet') return true; + if (view === 'host-console') { + // Defer experimental hide until ready so enabled deep links survive cold load. + if (experimentalDiscoveryReady(ctx) && !ctx.experimental) return true; + if (!ctx.isPaid) return true; + if (!ctx.isAdmin) return true; + return false; + } if (!ctx.isPaid) { - if (view === 'host-console' || view === 'audit-log') return true; + if (view === 'audit-log') return true; } else { if (view === 'audit-log' && !ctx.can('system:audit')) return true; - if (view === 'host-console' && !ctx.isAdmin) return true; } return false; } @@ -48,6 +67,10 @@ export function isViewCapabilityLocked(view: ActiveView, ctx: ReachabilityContex export function isFleetTabHidden(tab: FleetTab, ctx: ReachabilityContext): boolean { if (!authzReady(ctx)) return false; if (tab === 'container-labels' && !ctx.containerLabelsEnabled) return true; + // Defer experimental hide until ready (same cold-load contract as host-console). + if ((tab === 'routing' || tab === 'secrets') && experimentalDiscoveryReady(ctx) && !ctx.experimental) { + return true; + } return false; } @@ -58,6 +81,8 @@ export function isSettingsSectionHidden(section: SectionId, ctx: ReachabilityCon if (ctx.isRemote && item.hiddenOnRemote) return true; if (item.adminOnly && !ctx.isAdmin) return true; if (item.tier === 'paid' && !ctx.isPaid) return true; + // fleet-mesh stays reachable: snapshot_documentation lives there even when + // Mesh discovery is off. return false; }