mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-06 17:08:10 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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" />}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
Reference in New Issue
Block a user