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
@@ -9,6 +9,7 @@ import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { AdmiralGate } from './AdmiralGate';
import { CapabilityGate } from './CapabilityGate';
import { TierBadge } from './TierBadge';
import { Zap, Plus, Copy, Trash2, CheckCircle, RefreshCw, Clock } from 'lucide-react';
@@ -124,6 +125,7 @@ export function ApiTokensSection() {
return (
<AdmiralGate featureName="API Tokens">
<CapabilityGate capability="api-tokens" featureName="API Tokens">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
@@ -267,6 +269,7 @@ export function ApiTokensSection() {
</div>
))}
</div>
</CapabilityGate>
</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>
);
}
+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 />
)}
+4 -1
View File
@@ -65,7 +65,7 @@ export function NodeManager() {
const [editingNodeId, setEditingNodeId] = useState<number | null>(null);
const [deletingNode, setDeletingNode] = useState<Node | 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
const [generatedToken, setGeneratedToken] = useState<string | null>(null);
@@ -624,6 +624,9 @@ export function NodeManager() {
</h3>
<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>
{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">Arch:</span> {testResult.info.architecture}</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 { apiFetch } from '@/lib/api';
import { AdmiralGate } from './AdmiralGate';
import { CapabilityGate } from './CapabilityGate';
import { TierBadge } from './TierBadge';
import { Database, Plus, Trash2, Pencil, RefreshCw, CheckCircle, XCircle, Clock } from 'lucide-react';
@@ -206,6 +207,7 @@ export function RegistriesSection() {
return (
<AdmiralGate featureName="Private Registry Management">
<CapabilityGate capability="registries" featureName="Private Registries">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
@@ -379,6 +381,7 @@ export function RegistriesSection() {
</div>
))}
</div>
</CapabilityGate>
</AdmiralGate>
);
}
+10 -7
View File
@@ -21,6 +21,7 @@ import { useNodes } from '@/context/NodeContext';
import { useAuth } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { ProGate } from './ProGate';
import { CapabilityGate } from './CapabilityGate';
import { formatBytes } from '@/lib/utils';
import { cn } from '@/lib/utils';
import { lazy, Suspense } from 'react';
@@ -813,13 +814,15 @@ export default function ResourcesView() {
{networkViewMode === 'topology' ? (
<div className="p-4">
<ProGate featureName="Network Topology">
<Suspense fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
<span className="text-sm">Loading topology...</span>
</div>
}>
<NetworkTopologyView />
</Suspense>
<CapabilityGate capability="network-topology" featureName="Network Topology">
<Suspense fallback={
<div className="flex items-center justify-center h-[400px] text-muted-foreground gap-2">
<span className="text-sm">Loading topology...</span>
</div>
}>
<NetworkTopologyView />
</Suspense>
</CapabilityGate>
</ProGate>
</div>
) : (
+3
View File
@@ -8,6 +8,7 @@ import { Badge } from '@/components/ui/badge';
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { AdmiralGate } from './AdmiralGate';
import { CapabilityGate } from './CapabilityGate';
import { TierBadge } from './TierBadge';
import { Shield, Loader2, CheckCircle, XCircle } from 'lucide-react';
@@ -331,6 +332,7 @@ export function SSOSection() {
return (
<AdmiralGate featureName="SSO Authentication">
<CapabilityGate capability="sso" featureName="SSO Authentication">
<div className="space-y-6">
<div>
<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>
</div>
</div>
</CapabilityGate>
</AdmiralGate>
);
}
@@ -22,6 +22,7 @@ import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
import { apiFetch } from '@/lib/api';
import { toast } from '@/components/ui/toast-store';
import { ProGate } from '../ProGate';
import { CapabilityGate } from '../CapabilityGate';
import { LabelDot, type Label, type LabelColor } from '../LabelPill';
const LABEL_COLORS: LabelColor[] = ['teal', 'blue', 'purple', 'rose', 'amber', 'green', 'orange', 'pink', 'cyan', 'slate'];
@@ -123,6 +124,7 @@ export function LabelsSection() {
return (
<ProGate featureName="Stack Labels">
<CapabilityGate capability="labels" featureName="Stack Labels">
<div className="space-y-4">
<div className="flex items-center justify-between pr-8">
<div>
@@ -233,6 +235,7 @@ export function LabelsSection() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</CapabilityGate>
</ProGate>
);
}
@@ -29,6 +29,7 @@ import {
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { AdmiralGate } from '@/components/AdmiralGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import { TierBadge } from '@/components/TierBadge';
import { Plus, Trash2, Pencil, RefreshCw, Zap, X, GitBranch } from 'lucide-react';
@@ -230,6 +231,7 @@ export function NotificationRoutingSection() {
return (
<AdmiralGate featureName="Notification Routing">
<CapabilityGate capability="notification-routing" featureName="Notification Routing">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
@@ -452,6 +454,7 @@ export function NotificationRoutingSection() {
</div>
))}
</div>
</CapabilityGate>
</AdmiralGate>
);
}
@@ -14,6 +14,7 @@ import { apiFetch } from '@/lib/api';
import { useAuth, type UserRole } from '@/context/AuthContext';
import { useLicense } from '@/context/LicenseContext';
import { ProGate } from '@/components/ProGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import { RefreshCw, Trash2, Plus, Pencil } from 'lucide-react';
interface UserItem {
@@ -230,6 +231,7 @@ export function UsersSection() {
return (
<ProGate featureName="User management">
<CapabilityGate capability="users" featureName="User Management">
<div className="space-y-6">
<div className="flex items-start justify-between pr-8">
<div>
@@ -452,6 +454,7 @@ export function UsersSection() {
</div>
)}
</div>
</CapabilityGate>
</ProGate>
);
}
@@ -9,6 +9,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@
import { toast } from '@/components/ui/toast-store';
import { apiFetch } from '@/lib/api';
import { ProGate } from '@/components/ProGate';
import { CapabilityGate } from '@/components/CapabilityGate';
import { TierBadge } from '@/components/TierBadge';
import {
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>
</div>
<ProGate featureName="Webhooks">
<CapabilityGate capability="webhooks" featureName="Webhooks">
<div className="space-y-3">
<div className="h-16 rounded-lg border bg-card" />
<div className="h-16 rounded-lg border bg-card" />
</div>
</CapabilityGate>
</ProGate>
</div>
);