mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user