From e7217425605df55911405cc51cc48fca200b6622 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 19 Apr 2026 12:15:31 -0400 Subject: [PATCH] feat(ui): redesign node switcher as sidebar identity anchor (#694) Replace the inline Select in the sidebar with a dedicated NodeSwitcher component that always renders as an identity card, regardless of node count. With two or more nodes it opens a Popover listing every node with status dot, type, version, last-seen metadata, and an active-row accent rail, matching the design language of the user menu and notification panel. Extract the relative-time formatters out of NodeManager into a shared @/lib/relativeTime module with formatTimeUntil and formatTimeAgo, and add a 'just now' / '<1m' branch so fresh heartbeats and imminent runs read naturally. --- docs/features/multi-node.mdx | 8 +- frontend/src/components/EditorLayout.tsx | 55 ++----- frontend/src/components/NodeManager.tsx | 17 +- frontend/src/components/NodeSwitcher.tsx | 194 +++++++++++++++++++++++ frontend/src/lib/relativeTime.ts | 20 +++ 5 files changed, 236 insertions(+), 58 deletions(-) create mode 100644 frontend/src/components/NodeSwitcher.tsx create mode 100644 frontend/src/lib/relativeTime.ts diff --git a/docs/features/multi-node.mdx b/docs/features/multi-node.mdx index 55f54474..02295ae3 100644 --- a/docs/features/multi-node.mdx +++ b/docs/features/multi-node.mdx @@ -56,10 +56,14 @@ Click the **wifi icon** (Test Connection) on any node row at any time to re-chec ## Switching between nodes -The **node switcher** dropdown in the top-left of the sidebar shows the currently active node and its Sencho version. Click it to switch to any registered node. All views (dashboard stats, stack list, editor, resources, logs) immediately reflect the selected node. +The **node switcher** sits at the top of the sidebar and acts as a persistent identity anchor. It shows the active node's status dot, type (`LOCAL`, `REMOTE`, or `AGENT`), and name, so you always know which node you are viewing. + +When two or more nodes are registered, clicking the switcher opens a popover listing every connected node. Each row includes a status dot, name, and a metadata line with the node type, version, and (for pilot agents) the time since the agent last checked in. The active node is marked with a cyan accent rail, and a filled star calls out the default node. + +Click any row to switch. All views (dashboard stats, stack list, editor, resources, logs) immediately reflect the selected node. A **Manage nodes** link at the bottom of the popover opens the Nodes settings page to add, edit, or remove nodes. - Node switcher dropdown expanded showing available nodes + Node switcher popover listing connected nodes with type, version, and active-row accent ## Per-node scheduling and update indicators diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 03b4bf34..9f5579d0 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -20,7 +20,7 @@ import { springs } from '@/lib/motion'; import { Highlight, HighlightItem } from './animate-ui/primitives/effects/highlight'; import { CursorProvider, Cursor, CursorContainer, CursorFollow } from '@/components/animate-ui/primitives/animate/cursor'; import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; -import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Server, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react'; +import { Plus, Trash2, Play, Square, Save, Terminal, RotateCw, CloudDownload, Pencil, X, Home, ExternalLink, MoreVertical, BellRing, Rocket, HardDrive, ScrollText, Activity, Radar, Undo2, RefreshCw, Download, Clock, Menu, FolderSearch, Loader2, Tag, Check, ChevronDown, GitBranch, FileCode2, ShieldCheck, ArrowUpRight, Copy } from 'lucide-react'; import type { LucideIcon } from 'lucide-react'; import { LabelPill, LabelDot } from './LabelPill'; import { type Label as StackLabel } from './label-types'; @@ -28,7 +28,6 @@ import { LabelAssignPopover } from './LabelAssignPopover'; import { UserProfileDropdown } from './UserProfileDropdown'; import { NotificationPanel } from './NotificationPanel'; import { apiFetch, fetchForNode } from '@/lib/api'; -import { isValidVersion } from '@/lib/version'; import { toast } from '@/components/ui/toast-store'; import { Label } from './ui/label'; import { Command, CommandInput, CommandList, CommandItem } from './ui/command'; @@ -59,6 +58,7 @@ import AutoUpdateReadinessView from './AutoUpdateReadinessView'; import { SecurityHistoryView } from './SecurityHistoryView'; import { SENCHO_NAVIGATE_EVENT } from './NodeManager'; import type { SenchoNavigateDetail } from './NodeManager'; +import { NodeSwitcher } from './NodeSwitcher'; import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; import type { SenchoOpenLogsDetail } from '@/lib/events'; import { useNodes } from '@/context/NodeContext'; @@ -185,7 +185,7 @@ export default function EditorLayout() { } }; }, []); - const { nodes, activeNode, setActiveNode, nodeMeta } = useNodes(); + const { nodes, activeNode, setActiveNode } = useNodes(); // Stable ref so notification callbacks always read the latest nodes list // without needing nodes in their dependency arrays (which would cause loops). const nodesRef = useRef([]); @@ -344,7 +344,7 @@ export default function EditorLayout() { // Notifications & Settings state const [notifications, setNotifications] = useState([]); const [settingsModalOpen, setSettingsModalOpen] = useState(false); - const [settingsInitialSection, setSettingsInitialSection] = useState<'account' | 'labels'>('account'); + const [settingsInitialSection, setSettingsInitialSection] = useState<'account' | 'labels' | 'nodes'>('account'); const [alertSheetOpen, setAlertSheetOpen] = useState(false); const [alertSheetStack, setAlertSheetStack] = useState(''); const [autoHealStackName, setAutoHealStackName] = useState(null); @@ -1860,45 +1860,14 @@ export default function EditorLayout() { - {/* Node Switcher */} - {nodes.length > 1 && ( -
- -
- )} +
+ { + setSettingsInitialSection('nodes'); + setSettingsModalOpen(true); + }} + /> +
{/* Create Stack & Scan Buttons */} {can('stack:create') &&
diff --git a/frontend/src/components/NodeManager.tsx b/frontend/src/components/NodeManager.tsx index 9bafd9c0..fd417259 100644 --- a/frontend/src/components/NodeManager.tsx +++ b/frontend/src/components/NodeManager.tsx @@ -15,6 +15,7 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/t import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select'; import { Combobox } from './ui/combobox'; import { Plus, Trash2, Wifi, WifiOff, Star, Pencil, Monitor, Globe, Copy, KeyRound, Check, AlertTriangle, Calendar, RefreshCw, Terminal } from 'lucide-react'; +import { formatTimeUntil, formatTimeAgo } from '@/lib/relativeTime'; interface NodeSchedulingSummary { active_tasks: number; @@ -29,16 +30,6 @@ export interface SenchoNavigateDetail { nodeId?: number; } -function formatRelativeTime(timestamp: number): string { - const diff = timestamp - Date.now(); - if (diff < 0) return 'overdue'; - const mins = Math.floor(diff / 60000); - if (mins < 60) return `${mins}m`; - const hrs = Math.floor(mins / 60); - if (hrs < 24) return `${hrs}h`; - return `${Math.floor(hrs / 24)}d`; -} - interface NodeFormData { name: string; type: 'local' | 'remote'; @@ -562,7 +553,7 @@ export function NodeManager() { ? 'docker.sock' : node.mode === 'pilot_agent' ? (node.pilot_last_seen - ? `tunnel (seen ${formatRelativeTime(node.pilot_last_seen + 60_000)} ago)` + ? `tunnel (seen ${formatTimeAgo(node.pilot_last_seen)})` : 'tunnel (waiting)') : (node.api_url || '-')} @@ -583,7 +574,7 @@ export function NodeManager() { - next {formatRelativeTime(summary.next_run_at)} + next {formatTimeUntil(summary.next_run_at)} @@ -789,7 +780,7 @@ export function NodeManager() {

- Expires {formatRelativeTime(activeEnrollment.enrollment.expiresAt)} from now. + Expires {formatTimeUntil(activeEnrollment.enrollment.expiresAt)} from now.

+ + +
+
+
+
+
+ + Connected + + + {nodes.length} nodes + +
+
+
+ +
+ {nodes.map((node) => { + const meta = nodeMeta.get(node.id); + const isActive = activeNode?.id === node.id; + const version = isValidVersion(meta?.version) ? meta.version : null; + const typePart = typeLabel(node.type, node.mode).toUpperCase(); + const metaParts: string[] = [typePart]; + if (node.type === 'remote' && node.mode === 'pilot_agent') { + metaParts.push( + node.pilot_last_seen + ? `SEEN ${formatTimeAgo(node.pilot_last_seen).toUpperCase()}` + : 'WAITING', + ); + } + if (version) metaParts.push(`v${version}`); + return ( + + ); + })} +
+ +
+ +
+ + + ); +} diff --git a/frontend/src/lib/relativeTime.ts b/frontend/src/lib/relativeTime.ts new file mode 100644 index 00000000..3bd8c04f --- /dev/null +++ b/frontend/src/lib/relativeTime.ts @@ -0,0 +1,20 @@ +export function formatTimeUntil(timestamp: number): string { + const diff = timestamp - Date.now(); + if (diff < 0) return 'overdue'; + if (diff < 60_000) return '<1m'; + const mins = Math.floor(diff / 60_000); + if (mins < 60) return `${mins}m`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h`; + return `${Math.floor(hrs / 24)}d`; +} + +export function formatTimeAgo(timestamp: number): string { + const diff = Date.now() - timestamp; + if (diff < 60_000) return 'just now'; + const mins = Math.floor(diff / 60_000); + if (mins < 60) return `${mins}m ago`; + const hrs = Math.floor(mins / 60); + if (hrs < 24) return `${hrs}h ago`; + return `${Math.floor(hrs / 24)}d ago`; +}