mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22:59 +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)
|
# 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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 } : {}),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,25 +1062,29 @@ 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>
|
||||||
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
|
{experimental && (
|
||||||
<TabsHighlightItem value="deployments">
|
<>
|
||||||
<TabsTrigger value="deployments">
|
<span aria-hidden className="self-center mx-1 h-4 w-px bg-border" />
|
||||||
<Send className="w-4 h-4 mr-1.5" />Deployments
|
<TabsHighlightItem value="deployments">
|
||||||
{!isPaid && <SoonBadge />}
|
<TabsTrigger value="deployments">
|
||||||
</TabsTrigger>
|
<Send className="w-4 h-4 mr-1.5" />Deployments
|
||||||
</TabsHighlightItem>
|
{!isPaid && <SoonBadge />}
|
||||||
<TabsHighlightItem value="federation">
|
</TabsTrigger>
|
||||||
<TabsTrigger value="federation">
|
</TabsHighlightItem>
|
||||||
<Network className="w-4 h-4 mr-1.5" />Federation
|
<TabsHighlightItem value="federation">
|
||||||
<SoonBadge />
|
<TabsTrigger value="federation">
|
||||||
</TabsTrigger>
|
<Network className="w-4 h-4 mr-1.5" />Federation
|
||||||
</TabsHighlightItem>
|
<SoonBadge />
|
||||||
<TabsHighlightItem value="secrets">
|
</TabsTrigger>
|
||||||
<TabsTrigger value="secrets">
|
</TabsHighlightItem>
|
||||||
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
|
<TabsHighlightItem value="secrets">
|
||||||
<SoonBadge />
|
<TabsTrigger value="secrets">
|
||||||
</TabsTrigger>
|
<KeyRound className="w-4 h-4 mr-1.5" />Secrets
|
||||||
</TabsHighlightItem>
|
<SoonBadge />
|
||||||
|
</TabsTrigger>
|
||||||
|
</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,39 +1391,43 @@ export function FleetView({ onNavigateToNode }: FleetViewProps) {
|
|||||||
<TabsContent value="configuration">
|
<TabsContent value="configuration">
|
||||||
<FleetConfiguration />
|
<FleetConfiguration />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
<TabsContent value="deployments">
|
{experimental && (
|
||||||
{isPaid ? (
|
<>
|
||||||
<DeploymentsTab />
|
<TabsContent value="deployments">
|
||||||
) : (
|
{isPaid ? (
|
||||||
<PaidGate featureName="Blueprints (fleet-wide compose templates)">
|
<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
|
<FleetSoonPlaceholder
|
||||||
icon={<Send className="h-4 w-4" />}
|
icon={<Network className="h-4 w-4" />}
|
||||||
kicker="Deployments · Blueprints"
|
kicker="Federation · Coming soon"
|
||||||
title="Declare once. Distribute everywhere."
|
title="The fleet as one logical surface"
|
||||||
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."
|
description="Pin policies, drain a node for maintenance, weight-aware scheduling. This stack runs on whichever node has capacity."
|
||||||
plannedActions={['Author', 'Target', 'Reconcile', 'Snapshot+evict']}
|
plannedActions={['Pin policy', 'Drain node', 'Cordon', 'Capacity plan']}
|
||||||
/>
|
/>
|
||||||
</PaidGate>
|
</TabsContent>
|
||||||
)}
|
<TabsContent value="secrets">
|
||||||
</TabsContent>
|
<FleetSoonPlaceholder
|
||||||
<TabsContent value="federation">
|
icon={<KeyRound className="h-4 w-4" />}
|
||||||
<FleetSoonPlaceholder
|
kicker="Secrets · Coming soon"
|
||||||
icon={<Network className="h-4 w-4" />}
|
title="One source of truth for env, creds and certs"
|
||||||
kicker="Federation · Coming soon"
|
description="Push to selected nodes, rotate centrally, audit who-saw-what. Solves silent drift across copies."
|
||||||
title="The fleet as one logical surface"
|
plannedActions={['Sync env', 'Rotate', 'Audit', 'Pin to nodes']}
|
||||||
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>
|
)}
|
||||||
<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>
|
</Tabs>
|
||||||
|
|
||||||
{/* Reconnecting overlay shown when local node is updating */}
|
{/* 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