From ee75811e255e8d5f9ae87117d12c2902185d98f1 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 3 Apr 2026 00:06:34 -0400 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + backend/src/index.ts | 48 ++++++++++- backend/src/services/CapabilityRegistry.ts | 58 +++++++++++++ backend/src/services/NodeRegistry.ts | 7 +- docs/docs.json | 9 ++- docs/features/node-compatibility.mdx | 81 +++++++++++++++++++ docs/openapi.yaml | 60 ++++++++++++++ frontend/src/components/ApiTokensSection.tsx | 3 + frontend/src/components/CapabilityGate.tsx | 35 ++++++++ frontend/src/components/EditorLayout.tsx | 69 ++++++++++------ frontend/src/components/NodeManager.tsx | 5 +- frontend/src/components/RegistriesSection.tsx | 3 + frontend/src/components/ResourcesView.tsx | 17 ++-- frontend/src/components/SSOSection.tsx | 3 + .../src/components/settings/LabelsSection.tsx | 3 + .../settings/NotificationRoutingSection.tsx | 3 + .../src/components/settings/UsersSection.tsx | 3 + .../components/settings/WebhooksSection.tsx | 3 + frontend/src/context/NodeContext.tsx | 77 ++++++++++++++++-- frontend/src/lib/capabilities.ts | 25 ++++++ 20 files changed, 468 insertions(+), 45 deletions(-) create mode 100644 backend/src/services/CapabilityRegistry.ts create mode 100644 docs/features/node-compatibility.mdx create mode 100644 frontend/src/components/CapabilityGate.tsx create mode 100644 frontend/src/lib/capabilities.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a8bdbc43..7eaf8d41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/backend/src/index.ts b/backend/src/index.ts index 226da950..f41f6f66 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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({ // 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') { diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts new file mode 100644 index 00000000..53605435 --- /dev/null +++ b/backend/src/services/CapabilityRegistry.ts @@ -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 { + 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: [] }; + } +} diff --git a/backend/src/services/NodeRegistry.ts b/backend/src/services/NodeRegistry.ts index bb72ae71..4467e40b 100644 --- a/backend/src/services/NodeRegistry.ts +++ b/backend/src/services/NodeRegistry.ts @@ -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 ?? '-', diff --git a/docs/docs.json b/docs/docs.json index 4cecc4b5..08b25270 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -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" ] }, { diff --git a/docs/features/node-compatibility.mdx b/docs/features/node-compatibility.mdx new file mode 100644 index 00000000..f6d9580e --- /dev/null +++ b/docs/features/node-compatibility.mdx @@ -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 | + + + 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. + + +## 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. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 53165070..001664fd 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -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: diff --git a/frontend/src/components/ApiTokensSection.tsx b/frontend/src/components/ApiTokensSection.tsx index 972e768f..d963ee59 100644 --- a/frontend/src/components/ApiTokensSection.tsx +++ b/frontend/src/components/ApiTokensSection.tsx @@ -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 ( +
@@ -267,6 +269,7 @@ export function ApiTokensSection() {
))}
+ ); } diff --git a/frontend/src/components/CapabilityGate.tsx b/frontend/src/components/CapabilityGate.tsx new file mode 100644 index 00000000..0d7069cc --- /dev/null +++ b/frontend/src/components/CapabilityGate.tsx @@ -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 ( +
+
+ {children} +
+
+
+ + {featureName} is not available — {versionHint} +
+
+
+ ); +} diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 7eaaae92..a6425320 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -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([]); @@ -1225,16 +1226,24 @@ export default function EditorLayout() {
- {nodes.map(node => ( - -
-
- {node.name} -
- - ))} + {nodes.map(node => { + const meta = nodeMeta.get(node.id); + return ( + +
+
+ {node.name} + {meta?.version && ( + + v{meta.version} + + )} +
+ + ); + })}
@@ -1715,7 +1724,9 @@ export default function EditorLayout() { ) : activeView === 'host-console' ? ( - setActiveView(selectedFile ? 'editor' : 'dashboard')} /> + + setActiveView(selectedFile ? 'editor' : 'dashboard')} /> + ) : !isLoading && selectedFile && activeView === 'editor' ? ( @@ -2020,23 +2031,31 @@ export default function EditorLayout() { ) : activeView === 'global-observability' ? ( ) : activeView === 'fleet' ? ( - { - const node = nodes.find(n => n.id === nodeId); - if (node) { - if (activeNode?.id === nodeId) { - loadFile(stackName); - } else { - pendingStackLoadRef.current = stackName; - setActiveNode(node); + + { + const node = nodes.find(n => n.id === nodeId); + if (node) { + if (activeNode?.id === nodeId) { + loadFile(stackName); + } else { + pendingStackLoadRef.current = stackName; + setActiveNode(node); + } } - } - }} /> + }} /> + ) : activeView === 'audit-log' ? ( - + + + ) : activeView === 'auto-updates' ? ( - setFilterNodeId(null)} /> + + setFilterNodeId(null)} /> + ) : activeView === 'scheduled-ops' ? ( - setFilterNodeId(null)} /> + + setFilterNodeId(null)} /> + ) : ( )} diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 1c48de89..426fc753 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -65,7 +65,7 @@ export function NodeManager() { const [editingNodeId, setEditingNodeId] = useState(null); const [deletingNode, setDeletingNode] = useState(null); const [testing, setTesting] = useState(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(null); @@ -624,6 +624,9 @@ export function NodeManager() {
Instance: {testResult.info.serverVersion}
+ {testResult.info.senchoVersion && ( +
Sencho: v{testResult.info.senchoVersion}
+ )}
OS: {testResult.info.os}
Arch: {testResult.info.architecture}
Containers: {testResult.info.containers}
diff --git a/frontend/src/components/RegistriesSection.tsx b/frontend/src/components/RegistriesSection.tsx index b1c4236d..6e22860f 100644 --- a/frontend/src/components/RegistriesSection.tsx +++ b/frontend/src/components/RegistriesSection.tsx @@ -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 ( +
@@ -379,6 +381,7 @@ export function RegistriesSection() {
))}
+ ); } diff --git a/frontend/src/components/ResourcesView.tsx b/frontend/src/components/ResourcesView.tsx index dcc8fad8..3a495ca6 100644 --- a/frontend/src/components/ResourcesView.tsx +++ b/frontend/src/components/ResourcesView.tsx @@ -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' ? (
- - Loading topology... -
- }> - - + + + Loading topology... +
+ }> + + +
) : ( diff --git a/frontend/src/components/SSOSection.tsx b/frontend/src/components/SSOSection.tsx index 07b3467d..5b0b4ac3 100644 --- a/frontend/src/components/SSOSection.tsx +++ b/frontend/src/components/SSOSection.tsx @@ -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 ( +

@@ -361,6 +363,7 @@ export function SSOSection() {

For OIDC providers, set the OAuth callback URL to: {'https:///api/auth/sso/oidc//callback'}

+
); } diff --git a/frontend/src/components/settings/LabelsSection.tsx b/frontend/src/components/settings/LabelsSection.tsx index c78a678a..608fc09a 100644 --- a/frontend/src/components/settings/LabelsSection.tsx +++ b/frontend/src/components/settings/LabelsSection.tsx @@ -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 ( +
@@ -233,6 +235,7 @@ export function LabelsSection() { + ); } diff --git a/frontend/src/components/settings/NotificationRoutingSection.tsx b/frontend/src/components/settings/NotificationRoutingSection.tsx index f6e4799d..90c1a4b4 100644 --- a/frontend/src/components/settings/NotificationRoutingSection.tsx +++ b/frontend/src/components/settings/NotificationRoutingSection.tsx @@ -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 ( +
@@ -452,6 +454,7 @@ export function NotificationRoutingSection() {
))}
+ ); } diff --git a/frontend/src/components/settings/UsersSection.tsx b/frontend/src/components/settings/UsersSection.tsx index 36a53dc3..e9885ada 100644 --- a/frontend/src/components/settings/UsersSection.tsx +++ b/frontend/src/components/settings/UsersSection.tsx @@ -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 ( +
@@ -452,6 +454,7 @@ export function UsersSection() {
)}
+ ); } diff --git a/frontend/src/components/settings/WebhooksSection.tsx b/frontend/src/components/settings/WebhooksSection.tsx index 6c85e1e4..d399415f 100644 --- a/frontend/src/components/settings/WebhooksSection.tsx +++ b/frontend/src/components/settings/WebhooksSection.tsx @@ -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 }) {

Trigger stack actions from CI/CD pipelines via HTTP.

+
+
); diff --git a/frontend/src/context/NodeContext.tsx b/frontend/src/context/NodeContext.tsx index 1a297861..07634227 100644 --- a/frontend/src/context/NodeContext.tsx +++ b/frontend/src/context/NodeContext.tsx @@ -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; isLoading: boolean; + activeNodeMeta: NodeMeta | null; + hasCapability: (cap: Capability) => boolean; + nodeMeta: Map; + refreshNodeMeta: (nodeId: number) => Promise; } +const META_CACHE_TTL = 5 * 60 * 1000; // 5 minutes + const NodeContext = createContext(undefined); export function NodeProvider({ children }: { children: React.ReactNode }) { const [nodes, setNodes] = useState([]); const [activeNode, setActiveNodeState] = useState(null); const [isLoading, setIsLoading] = useState(true); - // Ref lets refreshNodes read current activeNode without being a dep (breaks infinite loop) + const [nodeMeta, setNodeMeta] = useState>(new Map()); + + // Refs let callbacks read current state without being deps (breaks infinite loop) const activeNodeRef = useRef(null); activeNodeRef.current = activeNode; + const nodeMetaRef = useRef>(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 ( - + {children} ); diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts new file mode 100644 index 00000000..1ecc09eb --- /dev/null +++ b/frontend/src/lib/capabilities.ts @@ -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];