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
+72 -5
View File
@@ -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>
);