mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +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
|
### 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.
|
* **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)
|
## [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 { SSOService } from './services/SSOService';
|
||||||
import { SchedulerService } from './services/SchedulerService';
|
import { SchedulerService } from './services/SchedulerService';
|
||||||
import { RegistryService } from './services/RegistryService';
|
import { RegistryService } from './services/RegistryService';
|
||||||
|
import { CAPABILITIES, getSenchoVersion, fetchRemoteMeta } from './services/CapabilityRegistry';
|
||||||
import { CronExpressionParser } from 'cron-parser';
|
import { CronExpressionParser } from 'cron-parser';
|
||||||
import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from './utils/validation';
|
import { isValidStackName, isValidRemoteUrl, isPathWithinBase } from './utils/validation';
|
||||||
import YAML from 'yaml';
|
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/nodes') &&
|
||||||
!req.path.startsWith('/api/license') &&
|
!req.path.startsWith('/api/license') &&
|
||||||
!req.path.startsWith('/api/fleet') &&
|
!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
|
// Preserve body stream for proxy piping
|
||||||
next();
|
next();
|
||||||
@@ -210,7 +212,8 @@ const nodeContextMiddleware = (req: Request, res: Response, next: NextFunction)
|
|||||||
!req.path.startsWith('/api/nodes') &&
|
!req.path.startsWith('/api/nodes') &&
|
||||||
!req.path.startsWith('/api/license') &&
|
!req.path.startsWith('/api/license') &&
|
||||||
!req.path.startsWith('/api/fleet') &&
|
!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);
|
const node = DatabaseService.getInstance().getNode(req.nodeId);
|
||||||
if (!node) {
|
if (!node) {
|
||||||
@@ -317,6 +320,12 @@ app.get('/api/health', (_req: Request, res: Response): void => {
|
|||||||
res.json({ status: 'ok', uptime: process.uptime() });
|
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)
|
// Auth Routes (no authentication required)
|
||||||
|
|
||||||
// Check if setup is needed
|
// 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
|
// 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.
|
// to the target Sencho instance. Node management and auth routes always execute locally.
|
||||||
app.use('/api/', (req: Request, res: Response, next: NextFunction): void => {
|
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();
|
next();
|
||||||
return;
|
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)
|
// Serve static files in production (for Docker deployment)
|
||||||
if (process.env.NODE_ENV === 'production') {
|
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 Docker from 'dockerode';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { DatabaseService, Node } from './DatabaseService';
|
import { DatabaseService, Node } from './DatabaseService';
|
||||||
|
import { fetchRemoteMeta } from './CapabilityRegistry';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NodeRegistry: Manages connections for multiple nodes.
|
* 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
|
// 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.
|
// 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/stats`, { headers, timeout: 8000 }),
|
||||||
axios.get(`${baseUrl}/api/system/stats`, { headers, timeout: 8000 }),
|
axios.get(`${baseUrl}/api/system/stats`, { headers, timeout: 8000 }),
|
||||||
axios.get(`${baseUrl}/api/system/images`, { 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 stats = statsResult.status === 'fulfilled' ? statsResult.value.data : null;
|
||||||
const sys = sysResult.status === 'fulfilled' ? sysResult.value.data : null;
|
const sys = sysResult.status === 'fulfilled' ? sysResult.value.data : null;
|
||||||
const images = imagesResult.status === 'fulfilled' ? imagesResult.value.data : null;
|
const images = imagesResult.status === 'fulfilled' ? imagesResult.value.data : null;
|
||||||
|
const meta = metaResult.status === 'fulfilled' ? metaResult.value : null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
info: {
|
info: {
|
||||||
name: node.name,
|
name: node.name,
|
||||||
serverVersion: 'Remote Sencho',
|
serverVersion: 'Remote Sencho',
|
||||||
|
senchoVersion: meta?.version ?? null,
|
||||||
|
capabilities: meta?.capabilities ?? [],
|
||||||
os: 'Remote',
|
os: 'Remote',
|
||||||
architecture: 'Remote',
|
architecture: 'Remote',
|
||||||
containers: stats?.total ?? '-',
|
containers: stats?.total ?? '-',
|
||||||
|
|||||||
+6
-3
@@ -102,6 +102,7 @@
|
|||||||
"features/global-observability",
|
"features/global-observability",
|
||||||
"features/host-console",
|
"features/host-console",
|
||||||
"features/multi-node",
|
"features/multi-node",
|
||||||
|
"features/node-compatibility",
|
||||||
"features/fleet-view",
|
"features/fleet-view",
|
||||||
"features/stack-labels",
|
"features/stack-labels",
|
||||||
"features/alerts-notifications",
|
"features/alerts-notifications",
|
||||||
@@ -145,9 +146,10 @@
|
|||||||
"pages": [
|
"pages": [
|
||||||
"api-reference/overview",
|
"api-reference/overview",
|
||||||
{
|
{
|
||||||
"group": "Health",
|
"group": "Health & Meta",
|
||||||
"pages": [
|
"pages": [
|
||||||
"GET /api/health"
|
"GET /api/health",
|
||||||
|
"GET /api/meta"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -210,7 +212,8 @@
|
|||||||
"GET /api/nodes/{id}",
|
"GET /api/nodes/{id}",
|
||||||
"PUT /api/nodes/{id}",
|
"PUT /api/nodes/{id}",
|
||||||
"DELETE /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
|
type: number
|
||||||
description: Process uptime in seconds.
|
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 ──────────────────────────────────────────────
|
# ── Stacks ──────────────────────────────────────────────
|
||||||
/api/stacks:
|
/api/stacks:
|
||||||
get:
|
get:
|
||||||
@@ -1767,6 +1794,39 @@ paths:
|
|||||||
"500":
|
"500":
|
||||||
$ref: "#/components/responses/InternalError"
|
$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 ───────────────────────────────────────────────
|
# ── Fleet ───────────────────────────────────────────────
|
||||||
/api/fleet/overview:
|
/api/fleet/overview:
|
||||||
get:
|
get:
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
|
|||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { AdmiralGate } from './AdmiralGate';
|
import { AdmiralGate } from './AdmiralGate';
|
||||||
|
import { CapabilityGate } from './CapabilityGate';
|
||||||
import { TierBadge } from './TierBadge';
|
import { TierBadge } from './TierBadge';
|
||||||
import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock } from 'lucide-react';
|
import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock } from 'lucide-react';
|
||||||
|
|
||||||
@@ -124,6 +125,7 @@ export function ApiTokensSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdmiralGate featureName="API Tokens">
|
<AdmiralGate featureName="API Tokens">
|
||||||
|
<CapabilityGate capability="api-tokens" featureName="API Tokens">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start justify-between pr-8">
|
<div className="flex items-start justify-between pr-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -267,6 +269,7 @@ export function ApiTokensSection() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</CapabilityGate>
|
||||||
</AdmiralGate>
|
</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 BashExecModal from './BashExecModal';
|
||||||
import HostConsole from './HostConsole';
|
import HostConsole from './HostConsole';
|
||||||
import { AdmiralGate } from './AdmiralGate';
|
import { AdmiralGate } from './AdmiralGate';
|
||||||
|
import { CapabilityGate } from './CapabilityGate';
|
||||||
import ResourcesView from './ResourcesView';
|
import ResourcesView from './ResourcesView';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
import { Input } from './ui/input';
|
import { Input } from './ui/input';
|
||||||
@@ -86,7 +87,7 @@ const formatBytes = (bytes: number) => {
|
|||||||
export default function EditorLayout() {
|
export default function EditorLayout() {
|
||||||
const { isAdmin, can } = useAuth();
|
const { isAdmin, can } = useAuth();
|
||||||
const { isPro, license } = useLicense();
|
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
|
// Stable ref so notification callbacks always read the latest nodes list
|
||||||
// without needing nodes in their dependency arrays (which would cause loops).
|
// without needing nodes in their dependency arrays (which would cause loops).
|
||||||
const nodesRef = useRef<Node[]>([]);
|
const nodesRef = useRef<Node[]>([]);
|
||||||
@@ -1225,16 +1226,24 @@ export default function EditorLayout() {
|
|||||||
</div>
|
</div>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{nodes.map(node => (
|
{nodes.map(node => {
|
||||||
<SelectItem key={node.id} value={node.id.toString()}>
|
const meta = nodeMeta.get(node.id);
|
||||||
<div className="flex items-center gap-2">
|
return (
|
||||||
<div className={`w-2 h-2 rounded-full shrink-0 ${node.status === 'online' ? 'bg-success' :
|
<SelectItem key={node.id} value={node.id.toString()}>
|
||||||
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
<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.name}
|
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||||||
</div>
|
}`} />
|
||||||
</SelectItem>
|
<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>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
@@ -1715,7 +1724,9 @@ export default function EditorLayout() {
|
|||||||
<ResourcesView />
|
<ResourcesView />
|
||||||
) : activeView === 'host-console' ? (
|
) : activeView === 'host-console' ? (
|
||||||
<AdmiralGate featureName="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>
|
</AdmiralGate>
|
||||||
) : !isLoading && selectedFile && activeView === 'editor' ? (
|
) : !isLoading && selectedFile && activeView === 'editor' ? (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
@@ -2020,23 +2031,31 @@ export default function EditorLayout() {
|
|||||||
) : activeView === 'global-observability' ? (
|
) : activeView === 'global-observability' ? (
|
||||||
<GlobalObservabilityView />
|
<GlobalObservabilityView />
|
||||||
) : activeView === 'fleet' ? (
|
) : activeView === 'fleet' ? (
|
||||||
<FleetView onNavigateToNode={(nodeId, stackName) => {
|
<CapabilityGate capability="fleet" featureName="Fleet Management">
|
||||||
const node = nodes.find(n => n.id === nodeId);
|
<FleetView onNavigateToNode={(nodeId, stackName) => {
|
||||||
if (node) {
|
const node = nodes.find(n => n.id === nodeId);
|
||||||
if (activeNode?.id === nodeId) {
|
if (node) {
|
||||||
loadFile(stackName);
|
if (activeNode?.id === nodeId) {
|
||||||
} else {
|
loadFile(stackName);
|
||||||
pendingStackLoadRef.current = stackName;
|
} else {
|
||||||
setActiveNode(node);
|
pendingStackLoadRef.current = stackName;
|
||||||
|
setActiveNode(node);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}} />
|
||||||
}} />
|
</CapabilityGate>
|
||||||
) : activeView === 'audit-log' ? (
|
) : activeView === 'audit-log' ? (
|
||||||
<AuditLogView />
|
<CapabilityGate capability="audit-log" featureName="Audit Log">
|
||||||
|
<AuditLogView />
|
||||||
|
</CapabilityGate>
|
||||||
) : activeView === 'auto-updates' ? (
|
) : 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' ? (
|
) : activeView === 'scheduled-ops' ? (
|
||||||
<ScheduledOperationsView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
|
<CapabilityGate capability="scheduled-ops" featureName="Scheduled Operations">
|
||||||
|
<ScheduledOperationsView filterNodeId={filterNodeId} onClearFilter={() => setFilterNodeId(null)} />
|
||||||
|
</CapabilityGate>
|
||||||
) : (
|
) : (
|
||||||
<HomeDashboard />
|
<HomeDashboard />
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ export function NodeManager() {
|
|||||||
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
|
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
|
||||||
const [deletingNode, setDeletingNode] = useState<Node | null>(null);
|
const [deletingNode, setDeletingNode] = useState<Node | null>(null);
|
||||||
const [testing, setTesting] = useState<number | 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
|
// Node token generation state
|
||||||
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
|
||||||
@@ -624,6 +624,9 @@ export function NodeManager() {
|
|||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 text-sm">
|
<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>
|
<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">OS:</span> {testResult.info.os}</div>
|
||||||
<div><span className="text-muted-foreground">Arch:</span> {testResult.info.architecture}</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>
|
<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 { toast } from '@/components/ui/toast-store';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { AdmiralGate } from './AdmiralGate';
|
import { AdmiralGate } from './AdmiralGate';
|
||||||
|
import { CapabilityGate } from './CapabilityGate';
|
||||||
import { TierBadge } from './TierBadge';
|
import { TierBadge } from './TierBadge';
|
||||||
import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock } from 'lucide-react';
|
import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||||
|
|
||||||
@@ -206,6 +207,7 @@ export function RegistriesSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdmiralGate featureName="Private Registry Management">
|
<AdmiralGate featureName="Private Registry Management">
|
||||||
|
<CapabilityGate capability="registries" featureName="Private Registries">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start justify-between pr-8">
|
<div className="flex items-start justify-between pr-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -379,6 +381,7 @@ export function RegistriesSection() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</CapabilityGate>
|
||||||
</AdmiralGate>
|
</AdmiralGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { useNodes } from '@/context/NodeContext';
|
|||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { useLicense } from '@/context/LicenseContext';
|
import { useLicense } from '@/context/LicenseContext';
|
||||||
import { ProGate } from './ProGate';
|
import { ProGate } from './ProGate';
|
||||||
|
import { CapabilityGate } from './CapabilityGate';
|
||||||
import { formatBytes } from '@/lib/utils';
|
import { formatBytes } from '@/lib/utils';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { lazy, Suspense } from 'react';
|
import { lazy, Suspense } from 'react';
|
||||||
@@ -813,13 +814,15 @@ export default function ResourcesView() {
|
|||||||
{networkViewMode === 'topology' ? (
|
{networkViewMode === 'topology' ? (
|
||||||
<div className="p-4">
|
<div className="p-4">
|
||||||
<ProGate featureName="Network Topology">
|
<ProGate featureName="Network Topology">
|
||||||
<Suspense fallback={
|
<CapabilityGate capability="network-topology" featureName="Network Topology">
|
||||||
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
|
<Suspense fallback={
|
||||||
<span className="text-sm">Loading topology...</span>
|
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
|
||||||
</div>
|
<span className="text-sm">Loading topology...</span>
|
||||||
}>
|
</div>
|
||||||
<NetworkTopologyView />
|
}>
|
||||||
</Suspense>
|
<NetworkTopologyView />
|
||||||
|
</Suspense>
|
||||||
|
</CapabilityGate>
|
||||||
</ProGate>
|
</ProGate>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { AdmiralGate } from './AdmiralGate';
|
import { AdmiralGate } from './AdmiralGate';
|
||||||
|
import { CapabilityGate } from './CapabilityGate';
|
||||||
import { TierBadge } from './TierBadge';
|
import { TierBadge } from './TierBadge';
|
||||||
import { Shield, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
import { Shield, Loader2, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
|
||||||
@@ -331,6 +332,7 @@ export function SSOSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdmiralGate featureName="SSO Authentication">
|
<AdmiralGate featureName="SSO Authentication">
|
||||||
|
<CapabilityGate capability="sso" featureName="SSO Authentication">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium tracking-tight flex items-center gap-2">
|
<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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
</CapabilityGate>
|
||||||
</AdmiralGate>
|
</AdmiralGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
|||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { ProGate } from '../ProGate';
|
import { ProGate } from '../ProGate';
|
||||||
|
import { CapabilityGate } from '../CapabilityGate';
|
||||||
import { LabelDot, type Label, type LabelColor } from '../LabelPill';
|
import { LabelDot, type Label, type LabelColor } from '../LabelPill';
|
||||||
|
|
||||||
const LABEL_COLORS: LabelColor[] = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'];
|
const LABEL_COLORS: LabelColor[] = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'];
|
||||||
@@ -123,6 +124,7 @@ export function LabelsSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ProGate featureName="Stack Labels">
|
<ProGate featureName="Stack Labels">
|
||||||
|
<CapabilityGate capability="labels" featureName="Stack Labels">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between pr-8">
|
<div className="flex items-center justify-between pr-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -233,6 +235,7 @@ export function LabelsSection() {
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
</CapabilityGate>
|
||||||
</ProGate>
|
</ProGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { AdmiralGate } from '@/components/AdmiralGate';
|
import { AdmiralGate } from '@/components/AdmiralGate';
|
||||||
|
import { CapabilityGate } from '@/components/CapabilityGate';
|
||||||
import { TierBadge } from '@/components/TierBadge';
|
import { TierBadge } from '@/components/TierBadge';
|
||||||
import { Plus, Trash2, Pencil, RefreshCw, Zap, X, GitBranch } from 'lucide-react';
|
import { Plus, Trash2, Pencil, RefreshCw, Zap, X, GitBranch } from 'lucide-react';
|
||||||
|
|
||||||
@@ -230,6 +231,7 @@ export function NotificationRoutingSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<AdmiralGate featureName="Notification Routing">
|
<AdmiralGate featureName="Notification Routing">
|
||||||
|
<CapabilityGate capability="notification-routing" featureName="Notification Routing">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start justify-between pr-8">
|
<div className="flex items-start justify-between pr-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -452,6 +454,7 @@ export function NotificationRoutingSection() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</CapabilityGate>
|
||||||
</AdmiralGate>
|
</AdmiralGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { apiFetch } from '@/lib/api';
|
|||||||
import { useAuth, type UserRole } from '@/context/AuthContext';
|
import { useAuth, type UserRole } from '@/context/AuthContext';
|
||||||
import { useLicense } from '@/context/LicenseContext';
|
import { useLicense } from '@/context/LicenseContext';
|
||||||
import { ProGate } from '@/components/ProGate';
|
import { ProGate } from '@/components/ProGate';
|
||||||
|
import { CapabilityGate } from '@/components/CapabilityGate';
|
||||||
import { RefreshCw, Trash2, Plus, Pencil } from 'lucide-react';
|
import { RefreshCw, Trash2, Plus, Pencil } from 'lucide-react';
|
||||||
|
|
||||||
interface UserItem {
|
interface UserItem {
|
||||||
@@ -230,6 +231,7 @@ export function UsersSection() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<ProGate featureName="User management">
|
<ProGate featureName="User management">
|
||||||
|
<CapabilityGate capability="users" featureName="User Management">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-start justify-between pr-8">
|
<div className="flex items-start justify-between pr-8">
|
||||||
<div>
|
<div>
|
||||||
@@ -452,6 +454,7 @@ export function UsersSection() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</CapabilityGate>
|
||||||
</ProGate>
|
</ProGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
|
|||||||
import { toast } from '@/components/ui/toast-store';
|
import { toast } from '@/components/ui/toast-store';
|
||||||
import { apiFetch } from '@/lib/api';
|
import { apiFetch } from '@/lib/api';
|
||||||
import { ProGate } from '@/components/ProGate';
|
import { ProGate } from '@/components/ProGate';
|
||||||
|
import { CapabilityGate } from '@/components/CapabilityGate';
|
||||||
import { TierBadge } from '@/components/TierBadge';
|
import { TierBadge } from '@/components/TierBadge';
|
||||||
import {
|
import {
|
||||||
RefreshCw, CheckCircle, XCircle, Webhook, Copy, Trash2,
|
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>
|
<p className="text-sm text-muted-foreground">Trigger stack actions from CI/CD pipelines via HTTP.</p>
|
||||||
</div>
|
</div>
|
||||||
<ProGate featureName="Webhooks">
|
<ProGate featureName="Webhooks">
|
||||||
|
<CapabilityGate capability="webhooks" featureName="Webhooks">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="h-16 rounded-lg border bg-card" />
|
<div className="h-16 rounded-lg border bg-card" />
|
||||||
<div className="h-16 rounded-lg border bg-card" />
|
<div className="h-16 rounded-lg border bg-card" />
|
||||||
</div>
|
</div>
|
||||||
|
</CapabilityGate>
|
||||||
</ProGate>
|
</ProGate>
|
||||||
</div>
|
</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 { apiFetch } from '@/lib/api';
|
||||||
|
import type { Capability } from '@/lib/capabilities';
|
||||||
|
|
||||||
export interface Node {
|
export interface Node {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -13,23 +14,66 @@ export interface Node {
|
|||||||
api_token?: string;
|
api_token?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NodeMeta {
|
||||||
|
version: string | null;
|
||||||
|
capabilities: string[];
|
||||||
|
fetchedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
interface NodeContextType {
|
interface NodeContextType {
|
||||||
nodes: Node[];
|
nodes: Node[];
|
||||||
activeNode: Node | null;
|
activeNode: Node | null;
|
||||||
setActiveNode: (node: Node) => void;
|
setActiveNode: (node: Node) => void;
|
||||||
refreshNodes: () => Promise<void>;
|
refreshNodes: () => Promise<void>;
|
||||||
isLoading: boolean;
|
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);
|
const NodeContext = createContext<NodeContextType | undefined>(undefined);
|
||||||
|
|
||||||
export function NodeProvider({ children }: { children: React.ReactNode }) {
|
export function NodeProvider({ children }: { children: React.ReactNode }) {
|
||||||
const [nodes, setNodes] = useState<Node[]>([]);
|
const [nodes, setNodes] = useState<Node[]>([]);
|
||||||
const [activeNode, setActiveNodeState] = useState<Node | null>(null);
|
const [activeNode, setActiveNodeState] = useState<Node | null>(null);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
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);
|
const activeNodeRef = useRef<Node | null>(null);
|
||||||
activeNodeRef.current = activeNode;
|
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 () => {
|
const refreshNodes = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -49,6 +93,7 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (nodeToActivate) {
|
if (nodeToActivate) {
|
||||||
setActiveNodeState(nodeToActivate);
|
setActiveNodeState(nodeToActivate);
|
||||||
localStorage.setItem('sencho-active-node', String(nodeToActivate.id));
|
localStorage.setItem('sencho-active-node', String(nodeToActivate.id));
|
||||||
|
fetchNodeMeta(nodeToActivate.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const updatedActive = data.find((n: Node) => n.id === currentActive.id);
|
const updatedActive = data.find((n: Node) => n.id === currentActive.id);
|
||||||
@@ -60,6 +105,7 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
if (fallback) {
|
if (fallback) {
|
||||||
setActiveNodeState(fallback);
|
setActiveNodeState(fallback);
|
||||||
localStorage.setItem('sencho-active-node', String(fallback.id));
|
localStorage.setItem('sencho-active-node', String(fallback.id));
|
||||||
|
fetchNodeMeta(fallback.id);
|
||||||
} else {
|
} else {
|
||||||
setActiveNodeState(null);
|
setActiveNodeState(null);
|
||||||
localStorage.removeItem('sencho-active-node');
|
localStorage.removeItem('sencho-active-node');
|
||||||
@@ -72,12 +118,13 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
}, []); // stable - reads activeNode via ref, not closure capture
|
}, [fetchNodeMeta]); // stable - reads activeNode via ref, not closure capture
|
||||||
|
|
||||||
const setActiveNode = useCallback((node: Node) => {
|
const setActiveNode = useCallback((node: Node) => {
|
||||||
setActiveNodeState(node);
|
setActiveNodeState(node);
|
||||||
localStorage.setItem('sencho-active-node', String(node.id));
|
localStorage.setItem('sencho-active-node', String(node.id));
|
||||||
}, []);
|
fetchNodeMeta(node.id);
|
||||||
|
}, [fetchNodeMeta]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refreshNodes();
|
refreshNodes();
|
||||||
@@ -91,8 +138,28 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
|
|||||||
return () => window.removeEventListener('node-not-found', handleNodeNotFound);
|
return () => window.removeEventListener('node-not-found', handleNodeNotFound);
|
||||||
}, [refreshNodes]);
|
}, [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 (
|
return (
|
||||||
<NodeContext.Provider value={{ nodes, activeNode, setActiveNode, refreshNodes, isLoading }}>
|
<NodeContext.Provider value={contextValue}>
|
||||||
{children}
|
{children}
|
||||||
</NodeContext.Provider>
|
</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