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
+44 -25
View File
@@ -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 />
)}