mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +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:
+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 ?? '-',
|
||||
|
||||
Reference in New Issue
Block a user