mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-10 18:56:53 +00:00
chore(ui): hide Mesh, Fleet Secrets, and Host Console behind experimental discovery (#1624)
Gate Routing, Secrets, Host Console, and Mesh dashboard/settings surfaces on the existing useExperimental readiness flag so immature operator surfaces stay out of the default UI while paid and admin backend gates remain unchanged.
This commit is contained in:
@@ -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 (
|
||||
<SettingsPage
|
||||
@@ -166,9 +168,11 @@ export function ViewRouter({
|
||||
);
|
||||
}
|
||||
if (activeView === 'host-console') {
|
||||
// Mirror the backend RBAC gate (system:console, admin-only). The nav
|
||||
// item is already admin-gated; this stops a non-admin who reaches the
|
||||
// view another way from mounting a console that the server will 403.
|
||||
// Discovery + paid/RBAC: hide until experimental discovery is on,
|
||||
// then mirror backend gates (system:console admin-only + PaidGate +
|
||||
// capability). Nav is already gated the same way; this stops a
|
||||
// deep link from mounting a console the operator cannot use.
|
||||
if (!experimentalReady || !experimental) return null;
|
||||
if (!can('system:console')) return null;
|
||||
return (
|
||||
<PaidGate>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,8 @@ function makeReachCtx(over: Partial<ReachabilityContext> = {}): ReachabilityCont
|
||||
containerLabelsEnabled: true,
|
||||
permissionsStatus: 'ready',
|
||||
licenseStatus: 'ready',
|
||||
experimental: true,
|
||||
experimentalReady: true,
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
<Send className="w-4 h-4 mr-1.5" />Deployments
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
{isPaid && (
|
||||
{canDiscoverRouting && (
|
||||
<TabsHighlightItem value="routing">
|
||||
<TabsTrigger value="routing">
|
||||
<ArrowLeftRight className="w-4 h-4 mr-1.5" />Routing
|
||||
@@ -175,7 +206,7 @@ export function FleetView({
|
||||
<Wrench className="w-4 h-4 mr-1.5" />Actions
|
||||
</TabsTrigger>
|
||||
</TabsHighlightItem>
|
||||
{isPaid && isAdmin && (
|
||||
{canDiscoverSecrets && (
|
||||
<TabsHighlightItem value="secrets">
|
||||
<TabsTrigger value="secrets">
|
||||
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
|
||||
@@ -281,7 +312,7 @@ export function FleetView({
|
||||
<TabsContent value="deployments">
|
||||
<DeploymentsTab />
|
||||
</TabsContent>
|
||||
{isPaid && (
|
||||
{canDiscoverRouting && (
|
||||
<TabsContent value="routing">
|
||||
<PaidGate>
|
||||
<RoutingTab canManage={isAdmin} />
|
||||
@@ -296,7 +327,7 @@ export function FleetView({
|
||||
unfiltered node list rather than the overview-filtered view. */}
|
||||
<FleetActionsTab nodes={overview.nodes} />
|
||||
</TabsContent>
|
||||
{isPaid && isAdmin && (
|
||||
{canDiscoverSecrets && (
|
||||
<TabsContent value="secrets">
|
||||
<SecretsTab />
|
||||
</TabsContent>
|
||||
|
||||
@@ -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: () => <div data-testid="masthead" /> }));
|
||||
vi.mock('../FleetView/OverviewTab', () => ({ OverviewTab: () => <div data-testid="overview-tab" /> }));
|
||||
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: () => <div data-testid="routing-tab" /> }));
|
||||
vi.mock('../fleet/FederationTab', () => ({ FederationTab: () => <div data-testid="federation-tab" /> }));
|
||||
vi.mock('../blueprints/DeploymentsTab', () => ({ DeploymentsTab: () => <div data-testid="deployments-tab" /> }));
|
||||
vi.mock('../fleet/FleetActions/FleetActionsTab', () => ({ FleetActionsTab: () => <div data-testid="actions-tab" /> }));
|
||||
vi.mock('../fleet/secrets/SecretsTab', () => ({ SecretsTab: () => <div data-testid="secrets-tab" /> }));
|
||||
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(<FleetView onNavigateToNode={vi.fn()} />);
|
||||
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(<FleetView onNavigateToNode={vi.fn()} />);
|
||||
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(
|
||||
<FleetView
|
||||
onNavigateToNode={vi.fn()}
|
||||
fleetActiveTab="routing"
|
||||
onFleetActiveTabChange={onTab}
|
||||
/>,
|
||||
);
|
||||
expect(screen.queryByRole('tab', { name: /routing/i })).toBeNull();
|
||||
expect(onTab).not.toHaveBeenCalled();
|
||||
|
||||
useExperimentalMock.mockReturnValue({ experimental: true, experimentalReady: true });
|
||||
rerender(
|
||||
<FleetView
|
||||
onNavigateToNode={vi.fn()}
|
||||
fleetActiveTab="routing"
|
||||
onFleetActiveTabChange={onTab}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<FleetView
|
||||
onNavigateToNode={vi.fn()}
|
||||
fleetActiveTab="routing"
|
||||
onFleetActiveTabChange={onTab}
|
||||
/>,
|
||||
);
|
||||
expect(onTab).not.toHaveBeenCalled();
|
||||
|
||||
useExperimentalMock.mockReturnValue({ experimental: false, experimentalReady: true });
|
||||
rerender(
|
||||
<FleetView
|
||||
onNavigateToNode={vi.fn()}
|
||||
fleetActiveTab="routing"
|
||||
onFleetActiveTabChange={onTab}
|
||||
/>,
|
||||
);
|
||||
await waitFor(() => expect(onTab).toHaveBeenCalledWith('overview'));
|
||||
expect(onTab).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -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<typeof import('@/lib/utils')>('@/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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<MeshDataPlaneStatus | null>(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 };
|
||||
}
|
||||
|
||||
@@ -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<PatchableSettings, 'mesh_auto_recreate' | 'snapshot_documentation'>;
|
||||
type SnapshotOnlyFields = Pick<PatchableSettings, 'snapshot_documentation'>;
|
||||
|
||||
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<FleetMeshFields>({ ...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 (
|
||||
<fieldset disabled={readOnly} className="m-0 flex min-w-0 flex-col gap-10 border-0 p-0">
|
||||
<SettingsSection title="Mesh data plane">
|
||||
<SettingsField
|
||||
label="Auto-recreate mesh network"
|
||||
helper="If sencho_mesh is removed at runtime, rebuild it at the same subnet on the next 10s tick. Off by default; leave off and restart Sencho manually for the safest path."
|
||||
>
|
||||
<TogglePill
|
||||
checked={settings.mesh_auto_recreate === '1'}
|
||||
onChange={(next) => onSettingChange('mesh_auto_recreate', next ? '1' : '0')}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
{showMesh && (
|
||||
<SettingsSection title="Mesh data plane">
|
||||
<SettingsField
|
||||
label="Auto-recreate mesh network"
|
||||
helper="If sencho_mesh is removed at runtime, rebuild it at the same subnet on the next 10s tick. Off by default; leave off and restart Sencho manually for the safest path."
|
||||
>
|
||||
<TogglePill
|
||||
checked={settings.mesh_auto_recreate === '1'}
|
||||
onChange={(next) => onSettingChange('mesh_auto_recreate', next ? '1' : '0')}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
<SettingsSection title="Documentation snapshots">
|
||||
<SettingsField
|
||||
|
||||
@@ -18,6 +18,10 @@ vi.mock('@/context/AuthContext', () => ({ 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<string, string> = {
|
||||
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(<FleetMeshSection />);
|
||||
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(<FleetMeshSection />);
|
||||
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(<DataRetentionSection />);
|
||||
const save = await screen.findByRole('button', { name: /save settings/i });
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Response>((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);
|
||||
});
|
||||
});
|
||||
@@ -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<boolean> | null = null;
|
||||
|
||||
@@ -37,16 +44,28 @@ async function fetchExperimental(): Promise<boolean> {
|
||||
return inflight;
|
||||
}
|
||||
|
||||
export function useExperimental(): boolean {
|
||||
const [value, setValue] = useState<boolean>(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<ExperimentalDiscovery>(() =>
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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> = {}): 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user