mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 20:27:22 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/** Must stay in sync with backend/src/services/CapabilityRegistry.ts */
|
||||
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];
|
||||
Reference in New Issue
Block a user