mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-05 16:37:46 +00:00
feat(fleet): add Node details sheet to the node card kebab (#1752)
* feat(fleet): add Node details sheet to the node card kebab Every Fleet node card now carries a "Node details" kebab item, open to any role that can see the card (previously the kebab only rendered for users with node-manage permissions, so plain viewers had none). The sheet shows connectivity, live capacity, Compose workload, version/capability compatibility, and governance info (labels, cordon reason and date, default-node flag, Compose directory, registration date) using data the Fleet page already fetches, plus one lazy call to the existing node meta endpoint for capabilities. Wired into both the desktop card and the mobile bespoke Fleet screen. * fix(fleet): correct Node details sheet timestamp units and update-status fallback QA against a live 3-node fleet found that last_successful_contact and pilot_last_seen come back from the fleet-overview endpoint in Unix seconds, but the sheet passed them straight into a milliseconds-only formatter, rendering values like "20647d ago" instead of "just now". Both are now converted before formatting. The Compatibility section's update-status badge also fell through to a confident "Up to date" whenever updateStatus was absent (e.g. on mobile, which doesn't poll update status) instead of reflecting that there was no data to back the claim; it now renders "Unknown" in that case. The local node no longer shows a misleading "Last successful contact: Never". Reworded the "read-only sheet" language in the docs page to describe the sheet accurately, since the Governance section's label picker stays editable for node managers by design.
This commit is contained in:
@@ -105,18 +105,19 @@ Every node renders as a card. The local node is pinned at the top of the grid wi
|
||||
|
||||
Offline nodes render dimmed, with no stats grid, no usage bars, and no update affordance.
|
||||
|
||||
### Node actions menu (admin)
|
||||
### Node actions menu
|
||||
|
||||
Every card carries a three-dot **Node actions** kebab in the top-right corner. The menu surfaces the same lifecycle actions you would find in **Settings · Infrastructure · Nodes**:
|
||||
Every card carries a three-dot **Node actions** kebab in the top-right corner:
|
||||
|
||||
| Action | Notes |
|
||||
|--------|-------|
|
||||
| **Node details** | Opens an info sheet with the node's connectivity, live capacity, Compose workload, version and update compatibility, and governance info (labels, cordon reason and date, default-node status, Compose directory, registration date). Available to anyone who can see the card; the label picker inside the sheet stays editable only for whoever holds `node:manage` on that node. |
|
||||
| **Edit node** | Opens the Edit dialog prefilled with the node's connection details. For proxy-mode remotes, saving with a changed API URL or token re-runs the connection test automatically. |
|
||||
| **Delete node** | Opens a destructive confirmation. The local (default) node has no Delete option. Deleting a remote only removes it from this console; the remote instance and its containers are untouched. |
|
||||
| **Cordon node** / **Uncordon node** | Marks the node unschedulable so new blueprint deployments skip it. Existing deployments keep running. Requires the `node:manage` permission (admin, or node-admin when scoped to that node). |
|
||||
| **Mute** submenu | Mute node notifications, mute update notifications, mute monitor alerts for this node, or open the full mute-rule manager. Shown to whoever can manage mute rules for the node. See [Alerts & Notifications](/features/alerts-notifications). |
|
||||
|
||||
Edit and delete remain admin-only. Users without `node:manage`, without mute permission, and without edit/delete affordances see no kebab on the card.
|
||||
Edit, delete, cordon, and mute stay gated on `node:manage` or mute permission as before. Every card shows the kebab with at least **Node details**, even for a viewer with no manage permissions.
|
||||
|
||||
### Topology view
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/comp
|
||||
import { FleetMasthead } from './fleet/FleetMasthead';
|
||||
import { ReconnectingOverlay } from './FleetView/ReconnectingOverlay';
|
||||
import { NodeUpdatesSheet } from './FleetView/NodeUpdatesSheet';
|
||||
import { NodeDetailsSheet } from './FleetView/NodeDetailsSheet';
|
||||
import { LocalUpdateConfirmDialog } from './FleetView/LocalUpdateConfirmDialog';
|
||||
import { OverviewTab } from './FleetView/OverviewTab';
|
||||
import { useFleetPreferences } from './FleetView/hooks/useFleetPreferences';
|
||||
@@ -66,7 +67,7 @@ export function FleetView({
|
||||
const { isAdmin, can } = useAuth();
|
||||
const canManageFleet = can('node:manage');
|
||||
const canExportDossier = can('node:read') && can('stack:read');
|
||||
const { hasCapability } = useNodes();
|
||||
const { hasCapability, nodes: registryNodes } = useNodes();
|
||||
const { experimental, experimentalReady } = useExperimental();
|
||||
const containerLabelsEnabled = hasCapability('container-label-inventory');
|
||||
// Visual fail-closed while /meta loads; paid/admin gates still apply when on.
|
||||
@@ -94,6 +95,7 @@ export function FleetView({
|
||||
});
|
||||
|
||||
const [initialUpdatesTab, setInitialUpdatesTab] = useState<'nodes' | 'changelog'>('nodes');
|
||||
const [detailsNodeId, setDetailsNodeId] = useState<number | null>(null);
|
||||
|
||||
const [internalTab, setInternalTab] = useState<FleetTab>('overview');
|
||||
const activeTab = controlledTab ?? internalTab;
|
||||
@@ -294,6 +296,7 @@ export function FleetView({
|
||||
onEditNode={openEdit}
|
||||
onDeleteNode={openDelete}
|
||||
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||
onOpenNodeDetails={setDetailsNodeId}
|
||||
onAddNode={isAdmin && onOpenSettingsSection ? () => onOpenSettingsSection('nodes') : undefined}
|
||||
onCheckUpdates={updateStatus.checkUpdates}
|
||||
checkingUpdates={updateStatus.checkingUpdates}
|
||||
@@ -374,6 +377,18 @@ export function FleetView({
|
||||
triggerUpdateAll={updateStatus.triggerUpdateAll}
|
||||
/>
|
||||
|
||||
<NodeDetailsSheet
|
||||
open={detailsNodeId !== null}
|
||||
onOpenChange={(open) => { if (!open) setDetailsNodeId(null); }}
|
||||
node={detailsNodeId !== null ? (overview.allNodes.find(n => n.id === detailsNodeId) ?? null) : null}
|
||||
registryNode={detailsNodeId !== null ? (registryNodes.find(n => n.id === detailsNodeId) ?? null) : null}
|
||||
updateStatus={detailsNodeId !== null ? overview.updateStatusMap.get(detailsNodeId) : undefined}
|
||||
networkingSignal={detailsNodeId !== null ? overview.networkingByNode.get(detailsNodeId) : undefined}
|
||||
canManageNode={detailsNodeId !== null && can('node:manage', 'node', String(detailsNodeId))}
|
||||
onOpenNetworking={onOpenNodeNetworking}
|
||||
onEdit={openEdit}
|
||||
/>
|
||||
|
||||
<LocalUpdateConfirmDialog
|
||||
open={confirmOpen}
|
||||
mode={confirmMode}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from 'react';
|
||||
import {
|
||||
Server, Cpu, MemoryStick, HardDrive, ChevronDown, ChevronRight,
|
||||
Layers, Wifi, WifiOff, AlertTriangle, Download, Loader2,
|
||||
MoreVertical, Ban, Pencil, Trash2,
|
||||
MoreVertical, Ban, Pencil, Trash2, Info,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { NodeMuteSubmenu } from '@/components/mute/MuteMenuItems';
|
||||
@@ -50,6 +51,8 @@ export interface NodeCardProps {
|
||||
onEdit?: (node: Node) => void;
|
||||
onDelete?: (node: Node) => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
/** Opens the read-only Node details sheet. Available to any role that can see the card. */
|
||||
onOpenDetails?: (nodeId: number) => void;
|
||||
}
|
||||
|
||||
// --- Sub-Components ---
|
||||
@@ -67,7 +70,7 @@ function UsageBar({ percent, color }: { percent: number; color: string }) {
|
||||
|
||||
// --- Main Export ---
|
||||
|
||||
export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill }: NodeCardProps) {
|
||||
export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal, labelMap, updateStatus, onUpdate, updatingNodeId, onRetryUpdate, onDismissUpdate, onCordonChange, onEdit, onDelete, onOpenMuteRulesWithPrefill, onOpenDetails }: NodeCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [stacks, setStacks] = useState<string[] | null>(node.stacks);
|
||||
const [loadingStacks, setLoadingStacks] = useState(false);
|
||||
@@ -89,7 +92,11 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal,
|
||||
node.name,
|
||||
onOpenMuteRulesWithPrefill ?? (() => {}),
|
||||
);
|
||||
const showMenu = canEdit || canDelete || canCordon || (nodeMuteActions.canMute && Boolean(onOpenMuteRulesWithPrefill));
|
||||
// "Node details" is always available to anyone who can see the card (same
|
||||
// node:read gate that already governs Fleet card visibility), so the kebab
|
||||
// itself is no longer conditional. This flag now only decides whether the
|
||||
// manage items (which stay node:manage-gated) render below the separator.
|
||||
const hasManageMenuItems = canEdit || canDelete || canCordon || (nodeMuteActions.canMute && Boolean(onOpenMuteRulesWithPrefill));
|
||||
|
||||
const isOnline = node.status === 'online';
|
||||
const isLocal = node.type === 'local';
|
||||
@@ -158,49 +165,54 @@ export function NodeCard({ node, onNavigate, onOpenNetworking, networkingSignal,
|
||||
{/* Card Header */}
|
||||
<div className="relative p-4 pb-3">
|
||||
{isLocal && (
|
||||
<span className={`absolute top-3 font-mono text-[9px] uppercase tracking-[0.22em] text-brand ${showMenu ? 'right-9' : 'right-3'}`}>
|
||||
<span className="absolute top-3 right-9 font-mono text-[9px] uppercase tracking-[0.22em] text-brand">
|
||||
★ Local
|
||||
</span>
|
||||
)}
|
||||
{showMenu && (
|
||||
<div className="absolute top-2 right-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Node actions"
|
||||
className="inline-flex items-center justify-center w-6 h-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
<div className="absolute top-2 right-2">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Node actions"
|
||||
className="inline-flex items-center justify-center w-6 h-6 rounded-md text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{onOpenDetails && (
|
||||
<DropdownMenuItem onSelect={() => onOpenDetails(node.id)}>
|
||||
<Info className="w-3.5 h-3.5 mr-2" />
|
||||
Node details
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onOpenDetails && hasManageMenuItems && <DropdownMenuSeparator />}
|
||||
{canEdit && registryNode && (
|
||||
<DropdownMenuItem onSelect={() => onEdit!(registryNode)}>
|
||||
<Pencil className="w-3.5 h-3.5 mr-2" />
|
||||
Edit node
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && registryNode && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onDelete!(registryNode)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
{canEdit && registryNode && (
|
||||
<DropdownMenuItem onSelect={() => onEdit!(registryNode)}>
|
||||
<Pencil className="w-3.5 h-3.5 mr-2" />
|
||||
Edit node
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canDelete && registryNode && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => onDelete!(registryNode)}
|
||||
className="text-destructive focus:text-destructive"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
||||
Delete node
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canCordon && (
|
||||
<DropdownMenuItem onSelect={openCordonModal}>
|
||||
<Ban className="w-3.5 h-3.5 mr-2" />
|
||||
{node.cordoned ? 'Uncordon node' : 'Cordon node'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onOpenMuteRulesWithPrefill && <NodeMuteSubmenu actions={nodeMuteActions} />}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
)}
|
||||
<Trash2 className="w-3.5 h-3.5 mr-2" />
|
||||
Delete node
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{canCordon && (
|
||||
<DropdownMenuItem onSelect={openCordonModal}>
|
||||
<Ban className="w-3.5 h-3.5 mr-2" />
|
||||
{node.cordoned ? 'Uncordon node' : 'Cordon node'}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onOpenMuteRulesWithPrefill && <NodeMuteSubmenu actions={nodeMuteActions} />}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className={`flex items-center justify-center w-8 h-8 rounded-lg ${isOnline ? 'bg-success-muted' : 'bg-muted'}`}>
|
||||
|
||||
@@ -0,0 +1,350 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react';
|
||||
import { Cpu, MemoryStick, HardDrive, Globe, Monitor, Terminal, Ban, Pencil, KeyRound, Network } from 'lucide-react';
|
||||
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { NodeLabelPicker } from '@/components/blueprints/NodeLabelPicker';
|
||||
import { useNodes, type Node } from '@/context/NodeContext';
|
||||
import { formatVersion } from '@/lib/version';
|
||||
import { formatTimeAgo } from '@/lib/relativeTime';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { PinnedUpdateBadge } from './PinnedUpdateBadge';
|
||||
import type { FleetNode, NodeUpdateStatus } from './types';
|
||||
|
||||
interface NodeDetailsSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
node: FleetNode | null;
|
||||
registryNode: Node | null;
|
||||
updateStatus?: NodeUpdateStatus;
|
||||
networkingSignal?: { exposed: boolean; unknown: boolean; drift: boolean };
|
||||
canManageNode: boolean;
|
||||
onOpenNetworking?: (nodeId: number) => void;
|
||||
onEdit?: (node: Node) => void;
|
||||
}
|
||||
|
||||
// A small nice-to-have translation for the most operator-relevant capability
|
||||
// strings; anything not listed here just renders its raw identifier.
|
||||
const CAPABILITY_LABELS: Partial<Record<string, string>> = {
|
||||
'cross-node-rbac': 'Cross-node RBAC',
|
||||
'self-update': 'Self-update',
|
||||
'fleet': 'Fleet management',
|
||||
'compose-networking': 'Networking inventory',
|
||||
};
|
||||
|
||||
function formatTimestamp(ms: number): string {
|
||||
return new Date(ms).toLocaleString();
|
||||
}
|
||||
|
||||
// `FleetNode.last_successful_contact` and `FleetNode.pilot_last_seen` come from
|
||||
// the fleet-overview endpoint in Unix SECONDS (DatabaseService.updateNodeLastContact
|
||||
// writes Math.floor(Date.now()/1000); fleet.ts's pilotLastSeenSeconds() divides the
|
||||
// millisecond DB value by 1000 for this same response). `formatTimeAgo`/`formatTimestamp`
|
||||
// both expect milliseconds, so any FleetNode-sourced timestamp must convert here before
|
||||
// use. `registryNode`-sourced timestamps (e.g. pilot_last_seen from /api/nodes) are
|
||||
// already in milliseconds and must NOT be passed through this helper.
|
||||
function fleetSecondsToMs(seconds: number): number {
|
||||
return seconds * 1000;
|
||||
}
|
||||
|
||||
function UsageBar({ percent, color }: { percent: number; color: string }) {
|
||||
return (
|
||||
<div className="h-1.5 w-full bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${color}`}
|
||||
style={{ width: `${Math.min(100, percent)}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children, span }: { label: string; children: ReactNode; span?: 1 | 2 }) {
|
||||
return (
|
||||
<div className={span === 2 ? 'col-span-2' : undefined}>
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NodeDetailsSheet({
|
||||
open, onOpenChange, node, registryNode, updateStatus, networkingSignal,
|
||||
canManageNode, onOpenNetworking, onEdit,
|
||||
}: NodeDetailsSheetProps) {
|
||||
const { nodeMeta, refreshNodeMeta } = useNodes();
|
||||
const [capabilitiesExpanded, setCapabilitiesExpanded] = useState(false);
|
||||
const nodeId = node?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (open && nodeId !== null) void refreshNodeMeta(nodeId);
|
||||
}, [open, nodeId, refreshNodeMeta]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setCapabilitiesExpanded(false);
|
||||
}, [open]);
|
||||
|
||||
if (!node) return null;
|
||||
|
||||
const meta = nodeMeta.get(node.id) ?? null;
|
||||
const isLocal = node.type === 'local';
|
||||
const isPilot = registryNode?.mode === 'pilot_agent';
|
||||
const connectionModeLabel = isLocal ? 'Local' : isPilot ? 'Pilot Agent' : 'API Proxy';
|
||||
const versionLabel = formatVersion(updateStatus?.version ?? meta?.version ?? null);
|
||||
const cpuPercent = node.systemStats ? parseFloat(node.systemStats.cpu.usage) : 0;
|
||||
const memPercent = node.systemStats ? parseFloat(node.systemStats.memory.usagePercent) : 0;
|
||||
const diskPercent = node.systemStats?.disk ? parseFloat(node.systemStats.disk.usagePercent) : 0;
|
||||
const hasNetworkingSignal = Boolean(
|
||||
networkingSignal && (networkingSignal.exposed || networkingSignal.unknown || networkingSignal.drift),
|
||||
);
|
||||
|
||||
const metaLine = [
|
||||
connectionModeLabel,
|
||||
node.status === 'online' ? 'Online' : node.status === 'offline' ? 'Offline' : 'Unknown',
|
||||
versionLabel,
|
||||
node.stacks ? `${node.stacks.length} stack${node.stacks.length === 1 ? '' : 's'}` : null,
|
||||
].filter(Boolean).join(' · ');
|
||||
|
||||
const footerContext = node.status === 'online'
|
||||
? 'Live · refreshes with the fleet overview'
|
||||
: node.last_successful_contact
|
||||
? `Last seen ${formatTimeAgo(fleetSecondsToMs(node.last_successful_contact))}`
|
||||
: 'Never contacted';
|
||||
|
||||
return (
|
||||
<SystemSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
crumb={['Fleet', 'Node', node.name]}
|
||||
name={node.name}
|
||||
meta={metaLine}
|
||||
primaryAction={canManageNode && onEdit && registryNode ? {
|
||||
label: 'Edit node',
|
||||
icon: Pencil,
|
||||
onClick: () => onEdit(registryNode),
|
||||
} : undefined}
|
||||
secondaryActions={onOpenNetworking && hasNetworkingSignal ? [{
|
||||
label: 'View networking',
|
||||
icon: Network,
|
||||
onClick: () => onOpenNetworking(node.id),
|
||||
}] : undefined}
|
||||
footerContext={footerContext}
|
||||
size="md"
|
||||
>
|
||||
<SheetSection title="Connectivity">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<Field label="Connection mode">
|
||||
<p className="text-xs mt-0.5 flex items-center gap-1.5">
|
||||
{isLocal ? <Monitor className="w-3 h-3" strokeWidth={1.5} /> : isPilot ? <Terminal className="w-3 h-3" strokeWidth={1.5} /> : <Globe className="w-3 h-3" strokeWidth={1.5} />}
|
||||
{connectionModeLabel}
|
||||
</p>
|
||||
</Field>
|
||||
<Field label="Management endpoint">
|
||||
<p className="font-mono text-xs mt-0.5 break-all">
|
||||
{isLocal
|
||||
? 'docker.sock'
|
||||
: isPilot
|
||||
? (registryNode?.pilot_last_seen ? `Tunnel (seen ${formatTimeAgo(registryNode.pilot_last_seen)})` : 'Tunnel (waiting)')
|
||||
: (registryNode?.api_url || '-')}
|
||||
</p>
|
||||
</Field>
|
||||
{typeof node.latency_ms === 'number' && (
|
||||
<Field label="Latency">
|
||||
<p className="font-mono text-xs mt-0.5 tabular-nums">{node.latency_ms} ms</p>
|
||||
</Field>
|
||||
)}
|
||||
{!isLocal && (
|
||||
<Field label="Last successful contact">
|
||||
<p className="text-xs mt-0.5" title={node.last_successful_contact ? formatTimestamp(fleetSecondsToMs(node.last_successful_contact)) : undefined}>
|
||||
{node.last_successful_contact ? formatTimeAgo(fleetSecondsToMs(node.last_successful_contact)) : 'Never'}
|
||||
</p>
|
||||
</Field>
|
||||
)}
|
||||
{isPilot && (
|
||||
<>
|
||||
<Field label="Pilot heartbeat">
|
||||
<p className="text-xs mt-0.5">
|
||||
{node.pilot_last_seen ? formatTimeAgo(fleetSecondsToMs(node.pilot_last_seen)) : 'Never'}
|
||||
</p>
|
||||
</Field>
|
||||
<Field label="Pilot Agent version">
|
||||
<p className="font-mono text-xs mt-0.5">{formatVersion(registryNode?.pilot_agent_version) ?? 'Unknown'}</p>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label="Token configured">
|
||||
<p className="text-xs mt-0.5">
|
||||
<Badge variant="outline" className="text-[10px] h-5 gap-1">
|
||||
<KeyRound className="w-2.5 h-2.5" strokeWidth={1.5} />
|
||||
{registryNode?.has_token ? 'Yes' : 'No'}
|
||||
</Badge>
|
||||
</p>
|
||||
</Field>
|
||||
</div>
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title="Capacity">
|
||||
{node.systemStats ? (
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<Cpu className="w-3 h-3" /> CPU · {node.systemStats.cpu.cores} cores
|
||||
</span>
|
||||
<span className="font-medium">{node.systemStats.cpu.usage}%</span>
|
||||
</div>
|
||||
<UsageBar percent={cpuPercent} color={cpuPercent > 80 ? 'bg-destructive/80' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<MemoryStick className="w-3 h-3" /> Memory
|
||||
</span>
|
||||
<span className="font-medium">{formatBytes(node.systemStats.memory.used, 1)} / {formatBytes(node.systemStats.memory.total, 1)}</span>
|
||||
</div>
|
||||
<UsageBar percent={memPercent} color={memPercent > 80 ? 'bg-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-brand/60'} />
|
||||
</div>
|
||||
{node.systemStats.disk && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1">
|
||||
<span className="flex items-center gap-1 text-muted-foreground">
|
||||
<HardDrive className="w-3 h-3" /> Disk
|
||||
</span>
|
||||
<span className="font-medium">{formatBytes(node.systemStats.disk.used, 1)} / {formatBytes(node.systemStats.disk.total, 1)}</span>
|
||||
</div>
|
||||
<UsageBar percent={diskPercent} color={diskPercent > 90 ? 'bg-destructive/80' : diskPercent > 75 ? 'bg-warning' : 'bg-brand'} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">Unavailable while the node is offline.</p>
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title={`Compose workload · ${node.stacks?.length ?? 0} stacks`}>
|
||||
{node.stats ? (
|
||||
<div className="grid grid-cols-4 rounded-md border border-card-border overflow-hidden text-center mb-2">
|
||||
<div className="border-r border-card-border bg-card px-2 py-2">
|
||||
<div className="text-base font-medium leading-none tabular-nums text-stat-value">{node.stats.active}</div>
|
||||
<div className="text-[9px] leading-3 font-mono uppercase tracking-[0.16em] text-stat-subtitle mt-1">Running</div>
|
||||
</div>
|
||||
<div className="border-r border-card-border bg-card px-2 py-2">
|
||||
<div className="text-base font-medium leading-none tabular-nums text-stat-value">{node.stats.exited}</div>
|
||||
<div className="text-[9px] leading-3 font-mono uppercase tracking-[0.16em] text-stat-subtitle mt-1">Stopped</div>
|
||||
</div>
|
||||
<div className="border-r border-card-border bg-card px-2 py-2">
|
||||
<div className="text-base font-medium leading-none tabular-nums text-stat-value">{node.stats.managed}</div>
|
||||
<div className="text-[9px] leading-3 font-mono uppercase tracking-[0.16em] text-stat-subtitle mt-1">Managed</div>
|
||||
</div>
|
||||
<div className="bg-card px-2 py-2">
|
||||
<div className="text-base font-medium leading-none tabular-nums text-stat-value">{node.stats.unmanaged}</div>
|
||||
<div className="text-[9px] leading-3 font-mono uppercase tracking-[0.16em] text-stat-subtitle mt-1">Unmanaged</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground italic">Unavailable while the node is offline.</p>
|
||||
)}
|
||||
{onOpenNetworking && hasNetworkingSignal && networkingSignal && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1.5 py-0 h-5 cursor-pointer bg-warning/10 text-warning border-warning/30 hover:bg-warning/20"
|
||||
onClick={() => onOpenNetworking(node.id)}
|
||||
>
|
||||
Networking · {networkingSignal.drift ? 'drift' : networkingSignal.exposed ? 'exposed' : 'unknown exposure'}
|
||||
</Badge>
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title="Compatibility">
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm mb-3">
|
||||
<Field label="Sencho version">
|
||||
<p className="font-mono text-xs mt-0.5">{versionLabel ?? 'Unknown'}</p>
|
||||
</Field>
|
||||
<Field label="Image channel">
|
||||
<p className="text-xs mt-0.5 capitalize">{updateStatus?.imageChannel ?? 'Unknown'}</p>
|
||||
</Field>
|
||||
<Field label="Pin type">
|
||||
<p className="text-xs mt-0.5 capitalize">{updateStatus?.imagePinKind ?? 'Unknown'}</p>
|
||||
</Field>
|
||||
<Field label="Update status">
|
||||
<p className="text-xs mt-0.5">
|
||||
{!updateStatus ? (
|
||||
<Badge variant="outline" className="text-[10px] px-1.5 py-0 h-5 text-muted-foreground">Unknown</Badge>
|
||||
) : updateStatus.updateBlocked ? (
|
||||
<PinnedUpdateBadge reason={updateStatus.updateBlockedReason} className="text-[10px] px-1.5 py-0 h-5" />
|
||||
) : updateStatus.updateAvailable ? (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-warning/15 text-warning border-warning/30">Update available</Badge>
|
||||
) : (
|
||||
<Badge className="text-[10px] px-1.5 py-0 h-5 bg-success-muted text-success border-success/30">Up to date</Badge>
|
||||
)}
|
||||
</p>
|
||||
</Field>
|
||||
</div>
|
||||
{meta ? (
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCapabilitiesExpanded(v => !v)}
|
||||
className="text-[11px] text-muted-foreground hover:text-foreground transition-colors underline-offset-2 hover:underline"
|
||||
>
|
||||
{meta.capabilities.length} capabilities advertised {capabilitiesExpanded ? '(hide)' : '(show)'}
|
||||
</button>
|
||||
{capabilitiesExpanded && (
|
||||
<ul className="mt-2 flex flex-wrap gap-1">
|
||||
{meta.capabilities.map(c => (
|
||||
<li key={c}>
|
||||
<Badge variant="outline" className="text-[10px] h-5 font-mono">{CAPABILITY_LABELS[c] ?? c}</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-4 w-40" />
|
||||
)}
|
||||
</SheetSection>
|
||||
|
||||
<SheetSection title="Governance">
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<span className="text-xs text-muted-foreground">Labels</span>
|
||||
<div className="mt-1">
|
||||
<NodeLabelPicker nodeId={node.id} canEdit={canManageNode} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<Field label="Cordon status">
|
||||
<p className="text-xs mt-0.5">
|
||||
{node.cordoned ? (
|
||||
<Badge variant="outline" className="text-[10px] h-5 gap-1 bg-warning/15 text-warning border-warning/30">
|
||||
<Ban className="w-2.5 h-2.5" strokeWidth={1.5} /> Cordoned
|
||||
</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">Schedulable</span>
|
||||
)}
|
||||
</p>
|
||||
</Field>
|
||||
{node.cordoned && (
|
||||
<>
|
||||
<Field label="Cordoned since">
|
||||
<p className="text-xs mt-0.5">{node.cordoned_at ? formatTimestamp(node.cordoned_at) : 'Unknown'}</p>
|
||||
</Field>
|
||||
<Field label="Reason" span={2}>
|
||||
<p className="text-xs mt-0.5">{node.cordoned_reason ?? 'No reason given'}</p>
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
<Field label="Default node">
|
||||
<p className="text-xs mt-0.5">{registryNode?.is_default ? 'Yes' : 'No'}</p>
|
||||
</Field>
|
||||
<Field label="Compose directory">
|
||||
<p className="font-mono text-xs mt-0.5 break-all">{registryNode?.compose_dir ?? '-'}</p>
|
||||
</Field>
|
||||
<Field label="Registered" span={2}>
|
||||
<p className="text-xs mt-0.5">{registryNode?.created_at ? formatTimestamp(registryNode.created_at) : 'Unknown'}</p>
|
||||
</Field>
|
||||
</div>
|
||||
</div>
|
||||
</SheetSection>
|
||||
</SystemSheet>
|
||||
);
|
||||
}
|
||||
@@ -39,6 +39,7 @@ interface OverviewTabProps {
|
||||
onEditNode?: (node: Node) => void;
|
||||
onDeleteNode?: (node: Node) => void;
|
||||
onOpenMuteRulesWithPrefill?: (draft: MuteRuleDraft) => void;
|
||||
onOpenNodeDetails: (nodeId: number) => void;
|
||||
topologyMode: LayoutMode;
|
||||
onTopologyModeChange: (mode: LayoutMode) => void;
|
||||
topologyPositions: SavedPositions;
|
||||
@@ -77,6 +78,7 @@ export function OverviewTab({
|
||||
onEditNode,
|
||||
onDeleteNode,
|
||||
onOpenMuteRulesWithPrefill,
|
||||
onOpenNodeDetails,
|
||||
topologyMode,
|
||||
onTopologyModeChange,
|
||||
topologyPositions,
|
||||
@@ -159,6 +161,7 @@ export function OverviewTab({
|
||||
onEdit={onEditNode}
|
||||
onDelete={onDeleteNode}
|
||||
onOpenMuteRulesWithPrefill={onOpenMuteRulesWithPrefill}
|
||||
onOpenDetails={onOpenNodeDetails}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -28,7 +28,7 @@ function offlineNode(): FleetNode {
|
||||
}
|
||||
|
||||
function baseProps(node: FleetNode) {
|
||||
return { node, onNavigate: vi.fn() };
|
||||
return { node, onNavigate: vi.fn(), onOpenDetails: vi.fn() };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -76,10 +76,37 @@ describe('NodeCard', () => {
|
||||
expect(screen.getByText('Delete node')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the cordon control from a user lacking node:manage', () => {
|
||||
it('shows only Node details to a user lacking node:manage', async () => {
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
expect(screen.queryByRole('button', { name: 'Node actions' })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
||||
expect(await screen.findByText('Node details')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Cordon node')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Edit node')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Delete node')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onOpenDetails with the node id when Node details is clicked', async () => {
|
||||
const onOpenDetails = vi.fn();
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can: vi.fn(() => false) });
|
||||
render(<NodeCard {...baseProps(onlineNode())} onOpenDetails={onOpenDetails} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
||||
await userEvent.click(await screen.findByText('Node details'));
|
||||
expect(onOpenDetails).toHaveBeenCalledWith(2);
|
||||
});
|
||||
|
||||
it('shows Node details ahead of the manage items for a node:manage user', async () => {
|
||||
const can = vi.fn((action: string) => action === 'node:manage');
|
||||
useAuthMock.mockReturnValue({ isAdmin: false, can });
|
||||
render(<NodeCard {...baseProps(onlineNode())} />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Node actions' }));
|
||||
const menuItems = await screen.findAllByRole('menuitem');
|
||||
const labels = menuItems.map(item => item.textContent);
|
||||
expect(labels[0]).toBe('Node details');
|
||||
expect(labels).toContain('Cordon node');
|
||||
});
|
||||
|
||||
it('shows Uncordon when the node is already cordoned', async () => {
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { render, screen, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
const useNodesMock = vi.fn();
|
||||
vi.mock('@/context/NodeContext', () => ({ useNodes: () => useNodesMock() }));
|
||||
|
||||
// NodeLabelPicker is a fully self-fetching reused unit (its own tests cover its
|
||||
// behavior); shallow-mock it here so this file stays focused on the sheet.
|
||||
vi.mock('@/components/blueprints/NodeLabelPicker', () => ({
|
||||
NodeLabelPicker: ({ nodeId, canEdit }: { nodeId: number; canEdit: boolean }) => (
|
||||
<div data-testid="node-label-picker">labels for {nodeId} · editable={String(canEdit)}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
import { NodeDetailsSheet } from '../NodeDetailsSheet';
|
||||
import type { FleetNode, NodeUpdateStatus } from '../types';
|
||||
import type { Node } from '@/context/NodeContext';
|
||||
|
||||
// FleetNode's last_successful_contact/pilot_last_seen come from the
|
||||
// fleet-overview endpoint in Unix SECONDS (see fleetSecondsToMs's comment in
|
||||
// the component) — these fixtures must use seconds, not milliseconds, or a
|
||||
// bug in the component's unit handling would go undetected here.
|
||||
function fleetNode(overrides: Partial<FleetNode> = {}): FleetNode {
|
||||
return {
|
||||
id: 2,
|
||||
name: 'Edge',
|
||||
type: 'remote',
|
||||
mode: 'proxy',
|
||||
status: 'online',
|
||||
stats: { active: 3, managed: 3, unmanaged: 0, exited: 1, total: 4 },
|
||||
systemStats: { cpu: { usage: '20.0', cores: 4 }, memory: { total: 100, used: 40, free: 60, usagePercent: '40.0' }, disk: { total: 100, used: 30, free: 70, usagePercent: '30.0' } },
|
||||
stacks: ['web'],
|
||||
cordoned: false,
|
||||
cordoned_at: null,
|
||||
cordoned_reason: null,
|
||||
latency_ms: 42,
|
||||
last_successful_contact: Math.floor(Date.now() / 1000) - 5,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function registryNode(overrides: Partial<Node> = {}): Node {
|
||||
return {
|
||||
id: 2,
|
||||
name: 'Edge',
|
||||
type: 'remote',
|
||||
mode: 'proxy',
|
||||
compose_dir: '/srv/compose',
|
||||
is_default: false,
|
||||
status: 'online',
|
||||
created_at: Date.UTC(2026, 0, 1),
|
||||
api_url: 'https://edge.internal:1852',
|
||||
has_token: true,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const UPDATE_STATUS: NodeUpdateStatus = {
|
||||
nodeId: 2, name: 'Edge', type: 'remote', version: '1.2.0', latestVersion: '1.2.0',
|
||||
updateAvailable: false, updateStatus: null, imageChannel: 'community', imagePinKind: 'semver',
|
||||
};
|
||||
|
||||
function baseProps(overrides: Partial<React.ComponentProps<typeof NodeDetailsSheet>> = {}) {
|
||||
return {
|
||||
open: true,
|
||||
onOpenChange: vi.fn(),
|
||||
node: fleetNode(),
|
||||
registryNode: registryNode(),
|
||||
updateStatus: UPDATE_STATUS,
|
||||
networkingSignal: { exposed: false, unknown: false, drift: false },
|
||||
canManageNode: false,
|
||||
onOpenNetworking: vi.fn(),
|
||||
onEdit: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
useNodesMock.mockReturnValue({ nodeMeta: new Map(), refreshNodeMeta: vi.fn() });
|
||||
});
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
|
||||
describe('NodeDetailsSheet', () => {
|
||||
it('renders all sections from the node, registry, and update-status data', () => {
|
||||
render(<NodeDetailsSheet {...baseProps()} />);
|
||||
expect(screen.getByRole('heading', { name: 'Edge' })).toBeInTheDocument();
|
||||
expect(screen.getByText('Connectivity')).toBeInTheDocument();
|
||||
expect(screen.getByText('Capacity')).toBeInTheDocument();
|
||||
expect(screen.getByText(/Compose workload/)).toBeInTheDocument();
|
||||
expect(screen.getByText('Compatibility')).toBeInTheDocument();
|
||||
expect(screen.getByText('Governance')).toBeInTheDocument();
|
||||
expect(screen.getByText('42 ms')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('node-label-picker')).toHaveTextContent('labels for 2 · editable=false');
|
||||
});
|
||||
|
||||
it('renders cordon reason and date as visible text, not tooltip-only', () => {
|
||||
render(
|
||||
<NodeDetailsSheet
|
||||
{...baseProps({
|
||||
node: fleetNode({ cordoned: true, cordoned_reason: 'Host maintenance', cordoned_at: Date.UTC(2026, 6, 1) }),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText('Host maintenance')).toBeInTheDocument();
|
||||
expect(screen.getByText(new Date(Date.UTC(2026, 6, 1)).toLocaleString())).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows token-configured as a yes/no badge and never renders a raw token value', () => {
|
||||
render(<NodeDetailsSheet {...baseProps({ registryNode: registryNode({ has_token: true }) })} />);
|
||||
expect(screen.getByText('Token configured')).toBeInTheDocument();
|
||||
expect(screen.getByText('Yes')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/eyJ|Bearer /)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reuses the existing networking handler instead of rendering networking detail inline', async () => {
|
||||
const onOpenNetworking = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<NodeDetailsSheet
|
||||
{...baseProps({
|
||||
onOpenNetworking,
|
||||
networkingSignal: { exposed: false, unknown: false, drift: true },
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
const badge = screen.getByText(/Networking/);
|
||||
await user.click(badge);
|
||||
expect(onOpenNetworking).toHaveBeenCalledWith(2);
|
||||
// No inline network detail (IPAM, subnet, etc.) is rendered by this sheet.
|
||||
expect(screen.queryByText(/subnet/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a skeleton for capabilities until nodeMeta resolves, then renders the count', () => {
|
||||
useNodesMock.mockReturnValue({ nodeMeta: new Map(), refreshNodeMeta: vi.fn() });
|
||||
const { rerender } = render(<NodeDetailsSheet {...baseProps()} />);
|
||||
expect(screen.queryByText(/capabilities advertised/)).not.toBeInTheDocument();
|
||||
|
||||
useNodesMock.mockReturnValue({
|
||||
nodeMeta: new Map([[2, { version: '1.2.0', capabilities: ['fleet', 'self-update'], fetchedAt: Date.now() }]]),
|
||||
refreshNodeMeta: vi.fn(),
|
||||
});
|
||||
rerender(<NodeDetailsSheet {...baseProps()} />);
|
||||
expect(screen.getByText('2 capabilities advertised (show)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('returns null when no node is selected', () => {
|
||||
const { container } = render(<NodeDetailsSheet {...baseProps({ node: null })} />);
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('converts FleetNode seconds-based timestamps correctly, not decades off', () => {
|
||||
render(
|
||||
<NodeDetailsSheet
|
||||
{...baseProps({
|
||||
node: fleetNode({
|
||||
mode: 'pilot_agent',
|
||||
last_successful_contact: Math.floor(Date.now() / 1000) - 5,
|
||||
pilot_last_seen: Math.floor(Date.now() / 1000) - 5,
|
||||
}),
|
||||
registryNode: registryNode({ mode: 'pilot_agent', pilot_last_seen: Date.now() - 5_000, pilot_agent_version: '1.0.0' }),
|
||||
})}
|
||||
/>,
|
||||
);
|
||||
// "just now" appears for both Last successful contact and Pilot heartbeat.
|
||||
// If the seconds value were passed straight to formatTimeAgo (which expects
|
||||
// ms), this would instead render something like "20647d ago".
|
||||
expect(screen.getAllByText('just now').length).toBeGreaterThanOrEqual(2);
|
||||
expect(screen.queryByText(/d ago/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits Last successful contact for the local node instead of showing Never', () => {
|
||||
render(<NodeDetailsSheet {...baseProps({ node: fleetNode({ type: 'local', last_successful_contact: null }) })} />);
|
||||
expect(screen.queryByText('Last successful contact')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('Never')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders Update status as Unknown, never a confident Up to date, when updateStatus is absent', () => {
|
||||
render(<NodeDetailsSheet {...baseProps({ updateStatus: undefined })} />);
|
||||
const updateStatusLabel = screen.getByText('Update status');
|
||||
const updateStatusField = updateStatusLabel.parentElement as HTMLElement;
|
||||
expect(within(updateStatusField).getByText('Unknown')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Up to date')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('still renders Up to date when updateStatus confirms no update is available', () => {
|
||||
render(<NodeDetailsSheet {...baseProps({ updateStatus: { ...UPDATE_STATUS, updateAvailable: false } })} />);
|
||||
expect(screen.getByText('Up to date')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -37,6 +37,7 @@ function props(overrides: Partial<React.ComponentProps<typeof OverviewTab>> = {}
|
||||
onNavigateToNode: vi.fn(),
|
||||
onOpenNodeNetworking: vi.fn(),
|
||||
networkingByNode: new Map(),
|
||||
onOpenNodeDetails: vi.fn(),
|
||||
updatingNodeId: null,
|
||||
topologyMode: 'hub' as const,
|
||||
onTopologyModeChange: vi.fn(),
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { ConfirmModal } from '@/components/ui/modal';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { getNodeCpu, getNodeMem, getNodeMemUsed, getNodeMemTotal, getNodeDisk, isCritical } from '@/components/FleetView/nodeUtils';
|
||||
import { NodeDetailsSheet } from '@/components/FleetView/NodeDetailsSheet';
|
||||
import type { FleetNode } from '@/components/FleetView/types';
|
||||
import { Bar, BackChip, Kicker, Masthead, MBtn, SectionHead, StateDot, StatePill } from './mobile-ui';
|
||||
import type { Tone as UiTone } from './mobile-ui';
|
||||
@@ -159,9 +160,12 @@ function NodeDetail({
|
||||
onCordonChange: () => void;
|
||||
}) {
|
||||
const { can } = useAuth();
|
||||
const { nodes: registryNodes } = useNodes();
|
||||
const registryNode = registryNodes.find(n => n.id === node.id) ?? null;
|
||||
const canCordon = can('node:manage', 'node', String(node.id));
|
||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||
|
||||
const tone = nodeTone(node);
|
||||
const online = node.status === 'online';
|
||||
@@ -207,6 +211,7 @@ function NodeDetail({
|
||||
<div className="flex-1 min-h-0 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden p-[14px]">
|
||||
<div className="flex gap-2">
|
||||
<MBtn kind="primary" full onClick={() => onInspectNode(node.id)}>Inspect</MBtn>
|
||||
<MBtn kind="outline" full onClick={() => setDetailsOpen(true)}>Details</MBtn>
|
||||
{canCordon ? (
|
||||
<MBtn kind="outline" full onClick={() => setConfirmOpen(true)}>
|
||||
{node.cordoned ? 'Uncordon' : 'Drain'}
|
||||
@@ -271,6 +276,14 @@ function NodeDetail({
|
||||
confirming={submitting}
|
||||
onConfirm={handleCordon}
|
||||
/>
|
||||
|
||||
<NodeDetailsSheet
|
||||
open={detailsOpen}
|
||||
onOpenChange={setDetailsOpen}
|
||||
node={node}
|
||||
registryNode={registryNode}
|
||||
canManageNode={canCordon}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,10 +22,19 @@ export interface Node {
|
||||
pilot_agent_version?: string | null;
|
||||
}
|
||||
|
||||
export type ImagePinKind = 'floating' | 'semver' | 'digest' | 'unknown';
|
||||
|
||||
export interface NodeMeta {
|
||||
version: string | null;
|
||||
capabilities: string[];
|
||||
fetchedAt: number;
|
||||
/** Remote-only fields below; absent (undefined) for local nodes' /meta response. */
|
||||
startedAt?: number | null;
|
||||
updateError?: string | null;
|
||||
online?: boolean;
|
||||
imagePinKind?: ImagePinKind | null;
|
||||
updateBlocked?: boolean;
|
||||
imageChannel?: 'community' | 'hardened' | 'unknown' | null;
|
||||
}
|
||||
|
||||
interface NodeContextType {
|
||||
@@ -95,6 +104,12 @@ export function NodeProvider({ children }: { children: React.ReactNode }) {
|
||||
version: data.version ?? null,
|
||||
capabilities: Array.isArray(data.capabilities) ? data.capabilities : [],
|
||||
fetchedAt: Date.now(),
|
||||
startedAt: data.startedAt ?? null,
|
||||
updateError: data.updateError ?? null,
|
||||
online: data.online,
|
||||
imagePinKind: data.imagePinKind ?? null,
|
||||
updateBlocked: data.updateBlocked,
|
||||
imageChannel: data.imageChannel ?? null,
|
||||
});
|
||||
} else {
|
||||
// A non-OK response (proxy error, auth, 5xx) is a resolved failure: record an
|
||||
|
||||
Reference in New Issue
Block a user