From 97be019696ef31a95765a21cd0a5d1caa9c1c4b6 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 2 Aug 2026 21:35:50 -0400 Subject: [PATCH] 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. --- docs/features/fleet-view.mdx | 7 +- frontend/src/components/FleetView.tsx | 17 +- .../src/components/FleetView/NodeCard.tsx | 96 ++--- .../components/FleetView/NodeDetailsSheet.tsx | 350 ++++++++++++++++++ .../src/components/FleetView/OverviewTab.tsx | 3 + .../FleetView/__tests__/NodeCard.test.tsx | 33 +- .../__tests__/NodeDetailsSheet.test.tsx | 190 ++++++++++ .../FleetView/__tests__/OverviewTab.test.tsx | 1 + .../src/components/mobile/MobileFleet.tsx | 13 + frontend/src/context/NodeContext.tsx | 15 + 10 files changed, 676 insertions(+), 49 deletions(-) create mode 100644 frontend/src/components/FleetView/NodeDetailsSheet.tsx create mode 100644 frontend/src/components/FleetView/__tests__/NodeDetailsSheet.test.tsx diff --git a/docs/features/fleet-view.mdx b/docs/features/fleet-view.mdx index 61856b69..448ecfb0 100644 --- a/docs/features/fleet-view.mdx +++ b/docs/features/fleet-view.mdx @@ -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 diff --git a/frontend/src/components/FleetView.tsx b/frontend/src/components/FleetView.tsx index 6e104594..7585e430 100644 --- a/frontend/src/components/FleetView.tsx +++ b/frontend/src/components/FleetView.tsx @@ -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(null); const [internalTab, setInternalTab] = useState('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} /> + { 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} + /> + 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(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 */}
{isLocal && ( - + ★ Local )} - {showMenu && ( -
- - - + + + {onOpenDetails && ( + onOpenDetails(node.id)}> + + Node details + + )} + {onOpenDetails && hasManageMenuItems && } + {canEdit && registryNode && ( + onEdit!(registryNode)}> + + Edit node + + )} + {canDelete && registryNode && ( + onDelete!(registryNode)} + className="text-destructive focus:text-destructive" > - - - - - {canEdit && registryNode && ( - onEdit!(registryNode)}> - - Edit node - - )} - {canDelete && registryNode && ( - onDelete!(registryNode)} - className="text-destructive focus:text-destructive" - > - - Delete node - - )} - {canCordon && ( - - - {node.cordoned ? 'Uncordon node' : 'Cordon node'} - - )} - {onOpenMuteRulesWithPrefill && } - - -
- )} + + Delete node + + )} + {canCordon && ( + + + {node.cordoned ? 'Uncordon node' : 'Cordon node'} + + )} + {onOpenMuteRulesWithPrefill && } + + +
diff --git a/frontend/src/components/FleetView/NodeDetailsSheet.tsx b/frontend/src/components/FleetView/NodeDetailsSheet.tsx new file mode 100644 index 00000000..f9090269 --- /dev/null +++ b/frontend/src/components/FleetView/NodeDetailsSheet.tsx @@ -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> = { + '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 ( +
+
+
+ ); +} + +function Field({ label, children, span }: { label: string; children: ReactNode; span?: 1 | 2 }) { + return ( +
+ {label} + {children} +
+ ); +} + +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 ( + onEdit(registryNode), + } : undefined} + secondaryActions={onOpenNetworking && hasNetworkingSignal ? [{ + label: 'View networking', + icon: Network, + onClick: () => onOpenNetworking(node.id), + }] : undefined} + footerContext={footerContext} + size="md" + > + +
+ +

+ {isLocal ? : isPilot ? : } + {connectionModeLabel} +

+
+ +

+ {isLocal + ? 'docker.sock' + : isPilot + ? (registryNode?.pilot_last_seen ? `Tunnel (seen ${formatTimeAgo(registryNode.pilot_last_seen)})` : 'Tunnel (waiting)') + : (registryNode?.api_url || '-')} +

+
+ {typeof node.latency_ms === 'number' && ( + +

{node.latency_ms} ms

+
+ )} + {!isLocal && ( + +

+ {node.last_successful_contact ? formatTimeAgo(fleetSecondsToMs(node.last_successful_contact)) : 'Never'} +

+
+ )} + {isPilot && ( + <> + +

+ {node.pilot_last_seen ? formatTimeAgo(fleetSecondsToMs(node.pilot_last_seen)) : 'Never'} +

+
+ +

{formatVersion(registryNode?.pilot_agent_version) ?? 'Unknown'}

+
+ + )} + +

+ + + {registryNode?.has_token ? 'Yes' : 'No'} + +

+
+
+
+ + + {node.systemStats ? ( +
+
+
+ + CPU · {node.systemStats.cpu.cores} cores + + {node.systemStats.cpu.usage}% +
+ 80 ? 'bg-destructive/80' : cpuPercent > 60 ? 'bg-warning' : 'bg-success'} /> +
+
+
+ + Memory + + {formatBytes(node.systemStats.memory.used, 1)} / {formatBytes(node.systemStats.memory.total, 1)} +
+ 80 ? 'bg-destructive/80' : memPercent > 60 ? 'bg-warning' : 'bg-brand/60'} /> +
+ {node.systemStats.disk && ( +
+
+ + Disk + + {formatBytes(node.systemStats.disk.used, 1)} / {formatBytes(node.systemStats.disk.total, 1)} +
+ 90 ? 'bg-destructive/80' : diskPercent > 75 ? 'bg-warning' : 'bg-brand'} /> +
+ )} +
+ ) : ( +

Unavailable while the node is offline.

+ )} +
+ + + {node.stats ? ( +
+
+
{node.stats.active}
+
Running
+
+
+
{node.stats.exited}
+
Stopped
+
+
+
{node.stats.managed}
+
Managed
+
+
+
{node.stats.unmanaged}
+
Unmanaged
+
+
+ ) : ( +

Unavailable while the node is offline.

+ )} + {onOpenNetworking && hasNetworkingSignal && networkingSignal && ( + onOpenNetworking(node.id)} + > + Networking · {networkingSignal.drift ? 'drift' : networkingSignal.exposed ? 'exposed' : 'unknown exposure'} + + )} +
+ + +
+ +

{versionLabel ?? 'Unknown'}

+
+ +

{updateStatus?.imageChannel ?? 'Unknown'}

+
+ +

{updateStatus?.imagePinKind ?? 'Unknown'}

+
+ +

+ {!updateStatus ? ( + Unknown + ) : updateStatus.updateBlocked ? ( + + ) : updateStatus.updateAvailable ? ( + Update available + ) : ( + Up to date + )} +

+
+
+ {meta ? ( +
+ + {capabilitiesExpanded && ( +
    + {meta.capabilities.map(c => ( +
  • + {CAPABILITY_LABELS[c] ?? c} +
  • + ))} +
+ )} +
+ ) : ( + + )} +
+ + +
+
+ Labels +
+ +
+
+
+ +

+ {node.cordoned ? ( + + Cordoned + + ) : ( + Schedulable + )} +

+
+ {node.cordoned && ( + <> + +

{node.cordoned_at ? formatTimestamp(node.cordoned_at) : 'Unknown'}

+
+ +

{node.cordoned_reason ?? 'No reason given'}

+
+ + )} + +

{registryNode?.is_default ? 'Yes' : 'No'}

+
+ +

{registryNode?.compose_dir ?? '-'}

+
+ +

{registryNode?.created_at ? formatTimestamp(registryNode.created_at) : 'Unknown'}

+
+
+
+
+
+ ); +} diff --git a/frontend/src/components/FleetView/OverviewTab.tsx b/frontend/src/components/FleetView/OverviewTab.tsx index 46465953..72c6f4c8 100644 --- a/frontend/src/components/FleetView/OverviewTab.tsx +++ b/frontend/src/components/FleetView/OverviewTab.tsx @@ -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} /> ))}
diff --git a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx index b771228c..fa32cc88 100644 --- a/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx +++ b/frontend/src/components/FleetView/__tests__/NodeCard.test.tsx @@ -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(); - 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(); + + 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(); + + 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 () => { diff --git a/frontend/src/components/FleetView/__tests__/NodeDetailsSheet.test.tsx b/frontend/src/components/FleetView/__tests__/NodeDetailsSheet.test.tsx new file mode 100644 index 00000000..8aa9adf4 --- /dev/null +++ b/frontend/src/components/FleetView/__tests__/NodeDetailsSheet.test.tsx @@ -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 }) => ( +
labels for {nodeId} · editable={String(canEdit)}
+ ), +})); + +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 { + 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 { + 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> = {}) { + 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(); + 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( + , + ); + 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(); + 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( + , + ); + 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(); + 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(); + expect(screen.getByText('2 capabilities advertised (show)')).toBeInTheDocument(); + }); + + it('returns null when no node is selected', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('converts FleetNode seconds-based timestamps correctly, not decades off', () => { + render( + , + ); + // "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(); + 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(); + 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(); + expect(screen.getByText('Up to date')).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx b/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx index b9448b09..742c0040 100644 --- a/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx +++ b/frontend/src/components/FleetView/__tests__/OverviewTab.test.tsx @@ -37,6 +37,7 @@ function props(overrides: Partial> = {} onNavigateToNode: vi.fn(), onOpenNodeNetworking: vi.fn(), networkingByNode: new Map(), + onOpenNodeDetails: vi.fn(), updatingNodeId: null, topologyMode: 'hub' as const, onTopologyModeChange: vi.fn(), diff --git a/frontend/src/components/mobile/MobileFleet.tsx b/frontend/src/components/mobile/MobileFleet.tsx index ab88abda..6eefb642 100644 --- a/frontend/src/components/mobile/MobileFleet.tsx +++ b/frontend/src/components/mobile/MobileFleet.tsx @@ -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({
onInspectNode(node.id)}>Inspect + setDetailsOpen(true)}>Details {canCordon ? ( setConfirmOpen(true)}> {node.cordoned ? 'Uncordon' : 'Drain'} @@ -271,6 +276,14 @@ function NodeDetail({ confirming={submitting} onConfirm={handleCordon} /> + +
); } diff --git a/frontend/src/context/NodeContext.tsx b/frontend/src/context/NodeContext.tsx index 80397e45..2063c379 100644 --- a/frontend/src/context/NodeContext.tsx +++ b/frontend/src/context/NodeContext.tsx @@ -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