mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 09:46:47 +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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user