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