feat(meta): gate deferred Fleet tabs behind SENCHO_EXPERIMENTAL flag (#886)

Hide the Traffic / Routing, Deployments, Federation and Secrets Fleet
tabs by default. They re-appear when the operator opts in by setting
SENCHO_EXPERIMENTAL=true. Backend routes and database tables are
unchanged; this is a UI discovery gate only.

The /api/meta endpoint now returns experimental as a boolean. A new
useExperimental hook reads it once per page load and feeds the four
tab triggers and tab content panels in FleetView.
This commit is contained in:
Anso
2026-05-02 18:01:06 -04:00
committed by GitHub
parent b6b154cb99
commit d69fb9f1da
5 changed files with 174 additions and 52 deletions
+8
View File
@@ -77,3 +77,11 @@ SSO_DEFAULT_ROLE=viewer
# External base URL for OAuth callback URLs (required behind reverse proxy) # External base URL for OAuth callback URLs (required behind reverse proxy)
SSO_CALLBACK_URL= SSO_CALLBACK_URL=
# Experimental UI surfaces. When unset or any value other than "true",
# the UI hides surfaces that are kept in the repo but not yet promoted
# to the default 1.0 build (Fleet Traffic · Routing tab, Fleet
# Deployments / Blueprints tab, Fleet Federation tab, Fleet Secrets
# sync tab, Fleet Actions tab). Backend routes for these surfaces stay
# live regardless. This flag controls UI discovery only.
SENCHO_EXPERIMENTAL=false
+51
View File
@@ -39,3 +39,54 @@ describe('GET /api/health', () => {
expect(res.status).not.toBe(403); expect(res.status).not.toBe(403);
}); });
}); });
describe('GET /api/meta experimental flag', () => {
it('reports experimental=false by default', async () => {
const prev = process.env.SENCHO_EXPERIMENTAL;
delete process.env.SENCHO_EXPERIMENTAL;
try {
const res = await request(app).get('/api/meta');
expect(res.status).toBe(200);
expect(res.body.experimental).toBe(false);
} finally {
if (prev !== undefined) process.env.SENCHO_EXPERIMENTAL = prev;
}
});
it('reports experimental=true when SENCHO_EXPERIMENTAL=true', async () => {
const prev = process.env.SENCHO_EXPERIMENTAL;
process.env.SENCHO_EXPERIMENTAL = 'true';
try {
const res = await request(app).get('/api/meta');
expect(res.status).toBe(200);
expect(res.body.experimental).toBe(true);
} finally {
if (prev === undefined) delete process.env.SENCHO_EXPERIMENTAL;
else process.env.SENCHO_EXPERIMENTAL = prev;
}
});
it('treats any non-"true" value as false', async () => {
const prev = process.env.SENCHO_EXPERIMENTAL;
process.env.SENCHO_EXPERIMENTAL = '1';
try {
const res = await request(app).get('/api/meta');
expect(res.body.experimental).toBe(false);
} finally {
if (prev === undefined) delete process.env.SENCHO_EXPERIMENTAL;
else process.env.SENCHO_EXPERIMENTAL = prev;
}
});
it('treats an empty string as false', async () => {
const prev = process.env.SENCHO_EXPERIMENTAL;
process.env.SENCHO_EXPERIMENTAL = '';
try {
const res = await request(app).get('/api/meta');
expect(res.body.experimental).toBe(false);
} finally {
if (prev === undefined) delete process.env.SENCHO_EXPERIMENTAL;
else process.env.SENCHO_EXPERIMENTAL = prev;
}
});
});
+1
View File
@@ -22,6 +22,7 @@ metaRouter.get('/meta', (_req: Request, res: Response): void => {
version: getSenchoVersion(), version: getSenchoVersion(),
capabilities: getActiveCapabilities(), capabilities: getActiveCapabilities(),
startedAt: processStartedAt, startedAt: processStartedAt,
experimental: process.env.SENCHO_EXPERIMENTAL === 'true',
...(updateError ? { updateError } : {}), ...(updateError ? { updateError } : {}),
}); });
}); });
+12 -2
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useExperimental } from '@/hooks/useExperimental';
import { import {
Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight, Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight,
Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle, Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle,
@@ -678,6 +679,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set()); const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set());
const { isPaid, license } = useLicense(); const { isPaid, license } = useLicense();
const isAdmiral = isPaid && license?.variant === 'admiral'; const isAdmiral = isPaid && license?.variant === 'admiral';
const experimental = useExperimental();
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]); const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null); const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
const [reconnecting, setReconnecting] = useState(false); const [reconnecting, setReconnecting] = useState(false);
@@ -1048,7 +1050,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</TabsTrigger> </TabsTrigger>
</TabsHighlightItem> </TabsHighlightItem>
)} )}
{isAdmiral && ( {isAdmiral && experimental && (
<TabsHighlightItem value="routing"> <TabsHighlightItem value="routing">
<TabsTrigger value="routing"> <TabsTrigger value="routing">
<ArrowLeftRight className="w-4 h-4 mr-1.5" />Traffic · Routing <ArrowLeftRight className="w-4 h-4 mr-1.5" />Traffic · Routing
@@ -1060,6 +1062,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<SlidersHorizontal className="w-4 h-4 mr-1.5" />Status <SlidersHorizontal className="w-4 h-4 mr-1.5" />Status
</TabsTrigger> </TabsTrigger>
</TabsHighlightItem> </TabsHighlightItem>
{experimental && (
<>
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" /> <span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
<TabsHighlightItem value="deployments"> <TabsHighlightItem value="deployments">
<TabsTrigger value="deployments"> <TabsTrigger value="deployments">
@@ -1079,6 +1083,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<SoonBadge /> <SoonBadge />
</TabsTrigger> </TabsTrigger>
</TabsHighlightItem> </TabsHighlightItem>
</>
)}
</TabsHighlight> </TabsHighlight>
</TabsList> </TabsList>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1375,7 +1381,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<FleetSnapshots /> <FleetSnapshots />
</TabsContent> </TabsContent>
)} )}
{isAdmiral && ( {isAdmiral && experimental && (
<TabsContent value="routing"> <TabsContent value="routing">
<AdmiralGate featureName="Sencho Mesh"> <AdmiralGate featureName="Sencho Mesh">
<RoutingTab /> <RoutingTab />
@@ -1385,6 +1391,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<TabsContent value="configuration"> <TabsContent value="configuration">
<FleetConfiguration /> <FleetConfiguration />
</TabsContent> </TabsContent>
{experimental && (
<>
<TabsContent value="deployments"> <TabsContent value="deployments">
{isPaid ? ( {isPaid ? (
<DeploymentsTab /> <DeploymentsTab />
@@ -1418,6 +1426,8 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
plannedActions={['Sync env', 'Rotate', 'Audit', 'Pin to nodes']} plannedActions={['Sync env', 'Rotate', 'Audit', 'Pin to nodes']}
/> />
</TabsContent> </TabsContent>
</>
)}
</Tabs> </Tabs>
{/* Reconnecting overlay shown when local node is updating */} {/* Reconnecting overlay shown when local node is updating */}
+52
View File
@@ -0,0 +1,52 @@
import { useEffect, useState } from 'react';
import { apiFetch } from '@/lib/api';
// 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.
let cached: boolean | null = null;
let inflight: Promise<boolean> | null = null;
async function fetchExperimental(): Promise<boolean> {
if (cached !== null) return cached;
if (inflight) return inflight;
inflight = (async () => {
try {
// localOnly: the flag is a property of the gateway running the
// browser session, not of whichever remote node is currently
// selected. Without this, switching nodes would re-evaluate the
// flag against the wrong process env.
const res = await apiFetch('/meta', { localOnly: true });
if (!res.ok) {
cached = false;
return false;
}
const body = (await res.json()) as { experimental?: boolean };
const next = body.experimental === true;
cached = next;
return next;
} catch {
cached = false;
return false;
} finally {
inflight = null;
}
})();
return inflight;
}
export function useExperimental(): boolean {
const [value, setValue] = useState<boolean>(cached ?? false);
useEffect(() => {
let active = true;
fetchExperimental().then((next) => {
if (active) setValue(next);
});
return () => {
active = false;
};
}, []);
return value;
}