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)
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);
});
});
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(),
capabilities: getActiveCapabilities(),
startedAt: processStartedAt,
experimental: process.env.SENCHO_EXPERIMENTAL === 'true',
...(updateError ? { updateError } : {}),
});
});
+62 -52
View File
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { useExperimental } from '@/hooks/useExperimental';
import {
Server, Cpu, MemoryStick, HardDrive, RefreshCw, ChevronDown, ChevronRight,
Layers, Wifi, WifiOff, Search, ArrowUpDown, AlertTriangle,
@@ -678,6 +679,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
const [labelFilters, setLabelFilters] = useState<Set<string>>(new Set());
const { isPaid, license } = useLicense();
const isAdmiral = isPaid && license?.variant === 'admiral';
const experimental = useExperimental();
const [updateStatuses, setUpdateStatuses] = useState<NodeUpdateStatus[]>([]);
const [updatingNodeId, setUpdatingNodeId] = useState<number | null>(null);
const [reconnecting, setReconnecting] = useState(false);
@@ -1048,7 +1050,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
</TabsTrigger>
</TabsHighlightItem>
)}
{isAdmiral && (
{isAdmiral && experimental && (
<TabsHighlightItem value="routing">
<TabsTrigger value="routing">
<ArrowLeftRight className="w-4 h-4 mr-1.5" />Traffic · Routing
@@ -1060,25 +1062,29 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<SlidersHorizontal className="w-4 h-4 mr-1.5" />Status
</TabsTrigger>
</TabsHighlightItem>
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
<TabsHighlightItem value="deployments">
<TabsTrigger value="deployments">
<Send className="w-4 h-4 mr-1.5" />Deployments
{!isPaid && <SoonBadge />}
</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="federation">
<TabsTrigger value="federation">
<Network className="w-4 h-4 mr-1.5" />Federation
<SoonBadge />
</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="secrets">
<TabsTrigger value="secrets">
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
<SoonBadge />
</TabsTrigger>
</TabsHighlightItem>
{experimental && (
<>
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
<TabsHighlightItem value="deployments">
<TabsTrigger value="deployments">
<Send className="w-4 h-4 mr-1.5" />Deployments
{!isPaid && <SoonBadge />}
</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="federation">
<TabsTrigger value="federation">
<Network className="w-4 h-4 mr-1.5" />Federation
<SoonBadge />
</TabsTrigger>
</TabsHighlightItem>
<TabsHighlightItem value="secrets">
<TabsTrigger value="secrets">
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
<SoonBadge />
</TabsTrigger>
</TabsHighlightItem>
</>
)}
</TabsHighlight>
</TabsList>
<div className="flex items-center gap-2">
@@ -1375,7 +1381,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<FleetSnapshots />
</TabsContent>
)}
{isAdmiral && (
{isAdmiral && experimental && (
<TabsContent value="routing">
<AdmiralGate featureName="Sencho Mesh">
<RoutingTab />
@@ -1385,39 +1391,43 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
<TabsContent value="configuration">
<FleetConfiguration />
</TabsContent>
<TabsContent value="deployments">
{isPaid ? (
<DeploymentsTab />
) : (
<PaidGate featureName="Blueprints (fleet-wide compose templates)">
{experimental && (
<>
<TabsContent value="deployments">
{isPaid ? (
<DeploymentsTab />
) : (
<PaidGate featureName="Blueprints (fleet-wide compose templates)">
<FleetSoonPlaceholder
icon={<Send className="h-4 w-4" />}
kicker="Deployments · Blueprints"
title="Declare once. Distribute everywhere."
description="Pick nodes by label, drop in a docker-compose, and Sencho keeps the matching nodes in sync. Drift detection always on; auto-fix optional."
plannedActions={['Author', 'Target', 'Reconcile', 'Snapshot+evict']}
/>
</PaidGate>
)}
</TabsContent>
<TabsContent value="federation">
<FleetSoonPlaceholder
icon={<Send className="h-4 w-4" />}
kicker="Deployments · Blueprints"
title="Declare once. Distribute everywhere."
description="Pick nodes by label, drop in a docker-compose, and Sencho keeps the matching nodes in sync. Drift detection always on; auto-fix optional."
plannedActions={['Author', 'Target', 'Reconcile', 'Snapshot+evict']}
icon={<Network className="h-4 w-4" />}
kicker="Federation · Coming soon"
title="The fleet as one logical surface"
description="Pin policies, drain a node for maintenance, weight-aware scheduling. This stack runs on whichever node has capacity."
plannedActions={['Pin policy', 'Drain node', 'Cordon', 'Capacity plan']}
/>
</PaidGate>
)}
</TabsContent>
<TabsContent value="federation">
<FleetSoonPlaceholder
icon={<Network className="h-4 w-4" />}
kicker="Federation · Coming soon"
title="The fleet as one logical surface"
description="Pin policies, drain a node for maintenance, weight-aware scheduling. This stack runs on whichever node has capacity."
plannedActions={['Pin policy', 'Drain node', 'Cordon', 'Capacity plan']}
/>
</TabsContent>
<TabsContent value="secrets">
<FleetSoonPlaceholder
icon={<KeyRound className="h-4 w-4" />}
kicker="Secrets · Coming soon"
title="One source of truth for env, creds and certs"
description="Push to selected nodes, rotate centrally, audit who-saw-what. Solves silent drift across copies."
plannedActions={['Sync env', 'Rotate', 'Audit', 'Pin to nodes']}
/>
</TabsContent>
</TabsContent>
<TabsContent value="secrets">
<FleetSoonPlaceholder
icon={<KeyRound className="h-4 w-4" />}
kicker="Secrets · Coming soon"
title="One source of truth for env, creds and certs"
description="Push to selected nodes, rotate centrally, audit who-saw-what. Solves silent drift across copies."
plannedActions={['Sync env', 'Rotate', 'Audit', 'Pin to nodes']}
/>
</TabsContent>
</>
)}
</Tabs>
{/* 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;
}