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.
This commit is contained in:
Anso
2026-04-19 12:15:31 -04:00
committed by GitHub
parent aa181aa932
commit e721742560
5 changed files with 236 additions and 58 deletions
+6 -2
View File
@@ -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.
<Frame>
<img src="/images/multi-node/node-switcher-dropdown.png" alt="Node switcher dropdown expanded showing available nodes" />
<img src="/images/multi-node/node-switcher-dropdown.png" alt="Node switcher popover listing connected nodes with type, version, and active-row accent" />
</Frame>
## Per-node scheduling and update indicators
+12 -43
View File
@@ -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<Node[]>([]);
@@ -344,7 +344,7 @@ export default function EditorLayout() {
// Notifications & Settings state
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
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<string | null>(null);
@@ -1860,45 +1860,14 @@ export default function EditorLayout() {
</div>
</div>
{/* Node Switcher */}
{nodes.length > 1 && (
<div className="px-4 pt-2 pb-0">
<Select
value={activeNode?.id?.toString() || ''}
onValueChange={(val) => {
const node = nodes.find(n => n.id === parseInt(val));
if (node) setActiveNode(node);
}}
>
<SelectTrigger className="w-full h-9 text-sm">
<div className="flex items-center gap-2">
<Server className="w-3.5 h-3.5 text-muted-foreground" />
<SelectValue placeholder="Select node" />
</div>
</SelectTrigger>
<SelectContent>
{nodes.map(node => {
const meta = nodeMeta.get(node.id);
return (
<SelectItem key={node.id} value={node.id.toString()}>
<div className="flex items-center gap-2 w-full">
<div className={`w-2 h-2 rounded-full shrink-0 ${node.status === 'online' ? 'bg-success' :
node.status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
}`} />
<span>{node.name}</span>
{isValidVersion(meta?.version) && (
<span className="font-mono text-[10px] tabular-nums text-muted-foreground/60 ml-auto">
v{meta.version}
</span>
)}
</div>
</SelectItem>
);
})}
</SelectContent>
</Select>
</div>
)}
<div className="px-4 pt-2 pb-0">
<NodeSwitcher
onManageNodes={() => {
setSettingsInitialSection('nodes');
setSettingsModalOpen(true);
}}
/>
</div>
{/* Create Stack & Scan Buttons */}
{can('stack:create') && <div className="p-4 flex gap-2">
+4 -13
View File
@@ -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 || '-')}
</TableCell>
@@ -583,7 +574,7 @@ export function NodeManager() {
<Tooltip>
<TooltipTrigger>
<span className="text-xs text-muted-foreground">
next {formatRelativeTime(summary.next_run_at)}
next {formatTimeUntil(summary.next_run_at)}
</span>
</TooltipTrigger>
<TooltipContent>
@@ -789,7 +780,7 @@ export function NodeManager() {
</div>
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">
Expires <span className="font-mono tabular-nums">{formatRelativeTime(activeEnrollment.enrollment.expiresAt)}</span> from now.
Expires <span className="font-mono tabular-nums">{formatTimeUntil(activeEnrollment.enrollment.expiresAt)}</span> from now.
</p>
<Button size="sm" variant="outline" className="gap-1" onClick={copyEnrollment}>
{enrollmentCopied ? <Check className="w-3.5 h-3.5 text-success" /> : <Copy className="w-3.5 h-3.5" />}
+194
View File
@@ -0,0 +1,194 @@
import { useState } from 'react';
import {
ChevronsUpDown,
Star,
Settings2,
CircleDashed,
} from 'lucide-react';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { cn } from '@/lib/utils';
import { useNodes, type Node, type NodeMode } from '@/context/NodeContext';
import { formatTimeAgo } from '@/lib/relativeTime';
import { isValidVersion } from '@/lib/version';
interface NodeSwitcherProps {
onManageNodes: () => void;
}
function dotClass(status: Node['status']): string {
if (status === 'online') {
return 'bg-success shadow-[0_0_0_3px_color-mix(in_oklch,var(--success)_20%,transparent)]';
}
if (status === 'offline') return 'bg-destructive';
return 'bg-muted-foreground/40';
}
function typeLabel(type: Node['type'], mode: NodeMode | undefined): string {
if (type === 'local') return 'Local';
if (mode === 'pilot_agent') return 'Agent';
return 'Remote';
}
export function NodeSwitcher({ onManageNodes }: NodeSwitcherProps) {
const { nodes, activeNode, setActiveNode, nodeMeta, isLoading } = useNodes();
const [open, setOpen] = useState(false);
const hasNodes = nodes.length > 0;
const hasMultiple = nodes.length > 1;
const kickerType = activeNode
? typeLabel(activeNode.type, activeNode.mode).toUpperCase()
: hasNodes
? 'UNKNOWN'
: 'LOADING';
const triggerContent = (
<div
className={cn(
'flex w-full items-center gap-3 rounded-md border border-card-border/60 bg-card/40 px-3.5 py-2.5 text-left transition-colors',
hasMultiple && 'cursor-pointer hover:bg-accent focus-visible:bg-accent focus-visible:outline-none',
!hasMultiple && 'cursor-default',
)}
>
{activeNode ? (
<span
aria-hidden
className={cn('h-2 w-2 flex-shrink-0 rounded-full', dotClass(activeNode.status))}
/>
) : (
<CircleDashed
className="h-3 w-3 flex-shrink-0 text-stat-icon"
strokeWidth={1.5}
aria-hidden
/>
)}
<div className="min-w-0 flex-1">
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
Node · {kickerType}
</div>
<div className="mt-0.5 truncate font-display text-base italic leading-none text-stat-value">
{activeNode?.name ?? (isLoading ? '—' : 'No node')}
</div>
</div>
{hasMultiple ? (
<ChevronsUpDown
className="h-3.5 w-3.5 flex-shrink-0 text-stat-icon"
strokeWidth={1.5}
aria-hidden
/>
) : null}
</div>
);
if (!hasMultiple) {
return triggerContent;
}
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button type="button" className="w-full" aria-label="Switch node">
{triggerContent}
</button>
</PopoverTrigger>
<PopoverContent
align="start"
sideOffset={8}
className="overflow-hidden rounded-md p-0"
style={{ width: 'var(--radix-popover-trigger-width)', minWidth: '260px' }}
>
<div className="relative overflow-hidden">
<div className="pointer-events-none absolute inset-0 bg-gradient-to-r from-brand/[0.05] via-transparent to-transparent" />
<div className="absolute inset-y-0 left-0 w-[2px] bg-brand/60" />
<div className="relative flex items-center justify-between px-5 py-3.5">
<div className="flex items-baseline gap-2.5">
<span className="font-display text-xl italic leading-none text-stat-value">
Connected
</span>
<span className="font-mono text-[11px] uppercase tracking-[0.14em] tabular-nums text-brand">
{nodes.length} nodes
</span>
</div>
</div>
</div>
<div className="max-h-[320px] overflow-y-auto border-t border-card-border/60">
{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 (
<button
key={node.id}
type="button"
onClick={() => {
setActiveNode(node);
setOpen(false);
}}
aria-current={isActive ? 'true' : undefined}
className={cn(
'group relative flex w-full items-center gap-3 px-5 py-2.5 text-left transition-colors',
'hover:bg-accent focus-visible:bg-accent focus-visible:outline-none',
)}
>
{isActive ? (
<div
className="pointer-events-none absolute inset-y-0 left-0 w-[3px] bg-brand"
aria-hidden
/>
) : null}
<span
aria-hidden
className={cn('h-2 w-2 flex-shrink-0 rounded-full', dotClass(node.status))}
/>
<div className="min-w-0 flex-1">
<div
className={cn(
'truncate text-sm leading-snug',
isActive ? 'font-medium text-stat-value' : 'text-stat-value',
)}
>
{node.name}
</div>
<div className="mt-0.5 truncate font-mono text-[10px] uppercase tracking-[0.14em] text-stat-subtitle">
{metaParts.join(' · ')}
</div>
</div>
{node.is_default ? (
<Star
className="h-3 w-3 flex-shrink-0 fill-brand text-brand"
strokeWidth={1.5}
aria-label="Default node"
/>
) : null}
</button>
);
})}
</div>
<div className="border-t border-card-border/60">
<button
type="button"
onClick={() => {
setOpen(false);
onManageNodes();
}}
className="flex w-full items-center gap-2.5 px-5 py-2.5 text-left text-sm text-stat-value transition-colors hover:bg-accent focus-visible:bg-accent focus-visible:outline-none"
>
<Settings2 className="h-4 w-4 text-stat-icon" strokeWidth={1.5} />
<span className="flex-1 truncate">Manage nodes</span>
</button>
</div>
</PopoverContent>
</Popover>
);
}
+20
View File
@@ -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`;
}