mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-13 04:06:59 +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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user