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