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

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

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

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

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

The static import fails in the Docker multi-stage build because the
root package.json is not copied into the backend-builder stage. The
require() call resolves at runtime when the file is available.
This commit is contained in:
Anso
2026-04-03 00:06:34 -04:00
committed by GitHub
parent ec23f8c4c2
commit ee75811e25
20 changed files with 468 additions and 45 deletions
@@ -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: [] };
}
}