feat(nodes): add capability-based node compatibility negotiation (#350)

* feat(nodes): add capability-based node compatibility negotiation

Each Sencho instance now exposes /api/meta with its version and supported
capabilities. When the user switches nodes, the frontend fetches this
metadata and disables features the remote node doesn't support via a
CapabilityGate overlay. Version is shown in the node switcher dropdown
and connection test results.

- Backend: CapabilityRegistry with static capability list and fetchRemoteMeta helper
- Backend: /api/meta (public) and /api/nodes/:id/meta (auth) endpoints
- Frontend: NodeContext enhanced with per-node meta caching (5min TTL)
- Frontend: CapabilityGate component with typed Capability union
- Frontend: 13 features wrapped with capability gates
- Docs: node-compatibility.mdx + OpenAPI spec updates

* fix(nodes): revert to require() for package.json version reading

The static import fails in the Docker multi-stage build because the
root package.json is not copied into the backend-builder stage. The
require() call resolves at runtime when the file is available.
This commit is contained in:
Anso
2026-04-03 00:06:34 -04:00
committed by GitHub
parent ec23f8c4c2
commit ee75811e25
20 changed files with 468 additions and 45 deletions
+1
View File
@@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
* **notifications:** shared notification routing rules (Admiral tier) — route stack alerts to specific Discord, Slack, or webhook channels instead of the global endpoint. Includes per-rule enable/disable, priority ordering, and fallback to global agents when no rule matches.
* **nodes:** capability-based node compatibility negotiation — each Sencho instance now exposes a `/api/meta` endpoint advertising its version and supported features. When switching nodes, the frontend fetches this metadata and disables features the remote node doesn't support, with a clear overlay explanation. Version is shown in the node switcher dropdown and connection test results. Lays the groundwork for future fleet-wide update management.
## [0.30.0](https://github.com/AnsoCode/Sencho/compare/v0.29.0...v0.30.0) (2026-04-03)
+45 -3
View File
@@ -30,6 +30,7 @@ import { WebhookService } from './services/WebhookService';
import { SSOService } from './services/SSOService';
import { SchedulerService } from './services/SchedulerService';
import { RegistryService } from './services/RegistryService';
import { CAPABILITIES, getSenchoVersion, fetchRemoteMeta } from './services/CapabilityRegistry';
import { CronExpressionParser } from 'cron-parser';
import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from './utils/validation';
import YAML from 'yaml';
@@ -177,7 +178,8 @@ app.use((req: Request, res: Response, next: NextFunction): void => {
!req.path.startsWith('/api/nodes') &&
!req.path.startsWith('/api/license') &&
!req.path.startsWith('/api/fleet') &&
!req.path.startsWith('/api/webhooks')
!req.path.startsWith('/api/webhooks') &&
!req.path.startsWith('/api/meta')
) {
// Preserve body stream for proxy piping
next();
@@ -210,7 +212,8 @@ const nodeContextMiddleware = (req: Request, res: Response, next: NextFunction)
!req.path.startsWith('/api/nodes') &&
!req.path.startsWith('/api/license') &&
!req.path.startsWith('/api/fleet') &&
!req.path.startsWith('/api/webhooks')
!req.path.startsWith('/api/webhooks') &&
!req.path.startsWith('/api/meta')
) {
const node = DatabaseService.getInstance().getNode(req.nodeId);
if (!node) {
@@ -317,6 +320,12 @@ app.get('/api/health', (_req: Request, res: Response): void => {
res.json({ status: 'ok', uptime: process.uptime() });
});
// Public meta endpoint - returns this instance's version and supported capabilities.
// No auth required (like /health). Used by remote nodes during connection tests.
app.get('/api/meta', (_req: Request, res: Response): void => {
res.json({ version: getSenchoVersion(), capabilities: CAPABILITIES });
});
// Auth Routes (no authentication required)
// Check if setup is needed
@@ -2153,7 +2162,7 @@ const remoteNodeProxy = createProxyMiddleware<Request, Response>({
// Intercepts all /api/ requests for remote Distributed API nodes and forwards them
// to the target Sencho instance. Node management and auth routes always execute locally.
app.use('/api/', (req: Request, res: Response, next: NextFunction): void => {
if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes') || req.path.startsWith('/license') || req.path.startsWith('/fleet') || req.path.startsWith('/webhooks')) {
if (req.path.startsWith('/auth/') || req.path.startsWith('/nodes') || req.path.startsWith('/license') || req.path.startsWith('/fleet') || req.path.startsWith('/webhooks') || req.path.startsWith('/meta')) {
next();
return;
}
@@ -5181,6 +5190,39 @@ app.post('/api/nodes/:id/test', async (req: Request, res: Response) => {
}
});
// Fetch capability metadata for a specific node. For local nodes, returns this
// instance's capabilities directly. For remote nodes, relays GET /api/meta from
// the remote Sencho instance. Returns { version: null, capabilities: [] } on
// failure (old node without /api/meta, or node offline) — never an error status.
app.get('/api/nodes/:id/meta', authMiddleware, async (req: Request, res: Response) => {
try {
const id = parseInt(req.params.id as string);
const node = DatabaseService.getInstance().getNode(id);
if (!node) {
res.status(404).json({ error: 'Node not found' });
return;
}
if (node.type === 'local') {
res.json({ version: getSenchoVersion(), capabilities: CAPABILITIES });
return;
}
// Remote node — relay GET /api/meta
const baseUrl = node.api_url?.replace(/\/$/, '');
if (!baseUrl || !node.api_token) {
res.json({ version: null, capabilities: [] });
return;
}
const meta = await fetchRemoteMeta(baseUrl, node.api_token);
res.json(meta);
} catch (error: any) {
console.error('Failed to fetch node meta:', error);
res.status(500).json({ error: error.message || 'Failed to fetch node metadata' });
}
});
// Serve static files in production (for Docker deployment)
if (process.env.NODE_ENV === 'production') {
@@ -0,0 +1,58 @@
import axios from 'axios';
/**
* Static registry of capabilities supported by THIS Sencho instance.
* Append-only: when a new feature ships, add its capability string here.
* The frontend uses these flags (not semver comparisons) to gate features
* on nodes that may be running older versions.
*/
export const CAPABILITIES = [
'stacks',
'containers',
'resources',
'templates',
'global-logs',
'system-stats',
'fleet',
'auto-updates',
'labels',
'webhooks',
'network-topology',
'notifications',
'notification-routing',
'host-console',
'audit-log',
'scheduled-ops',
'sso',
'api-tokens',
'users',
'registries',
] as const;
export type Capability = (typeof CAPABILITIES)[number];
export function getSenchoVersion(): string {
// eslint-disable-next-line @typescript-eslint/no-require-imports
return require('../../../package.json').version;
}
export interface RemoteMeta {
version: string | null;
capabilities: string[];
}
/** Fetch /api/meta from a remote Sencho instance. Returns empty data on failure. */
export async function fetchRemoteMeta(baseUrl: string, apiToken: string): Promise<RemoteMeta> {
try {
const res = await axios.get(`${baseUrl}/api/meta`, {
headers: { Authorization: `Bearer ${apiToken}` },
timeout: 5000,
});
return {
version: res.data.version ?? null,
capabilities: Array.isArray(res.data.capabilities) ? res.data.capabilities : [],
};
} catch {
return { version: null, capabilities: [] };
}
}
+6 -1
View File
@@ -1,6 +1,7 @@
import Docker from 'dockerode';
import axios from 'axios';
import { DatabaseService, Node } from './DatabaseService';
import { fetchRemoteMeta } from './CapabilityRegistry';
/**
* NodeRegistry: Manages connections for multiple nodes.
@@ -165,21 +166,25 @@ export class NodeRegistry {
// Step 2: Fetch Docker stats in parallel. Use allSettled so a slow or missing
// endpoint doesn't fail the whole test - each field falls back to '-' gracefully.
const [statsResult, sysResult, imagesResult] = await Promise.allSettled([
const [statsResult, sysResult, imagesResult, metaResult] = await Promise.allSettled([
axios.get(`${baseUrl}/api/stats`, { headers, timeout: 8000 }),
axios.get(`${baseUrl}/api/system/stats`, { headers, timeout: 8000 }),
axios.get(`${baseUrl}/api/system/images`, { headers, timeout: 8000 }),
fetchRemoteMeta(baseUrl, node.api_token!),
]);
const stats = statsResult.status === 'fulfilled' ? statsResult.value.data : null;
const sys = sysResult.status === 'fulfilled' ? sysResult.value.data : null;
const images = imagesResult.status === 'fulfilled' ? imagesResult.value.data : null;
const meta = metaResult.status === 'fulfilled' ? metaResult.value : null;
return {
success: true,
info: {
name: node.name,
serverVersion: 'Remote Sencho',
senchoVersion: meta?.version ?? null,
capabilities: meta?.capabilities ?? [],
os: 'Remote',
architecture: 'Remote',
containers: stats?.total ?? '-',
+6 -3
View File
@@ -102,6 +102,7 @@
"features/global-observability",
"features/host-console",
"features/multi-node",
"features/node-compatibility",
"features/fleet-view",
"features/stack-labels",
"features/alerts-notifications",
@@ -145,9 +146,10 @@
"pages": [
"api-reference/overview",
{
"group": "Health",
"group": "Health & Meta",
"pages": [
"GET /api/health"
"GET /api/health",
"GET /api/meta"
]
},
{
@@ -210,7 +212,8 @@
"GET /api/nodes/{id}",
"PUT /api/nodes/{id}",
"DELETE /api/nodes/{id}",
"POST /api/nodes/{id}/test"
"POST /api/nodes/{id}/test",
"GET /api/nodes/{id}/meta"
]
},
{
+81
View File
@@ -0,0 +1,81 @@
---
title: Node Compatibility
description: How Sencho handles version differences across nodes and gracefully degrades features that aren't available on older instances.
---
When you manage multiple nodes, each running its own Sencho instance, those instances may be on different versions. A node running v0.28 won't have features that shipped in v0.31. Sencho detects this automatically and disables features that the active node doesn't support — no errors, no broken pages.
## How it works
Every Sencho instance exposes a `/api/meta` endpoint that returns its version and a list of **capabilities** — feature flags describing what that instance supports. When you switch to a node, your primary instance fetches this metadata and caches it for 5 minutes.
Features that require a capability the active node doesn't have are visually dimmed with an explanation overlay. Core features like stack management, containers, and resource monitoring are always available on every Sencho version.
## What you'll see
### Version in the node switcher
The sidebar node switcher shows each node's Sencho version next to its name. If a node is too old to report its version (pre-compatibility era), no version is shown.
### Disabled features
When you switch to a node that lacks a capability, the affected feature appears dimmed with a pill overlay explaining why:
> **Fleet Management** is not available — prod-server is running v0.28.0
The feature content is still visible (blurred) so you can see what you're missing, but it's non-interactive. Switch to a node that supports the feature, or update the remote node to the latest Sencho version.
### Connection test details
When you test a remote node's connection in **Settings > Nodes**, the result now includes the remote Sencho version alongside Docker info, container counts, and system stats.
## Capability list
Every Sencho release includes a static list of capabilities. Here are the capabilities tracked as of the current version:
| Capability | Feature |
|-----------|---------|
| `stacks` | Stack management (always available) |
| `containers` | Container operations (always available) |
| `resources` | Resource monitoring (always available) |
| `templates` | App Store (always available) |
| `global-logs` | Global log viewer (always available) |
| `system-stats` | System statistics (always available) |
| `fleet` | Fleet management |
| `auto-updates` | Auto-update policies |
| `labels` | Stack labels |
| `webhooks` | Webhooks |
| `network-topology` | Network topology view |
| `notifications` | Alert notifications |
| `notification-routing` | Notification routing rules |
| `host-console` | Host console |
| `audit-log` | Audit log |
| `scheduled-ops` | Scheduled operations |
| `sso` | SSO authentication |
| `api-tokens` | API token management |
| `users` | User management |
| `registries` | Private registry management |
<Note>
Capabilities are flag-based, not version-based. Sencho never compares version numbers — it checks whether the remote node explicitly advertises support for a feature. This means the system stays forward-compatible as new features are added.
</Note>
## Handling older nodes
Nodes running a Sencho version from before the compatibility system was introduced (pre-v0.32) don't have the `/api/meta` endpoint. Sencho handles this gracefully:
- The version shows as blank in the node switcher
- All gated features appear as unavailable on that node
- Core features (stacks, containers, resources, logs) work normally
- No errors are thrown — the UI degrades cleanly
The fix is straightforward: update the remote node to the latest Sencho version. See the [upgrade guide](/operations/upgrade) for instructions.
## Interaction with license tiers
Some features require both a license tier (Skipper or Admiral) **and** node capability support. When both gates apply:
1. The license gate is checked first — if you're on the Community tier, you see the upgrade prompt
2. If your license covers the feature but the node doesn't support it, you see the compatibility overlay instead
This means you won't see confusing "upgrade to unlock" messages for features that wouldn't work on the active node anyway.
+60
View File
@@ -543,6 +543,33 @@ paths:
type: number
description: Process uptime in seconds.
/api/meta:
get:
operationId: getMeta
tags: [Health]
summary: Instance metadata
description: Returns this Sencho instance's version and list of supported capabilities. No authentication required. Used by nodes to negotiate feature compatibility.
security: []
responses:
"200":
description: Instance metadata.
content:
application/json:
schema:
type: object
required: [version, capabilities]
properties:
version:
type: string
description: Sencho version (semver).
example: "0.32.0"
capabilities:
type: array
description: List of feature capabilities this instance supports.
items:
type: string
example: ["stacks", "containers", "fleet", "auto-updates", "host-console"]
# ── Stacks ──────────────────────────────────────────────
/api/stacks:
get:
@@ -1767,6 +1794,39 @@ paths:
"500":
$ref: "#/components/responses/InternalError"
/api/nodes/{id}/meta:
get:
operationId: getNodeMeta
tags: [Nodes]
summary: Get node capabilities
description: Returns the version and supported capabilities for a specific node. For local nodes, returns this instance's own metadata. For remote nodes, relays the response from the remote instance's `/api/meta` endpoint. Returns `{ version: null, capabilities: [] }` if the remote node is offline or doesn't support the meta endpoint.
parameters:
- $ref: "#/components/parameters/idPath"
responses:
"200":
description: Node capability metadata.
content:
application/json:
schema:
type: object
required: [version, capabilities]
properties:
version:
type: string
nullable: true
description: Sencho version of the node, or null if unavailable.
example: "0.31.0"
capabilities:
type: array
description: List of feature capabilities the node supports. Empty array if unavailable.
items:
type: string
example: ["stacks", "containers", "fleet"]
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
# ── Fleet ───────────────────────────────────────────────
/api/fleet/overview:
get:
@@ -9,6 +9,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { AdmiralGate } from './AdmiralGate';
import { CapabilityGate } from './CapabilityGate';
import { TierBadge } from './TierBadge';
import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock } from 'lucide-react';
@@ -124,6 +125,7 @@ export function ApiTokensSection() {
return (
<AdmiralGate featureName="API Tokens">
<CapabilityGate capability="api-tokens" featureName="API Tokens">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
@@ -267,6 +269,7 @@ export function ApiTokensSection() {
</div>
))}
</div>
</CapabilityGate>
</AdmiralGate>
);
}
@@ -0,0 +1,35 @@
import { type ReactNode } from 'react';
import { Unplug } from 'lucide-react';
import { useNodes } from '@/context/NodeContext';
import type { Capability } from '@/lib/capabilities';
interface CapabilityGateProps {
capability: Capability;
featureName?: string;
children: ReactNode;
}
export function CapabilityGate({ capability, featureName = 'This feature', children }: CapabilityGateProps) {
const { hasCapability, activeNode, activeNodeMeta } = useNodes();
if (hasCapability(capability)) return <>{children}</>;
const nodeName = activeNode?.name ?? 'this node';
const versionHint = activeNodeMeta?.version
? `${nodeName} is running v${activeNodeMeta.version}`
: `${nodeName} does not support this capability`;
return (
<div className="relative">
<div className="opacity-40 pointer-events-none select-none blur-[2px]">
{children}
</div>
<div className="absolute inset-0 flex items-start justify-center pt-8">
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-muted/80 border border-border text-muted-foreground text-xs">
<Unplug className="w-3 h-3" strokeWidth={1.5} />
{featureName} is not available {versionHint}
</div>
</div>
</div>
);
}
+44 -25
View File
@@ -8,6 +8,7 @@ import HomeDashboard from './HomeDashboard';
import BashExecModal from './BashExecModal';
import HostConsole from './HostConsole';
import { AdmiralGate } from './AdmiralGate';
import { CapabilityGate } from './CapabilityGate';
import ResourcesView from './ResourcesView';
import { Button } from './ui/button';
import { Input } from './ui/input';
@@ -86,7 +87,7 @@ const formatBytes = (bytes: number) => {
export default function EditorLayout() {
const { isAdmin, can } = useAuth();
const { isPro, license } = useLicense();
const { nodes, activeNode, setActiveNode } = useNodes();
const { nodes, activeNode, setActiveNode, nodeMeta } = useNodes();
// Stable ref so notification callbacks always read the latest nodes list
// without needing nodes in their dependency arrays (which would cause loops).
const nodesRef = useRef<Node[]>([]);
@@ -1225,16 +1226,24 @@ export default function EditorLayout() {
</div>
</SelectTrigger>
<SelectContent>
{nodes.map(node => (
<SelectItem key={node.id} value={node.id.toString()}>
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full shrink-0 ${node.status === 'online' ? 'bg-success' :
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
}`} />
{node.name}
</div>
</SelectItem>
))}
{nodes.map(node => {
const meta = nodeMeta.get(node.id);
return (
<SelectItem key={node.id} value={node.id.toString()}>
<div className="flex items-center gap-2 w-full">
<div className={`w-2 h-2 rounded-full shrink-0 ${node.status === 'online' ? 'bg-success' :
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
}`} />
<span>{node.name}</span>
{meta?.version && (
<span className="font-mono text-[10px] tabular-nums text-muted-foreground/60 ml-auto">
v{meta.version}
</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
@@ -1715,7 +1724,9 @@ export default function EditorLayout() {
<ResourcesView />
) : activeView === 'host-console' ? (
<AdmiralGate featureName="Host Console">
<HostConsole stackName={selectedFile} onClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')} />
<CapabilityGate capability="host-console" featureName="Host Console">
<HostConsole stackName={selectedFile} onClose={() => setActiveView(selectedFile ? 'editor' : 'dashboard')} />
</CapabilityGate>
</AdmiralGate>
) : !isLoading && selectedFile && activeView === 'editor' ? (
<ErrorBoundary>
@@ -2020,23 +2031,31 @@ export default function EditorLayout() {
) : activeView === 'global-observability' ? (
<GlobalObservabilityView />
) : activeView === 'fleet' ? (
<FleetView onNavigateToNode={(nodeId, stackName) => {
const node = nodes.find(n => n.id === nodeId);
if (node) {
if (activeNode?.id === nodeId) {
loadFile(stackName);
} else {
pendingStackLoadRef.current = stackName;
setActiveNode(node);
<CapabilityGate capability="fleet" featureName="Fleet Management">
<FleetView onNavigateToNode={(nodeId, stackName) => {
const node = nodes.find(n => n.id === nodeId);
if (node) {
if (activeNode?.id === nodeId) {
loadFile(stackName);
} else {
pendingStackLoadRef.current = stackName;
setActiveNode(node);
}
}
}
}} />
}} />
</CapabilityGate>
) : activeView === 'audit-log' ? (
<AuditLogView />
<CapabilityGate capability="audit-log" featureName="Audit Log">
<AuditLogView />
</CapabilityGate>
) : activeView === 'auto-updates' ? (
<AutoUpdatePoliciesView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
<CapabilityGate capability="auto-updates" featureName="Auto-Update Policies">
<AutoUpdatePoliciesView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
</CapabilityGate>
) : activeView === 'scheduled-ops' ? (
<ScheduledOperationsView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
<ScheduledOperationsView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
</CapabilityGate>
) : (
<HomeDashboard />
)}
+4 -1
View File
@@ -65,7 +65,7 @@ export function NodeManager() {
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
const [deletingNode, setDeletingNode] = useState<Node | null>(null);
const [testing, setTesting] = useState<number | null>(null);
const [testResult, setTestResult] = useState<{ nodeId: number; info: { serverVersion?: string; os?: string; architecture?: string; containers?: number; images?: number; cpus?: number } } | null>(null);
const [testResult, setTestResult] = useState<{ nodeId: number; info: { serverVersion?: string; senchoVersion?: string; os?: string; architecture?: string; containers?: number; images?: number; cpus?: number } } | null>(null);
// Node token generation state
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
@@ -624,6 +624,9 @@ export function NodeManager() {
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
<div><span className="text-muted-foreground">Instance:</span> {testResult.info.serverVersion}</div>
{testResult.info.senchoVersion && (
<div><span className="text-muted-foreground">Sencho:</span> <span className="font-mono tabular-nums">v{testResult.info.senchoVersion}</span></div>
)}
<div><span className="text-muted-foreground">OS:</span> {testResult.info.os}</div>
<div><span className="text-muted-foreground">Arch:</span> {testResult.info.architecture}</div>
<div><span className="text-muted-foreground">Containers:</span> {testResult.info.containers}</div>
@@ -9,6 +9,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { AdmiralGate } from './AdmiralGate';
import { CapabilityGate } from './CapabilityGate';
import { TierBadge } from './TierBadge';
import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock } from 'lucide-react';
@@ -206,6 +207,7 @@ export function RegistriesSection() {
return (
<AdmiralGate featureName="Private Registry Management">
<CapabilityGate capability="registries" featureName="Private Registries">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
@@ -379,6 +381,7 @@ export function RegistriesSection() {
</div>
))}
</div>
</CapabilityGate>
</AdmiralGate>
);
}
+10 -7
View File
@@ -21,6 +21,7 @@ import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { ProGate } from './ProGate';
import { CapabilityGate } from './CapabilityGate';
import { formatBytes } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { lazy, Suspense } from 'react';
@@ -813,13 +814,15 @@ export default function ResourcesView() {
{networkViewMode === 'topology' ? (
<div className="p-4">
<ProGate featureName="Network Topology">
<Suspense fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
<span className="text-sm">Loading topology...</span>
</div>
}>
<NetworkTopologyView />
</Suspense>
<CapabilityGate capability="network-topology" featureName="Network Topology">
<Suspense fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
<span className="text-sm">Loading topology...</span>
</div>
}>
<NetworkTopologyView />
</Suspense>
</CapabilityGate>
</ProGate>
</div>
) : (
+3
View File
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { AdmiralGate } from './AdmiralGate';
import { CapabilityGate } from './CapabilityGate';
import { TierBadge } from './TierBadge';
import { Shield, Loader2, CheckCircle, XCircle } from 'lucide-react';
@@ -331,6 +332,7 @@ export function SSOSection() {
return (
<AdmiralGate featureName="SSO Authentication">
<CapabilityGate capability="sso" featureName="SSO Authentication">
<div className="space-y-6">
<div>
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
@@ -361,6 +363,7 @@ export function SSOSection() {
<p>For OIDC providers, set the OAuth callback URL to: <code className="bg-muted px-1 rounded">{'https://<your-sencho-url>/api/auth/sso/oidc/<provider>/callback'}</code></p>
</div>
</div>
</CapabilityGate>
</AdmiralGate>
);
}
@@ -22,6 +22,7 @@ import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { ProGate } from '../ProGate';
import { CapabilityGate } from '../CapabilityGate';
import { LabelDot, type Label, type LabelColor } from '../LabelPill';
const LABEL_COLORS: LabelColor[] = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'];
@@ -123,6 +124,7 @@ export function LabelsSection() {
return (
<ProGate featureName="Stack Labels">
<CapabilityGate capability="labels" featureName="Stack Labels">
<div className="space-y-4">
<div className="flex items-center justify-between pr-8">
<div>
@@ -233,6 +235,7 @@ export function LabelsSection() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CapabilityGate>
</ProGate>
);
}
@@ -29,6 +29,7 @@ import {
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { AdmiralGate } from '@/components/AdmiralGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import { TierBadge } from '@/components/TierBadge';
import { Plus, Trash2, Pencil, RefreshCw, Zap, X, GitBranch } from 'lucide-react';
@@ -230,6 +231,7 @@ export function NotificationRoutingSection() {
return (
<AdmiralGate featureName="Notification Routing">
<CapabilityGate capability="notification-routing" featureName="Notification Routing">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
@@ -452,6 +454,7 @@ export function NotificationRoutingSection() {
</div>
))}
</div>
</CapabilityGate>
</AdmiralGate>
);
}
@@ -14,6 +14,7 @@ import { apiFetch } from '@/lib/api';
import { useAuth, type UserRole } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { ProGate } from '@/components/ProGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import { RefreshCw, Trash2, Plus, Pencil } from 'lucide-react';
interface UserItem {
@@ -230,6 +231,7 @@ export function UsersSection() {
return (
<ProGate featureName="User management">
<CapabilityGate capability="users" featureName="User Management">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
@@ -452,6 +454,7 @@ export function UsersSection() {
</div>
)}
</div>
</CapabilityGate>
</ProGate>
);
}
@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { ProGate } from '@/components/ProGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import { TierBadge } from '@/components/TierBadge';
import {
RefreshCw, CheckCircle, XCircle, Webhook, Copy, Trash2,
@@ -141,10 +142,12 @@ export function WebhooksSection({ isPro }: { isPro: boolean }) {
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
</div>
<ProGate featureName="Webhooks">
<CapabilityGate capability="webhooks" featureName="Webhooks">
<div className="space-y-3">
<div className="h-16 rounded-lg border bg-card" />
<div className="h-16 rounded-lg border bg-card" />
</div>
</CapabilityGate>
</ProGate>
</div>
);
+72 -5
View File
@@ -1,5 +1,6 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import React, { createContext, useContext, useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { apiFetch } from '@/lib/api';
import type { Capability } from '@/lib/capabilities';
export interface Node {
id: number;
@@ -13,23 +14,66 @@ export interface Node {
api_token?: string;
}
export interface NodeMeta {
version: string | null;
capabilities: string[];
fetchedAt: number;
}
interface NodeContextType {
nodes: Node[];
activeNode: Node | null;
setActiveNode: (node: Node) => void;
refreshNodes: () => Promise<void>;
isLoading: boolean;
activeNodeMeta: NodeMeta | null;
hasCapability: (cap: Capability) => boolean;
nodeMeta: Map<number, NodeMeta>;
refreshNodeMeta: (nodeId: number) => Promise<void>;
}
const META_CACHE_TTL = 5 * 60 * 1000; // 5 minutes
const NodeContext = createContext<NodeContextType | undefined>(undefined);
export function NodeProvider({ children }: { children: React.ReactNode }) {
const [nodes, setNodes] = useState<Node[]>([]);
const [activeNode, setActiveNodeState] = useState<Node | null>(null);
const [isLoading, setIsLoading] = useState(true);
// Ref lets refreshNodes read current activeNode without being a dep (breaks infinite loop)
const [nodeMeta, setNodeMeta] = useState<Map<number, NodeMeta>>(new Map());
// Refs let callbacks read current state without being deps (breaks infinite loop)
const activeNodeRef = useRef<Node | null>(null);
activeNodeRef.current = activeNode;
const nodeMetaRef = useRef<Map<number, NodeMeta>>(nodeMeta);
nodeMetaRef.current = nodeMeta;
const fetchNodeMeta = useCallback(async (nodeId: number) => {
const cached = nodeMetaRef.current.get(nodeId);
if (cached && Date.now() - cached.fetchedAt < META_CACHE_TTL) return;
try {
const res = await apiFetch(`/nodes/${nodeId}/meta`, { localOnly: true });
if (res.ok) {
const data = await res.json();
setNodeMeta(prev => {
const next = new Map(prev);
next.set(nodeId, {
version: data.version ?? null,
capabilities: Array.isArray(data.capabilities) ? data.capabilities : [],
fetchedAt: Date.now(),
});
return next;
});
}
} catch {
setNodeMeta(prev => {
const next = new Map(prev);
next.set(nodeId, { version: null, capabilities: [], fetchedAt: Date.now() });
return next;
});
}
}, []);
const refreshNodes = useCallback(async () => {
try {
@@ -49,6 +93,7 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
if (nodeToActivate) {
setActiveNodeState(nodeToActivate);
localStorage.setItem('sencho-active-node', String(nodeToActivate.id));
fetchNodeMeta(nodeToActivate.id);
}
} else {
const updatedActive = data.find((n: Node) => n.id === currentActive.id);
@@ -60,6 +105,7 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
if (fallback) {
setActiveNodeState(fallback);
localStorage.setItem('sencho-active-node', String(fallback.id));
fetchNodeMeta(fallback.id);
} else {
setActiveNodeState(null);
localStorage.removeItem('sencho-active-node');
@@ -72,12 +118,13 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
} finally {
setIsLoading(false);
}
}, []); // stable - reads activeNode via ref, not closure capture
}, [fetchNodeMeta]); // stable - reads activeNode via ref, not closure capture
const setActiveNode = useCallback((node: Node) => {
setActiveNodeState(node);
localStorage.setItem('sencho-active-node', String(node.id));
}, []);
fetchNodeMeta(node.id);
}, [fetchNodeMeta]);
useEffect(() => {
refreshNodes();
@@ -91,8 +138,28 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
return () => window.removeEventListener('node-not-found', handleNodeNotFound);
}, [refreshNodes]);
const activeNodeMeta = useMemo(() => {
if (!activeNode) return null;
return nodeMeta.get(activeNode.id) ?? null;
}, [activeNode, nodeMeta]);
const hasCapability = useCallback((cap: Capability): boolean => {
const current = activeNodeRef.current;
if (!current) return true;
const meta = nodeMetaRef.current.get(current.id);
// Optimistic: if meta not yet fetched, assume capable (prevents flash of disabled UI)
if (!meta) return true;
return meta.capabilities.includes(cap);
}, []);
const contextValue = useMemo(() => ({
nodes, activeNode, setActiveNode, refreshNodes, isLoading,
activeNodeMeta, hasCapability, nodeMeta, refreshNodeMeta: fetchNodeMeta,
}), [nodes, activeNode, setActiveNode, refreshNodes, isLoading,
activeNodeMeta, hasCapability, nodeMeta, fetchNodeMeta]);
return (
<NodeContext.Provider value={{ nodes, activeNode, setActiveNode, refreshNodes, isLoading }}>
<NodeContext.Provider value={contextValue}>
{children}
</NodeContext.Provider>
);
+25
View File
@@ -0,0 +1,25 @@
/** Must stay in sync with backend/src/services/CapabilityRegistry.ts */
export const CAPABILITIES = [
'stacks',
'containers',
'resources',
'templates',
'global-logs',
'system-stats',
'fleet',
'auto-updates',
'labels',
'webhooks',
'network-topology',
'notifications',
'notification-routing',
'host-console',
'audit-log',
'scheduled-ops',
'sso',
'api-tokens',
'users',
'registries',
] as const;
export type Capability = (typeof CAPABILITIES)[number];