From d69fb9f1da24ef257d58ce9682fad308be2f86ff Mon Sep 17 00:00:00 2001 From: Anso Date: Sat, 2 May 2026 18:01:06 -0400 Subject: [PATCH] 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. --- .env.example | 8 ++ backend/src/__tests__/health.test.ts | 51 ++++++++++++ backend/src/routes/meta.ts | 1 + frontend/src/components/FleetView.tsx | 114 ++++++++++++++------------ frontend/src/hooks/useExperimental.ts | 52 ++++++++++++ 5 files changed, 174 insertions(+), 52 deletions(-) create mode 100644 frontend/src/hooks/useExperimental.ts diff --git a/.env.example b/.env.example index 35346e40..ac4a367d 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/backend/src/__tests__/health.test.ts b/backend/src/__tests__/health.test.ts index e19a9a43..b383e6a6 100644 --- a/backend/src/__tests__/health.test.ts +++ b/backend/src/__tests__/health.test.ts @@ -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; + } + }); +}); diff --git a/backend/src/routes/meta.ts b/backend/src/routes/meta.ts index bc24dbfd..4f05ecfd 100644 --- a/backend/src/routes/meta.ts +++ b/backend/src/routes/meta.ts @@ -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 } : {}), }); }); diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 4895b8e1..17def6c5 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -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>(new Set()); const { isPaid, license } = useLicense(); const isAdmiral = isPaid && license?.variant === 'admiral'; + const experimental = useExperimental(); const [updateStatuses, setUpdateStatuses] = useState([]); const [updatingNodeId, setUpdatingNodeId] = useState(null); const [reconnecting, setReconnecting] = useState(false); @@ -1048,7 +1050,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} - {isAdmiral && ( + {isAdmiral && experimental && ( Traffic · Routing @@ -1060,25 +1062,29 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { Status - - - - Deployments - {!isPaid && } - - - - - Federation - - - - - - Secrets - - - + {experimental && ( + <> + + + + Deployments + {!isPaid && } + + + + + Federation + + + + + + Secrets + + + + + )}
@@ -1375,7 +1381,7 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { )} - {isAdmiral && ( + {isAdmiral && experimental && ( @@ -1385,39 +1391,43 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) { - - {isPaid ? ( - - ) : ( - + {experimental && ( + <> + + {isPaid ? ( + + ) : ( + + } + 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']} + /> + + )} + + } - 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={} + 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']} /> - - )} - - - } - 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']} - /> - - - } - 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']} - /> - + + + } + 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']} + /> + + + )} {/* Reconnecting overlay shown when local node is updating */} diff --git a/frontend/src/hooks/useExperimental.ts b/frontend/src/hooks/useExperimental.ts new file mode 100644 index 00000000..d03a0198 --- /dev/null +++ b/frontend/src/hooks/useExperimental.ts @@ -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 | null = null; + +async function fetchExperimental(): Promise { + 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(cached ?? false); + useEffect(() => { + let active = true; + fetchExperimental().then((next) => { + if (active) setValue(next); + }); + return () => { + active = false; + }; + }, []); + return value; +}