mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-20 15:22: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:
@@ -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 />
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user