Minor UI Rework + Introduction of Operations view.
This commit is contained in:
@@ -6,6 +6,9 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Mikrotik Manager</title>
|
||||
<meta name="description" content="Self-hosted Mikrotik network management platform" />
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Geist:wght@400;500;600;700&family=Geist+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.10.8-beta",
|
||||
"version": "0.10.9-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -8,7 +8,7 @@ export default function AppLayout() {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="flex h-screen bg-gray-50 dark:bg-slate-900 overflow-hidden">
|
||||
<div className="flex h-screen overflow-hidden" style={{ background: 'var(--bg)' }}>
|
||||
{/* Mobile backdrop */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
@@ -22,7 +22,7 @@ export default function AppLayout() {
|
||||
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
|
||||
<TopBar onMenuClick={() => setSidebarOpen((o) => !o)} />
|
||||
<SecurityBanners />
|
||||
<main className="flex-1 overflow-auto p-3 sm:p-6">
|
||||
<main className="flex-1 overflow-auto" style={{ padding: '22px 28px' }}>
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -40,8 +40,8 @@ const networkServicesSubItems = [
|
||||
{ to: '/network-services/dhcp', icon: Server, label: 'DHCP' },
|
||||
{ to: '/network-services/dns', icon: Globe, label: 'DNS' },
|
||||
{ to: '/network-services/ntp', icon: Clock, label: 'NTP' },
|
||||
{ to: '/network-services/wireguard', icon: Shield, label: 'WireGuard' },
|
||||
{ to: '/network-services/syslog', icon: FileText, label: 'Syslog' },
|
||||
{ to: '/network-services/wireguard', icon: Shield, label: 'WireGuard' },
|
||||
{ to: '/network-services/syslog', icon: FileText, label: 'Syslog' },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -49,6 +49,43 @@ interface SidebarProps {
|
||||
onMobileClose: () => void;
|
||||
}
|
||||
|
||||
function NavItem({
|
||||
to,
|
||||
icon: Icon,
|
||||
label,
|
||||
isCollapsed,
|
||||
onClick,
|
||||
end,
|
||||
}: {
|
||||
to: string;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
isCollapsed: boolean;
|
||||
onClick: () => void;
|
||||
end?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
end={end}
|
||||
title={isCollapsed ? label : undefined}
|
||||
onClick={onClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-[10px] rounded-[6px] text-[13px] font-medium transition-colors duration-150 relative',
|
||||
isCollapsed ? 'md:justify-center md:px-2 px-[10px] py-[7px]' : 'px-[10px] py-[7px]',
|
||||
isActive
|
||||
? 'bg-surface-3 text-ink font-semibold border-l-2 border-accent pl-[8px]'
|
||||
: 'text-ink-2 hover:bg-surface-3 hover:text-ink border-l-2 border-transparent'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-[15px] h-[15px] flex-shrink-0" />
|
||||
<span className={clsx(isCollapsed && 'md:hidden')}>{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const location = useLocation();
|
||||
@@ -63,6 +100,8 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const hasRouters = devices.some(d => d.device_type === 'router');
|
||||
const hasWireless = devices.some(d => d.device_type === 'wireless_ap');
|
||||
|
||||
const onlineCount = devices.filter(d => d.status === 'online').length;
|
||||
|
||||
const switchesActive = location.pathname.startsWith('/switches');
|
||||
const [switchesOpen, setSwitchesOpen] = useState(switchesActive);
|
||||
const effectiveSwitchesOpen = switchesOpen || switchesActive;
|
||||
@@ -79,77 +118,90 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const [networkServicesOpen, setNetworkServicesOpen] = useState(networkServicesActive);
|
||||
const effectiveNetworkServicesOpen = networkServicesOpen || networkServicesActive;
|
||||
|
||||
// On mobile the sidebar is always expanded (never icon-only)
|
||||
const isCollapsed = collapsed;
|
||||
|
||||
const handleNavClick = () => {
|
||||
// Close mobile drawer when navigating
|
||||
onMobileClose();
|
||||
};
|
||||
|
||||
// Group header button (Switches / Routers / Wireless / Network Services)
|
||||
const GroupHeader = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
isActive,
|
||||
isOpen,
|
||||
onToggle,
|
||||
}: {
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
isActive: boolean;
|
||||
isOpen: boolean;
|
||||
onToggle: () => void;
|
||||
}) => (
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-[10px] px-[10px] py-[7px] rounded-[6px] text-[13px] font-medium transition-colors duration-150 border-l-2 border-transparent',
|
||||
isActive ? 'text-accent' : 'text-ink-2 hover:bg-surface-3 hover:text-ink'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-[15px] h-[15px] flex-shrink-0" />
|
||||
<span className="flex-1 text-left">{label}</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', isOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
);
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
// Base styles
|
||||
'flex-shrink-0 bg-slate-100 dark:bg-slate-900 border-r border-gray-200 dark:border-slate-700 flex flex-col transition-all duration-200',
|
||||
// Mobile: fixed drawer that slides in/out
|
||||
'flex-shrink-0 flex flex-col transition-all duration-200',
|
||||
'fixed inset-y-0 left-0 z-50',
|
||||
mobileOpen ? 'translate-x-0' : '-translate-x-full',
|
||||
// Desktop: static in flex layout, normal collapse behaviour
|
||||
'md:relative md:translate-x-0',
|
||||
isCollapsed ? 'md:w-14' : 'md:w-56',
|
||||
// Mobile always full width
|
||||
'w-64',
|
||||
)}
|
||||
style={{ background: 'var(--surface)', borderRight: '1px solid var(--line)' }}
|
||||
>
|
||||
{/* Logo */}
|
||||
{/* Brand block */}
|
||||
<div
|
||||
className={clsx(
|
||||
'flex items-center border-b border-gray-200 dark:border-slate-700 py-5',
|
||||
isCollapsed ? 'md:justify-center md:px-2 px-4 gap-3' : 'gap-3 px-4'
|
||||
'flex items-center py-[22px]',
|
||||
isCollapsed ? 'md:justify-center md:px-2 px-4 gap-[10px]' : 'gap-[10px] px-4',
|
||||
'border-b'
|
||||
)}
|
||||
style={{ borderColor: 'var(--line)' }}
|
||||
>
|
||||
<div className="w-8 h-8 bg-blue-600 rounded-lg flex items-center justify-center flex-shrink-0">
|
||||
<Network className="w-4 h-4 text-white" />
|
||||
<div
|
||||
className="w-[30px] h-[30px] rounded-[7px] flex items-center justify-center flex-shrink-0"
|
||||
style={{ background: 'linear-gradient(135deg, var(--accent), var(--accent-2))' }}
|
||||
>
|
||||
<Network className="w-4 h-4" style={{ color: 'var(--accent-fg)' }} />
|
||||
</div>
|
||||
{/* Always show text on mobile; respect collapsed on desktop */}
|
||||
<div className={clsx(isCollapsed && 'md:hidden')}>
|
||||
<div className="text-sm font-bold text-gray-900 dark:text-white leading-none">Mikrotik</div>
|
||||
<div className="text-xs text-gray-500 dark:text-slate-400 mt-0.5 leading-none">Manager</div>
|
||||
<div className="text-[10px] text-gray-400 dark:text-slate-500 mt-1 leading-none">{APP_VERSION}</div>
|
||||
<div className="text-[14px] font-semibold leading-none" style={{ color: 'var(--ink)' }}>Mikrotik Manager</div>
|
||||
<div className="text-[11px] leading-none mt-[3px]" style={{ color: 'var(--ink-3)' }}>{APP_VERSION}</div>
|
||||
</div>
|
||||
{/* Mobile close button */}
|
||||
<button
|
||||
onClick={onMobileClose}
|
||||
className="ml-auto p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-slate-300 md:hidden"
|
||||
className="ml-auto p-1.5 rounded-lg md:hidden"
|
||||
style={{ color: 'var(--ink-4)' }}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-2 py-3 space-y-0.5 overflow-y-auto">
|
||||
{navItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
title={isCollapsed ? label : undefined}
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isCollapsed ? 'md:justify-center md:px-2 px-3 py-2.5 gap-3' : 'gap-3 px-3 py-2.5',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-4 h-4 flex-shrink-0" />
|
||||
<span className={clsx(isCollapsed && 'md:hidden')}>{label}</span>
|
||||
</NavLink>
|
||||
<nav className="flex-1 px-[14px] py-3 space-y-[1px] overflow-y-auto">
|
||||
{navItems.map(({ to, icon, label }) => (
|
||||
<NavItem key={to} to={to} icon={icon} label={label} isCollapsed={isCollapsed} onClick={handleNavClick} />
|
||||
))}
|
||||
|
||||
{/* Divider before device-type groups */}
|
||||
{(hasSwitches || hasRouters || hasWireless) && !isCollapsed && (
|
||||
<div className="my-[6px] mx-2" style={{ height: 1, background: 'var(--line)' }} />
|
||||
)}
|
||||
|
||||
{/* Switches group */}
|
||||
{hasSwitches && (
|
||||
isCollapsed ? (
|
||||
@@ -159,50 +211,28 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
'hidden md:flex items-center justify-center rounded-[6px] text-[13px] font-medium transition-colors duration-150 px-2 py-[7px] border-l-2',
|
||||
isActive || switchesActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
? 'bg-surface-3 text-ink border-accent'
|
||||
: 'text-ink-2 hover:bg-surface-3 hover:text-ink border-transparent'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Layers className="w-4 h-4 flex-shrink-0" />
|
||||
<Layers className="w-[15px] h-[15px] flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setSwitchesOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
switchesActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Layers className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Switches</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveSwitchesOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
<GroupHeader
|
||||
icon={Layers}
|
||||
label="Switches"
|
||||
isActive={switchesActive}
|
||||
isOpen={effectiveSwitchesOpen}
|
||||
onToggle={() => setSwitchesOpen(o => !o)}
|
||||
/>
|
||||
{effectiveSwitchesOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{switchSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
<div className="ml-4 pl-3 space-y-[1px]" style={{ borderLeft: '1px solid var(--line)' }}>
|
||||
{switchSubItems.map(({ to, icon, label }) => (
|
||||
<NavItem key={to} to={to} end icon={icon} label={label} isCollapsed={false} onClick={handleNavClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -219,50 +249,28 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
'hidden md:flex items-center justify-center rounded-[6px] text-[13px] font-medium transition-colors duration-150 px-2 py-[7px] border-l-2',
|
||||
isActive || routersActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
? 'bg-surface-3 text-ink border-accent'
|
||||
: 'text-ink-2 hover:bg-surface-3 hover:text-ink border-transparent'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Router className="w-4 h-4 flex-shrink-0" />
|
||||
<Router className="w-[15px] h-[15px] flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setRoutersOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
routersActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Router className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Routers</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveRoutersOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
<GroupHeader
|
||||
icon={Router}
|
||||
label="Routers"
|
||||
isActive={routersActive}
|
||||
isOpen={effectiveRoutersOpen}
|
||||
onToggle={() => setRoutersOpen(o => !o)}
|
||||
/>
|
||||
{effectiveRoutersOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{routerSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
<div className="ml-4 pl-3 space-y-[1px]" style={{ borderLeft: '1px solid var(--line)' }}>
|
||||
{routerSubItems.map(({ to, icon, label }) => (
|
||||
<NavItem key={to} to={to} end icon={icon} label={label} isCollapsed={false} onClick={handleNavClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -279,148 +287,123 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
'hidden md:flex items-center justify-center rounded-[6px] text-[13px] font-medium transition-colors duration-150 px-2 py-[7px] border-l-2',
|
||||
isActive || wirelessActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
? 'bg-surface-3 text-ink border-accent'
|
||||
: 'text-ink-2 hover:bg-surface-3 hover:text-ink border-transparent'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Wifi className="w-4 h-4 flex-shrink-0" />
|
||||
<Wifi className="w-[15px] h-[15px] flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setWirelessOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
wirelessActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
>
|
||||
<Wifi className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Wireless</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveWirelessOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
<GroupHeader
|
||||
icon={Wifi}
|
||||
label="Wireless"
|
||||
isActive={wirelessActive}
|
||||
isOpen={effectiveWirelessOpen}
|
||||
onToggle={() => setWirelessOpen(o => !o)}
|
||||
/>
|
||||
{effectiveWirelessOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{wirelessSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
<div className="ml-4 pl-3 space-y-[1px]" style={{ borderLeft: '1px solid var(--line)' }}>
|
||||
{wirelessSubItems.map(({ to, icon, label }) => (
|
||||
<NavItem key={to} to={to} end icon={icon} label={label} isCollapsed={false} onClick={handleNavClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
{/* Network Services group — always shown */}
|
||||
{isCollapsed ? (
|
||||
<NavLink
|
||||
to="/network-services"
|
||||
title="Network Services"
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-lg text-sm font-medium transition-colors duration-150 px-2 py-2.5',
|
||||
isActive || networkServicesActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Network className="w-4 h-4 flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setNetworkServicesOpen(o => !o)}
|
||||
className={clsx(
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
networkServicesActive
|
||||
? 'text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)}
|
||||
|
||||
{/* Network Services group */}
|
||||
<div className="mt-[6px]" style={{ height: 1, background: 'var(--line)', marginInline: '8px' }} />
|
||||
<div className="mt-[6px]">
|
||||
{isCollapsed ? (
|
||||
<NavLink
|
||||
to="/network-services"
|
||||
title="Network Services"
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'hidden md:flex items-center justify-center rounded-[6px] text-[13px] font-medium transition-colors duration-150 px-2 py-[7px] border-l-2',
|
||||
isActive || networkServicesActive
|
||||
? 'bg-surface-3 text-ink border-accent'
|
||||
: 'text-ink-2 hover:bg-surface-3 hover:text-ink border-transparent'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Network className="w-4 h-4 flex-shrink-0" />
|
||||
<span className="flex-1 text-left">Network Services</span>
|
||||
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', effectiveNetworkServicesOpen ? 'rotate-0' : '-rotate-90')} />
|
||||
</button>
|
||||
{effectiveNetworkServicesOpen && (
|
||||
<div className="ml-4 pl-3 border-l border-gray-300 dark:border-slate-600 space-y-0.5">
|
||||
{networkServicesSubItems.map(({ to, icon: Icon, label }) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
end
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center gap-2.5 px-2.5 py-2 rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<Network className="w-[15px] h-[15px] flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<GroupHeader
|
||||
icon={Network}
|
||||
label="Network Services"
|
||||
isActive={networkServicesActive}
|
||||
isOpen={effectiveNetworkServicesOpen}
|
||||
onToggle={() => setNetworkServicesOpen(o => !o)}
|
||||
/>
|
||||
{effectiveNetworkServicesOpen && (
|
||||
<div className="ml-4 pl-3 space-y-[1px]" style={{ borderLeft: '1px solid var(--line)' }}>
|
||||
{networkServicesSubItems.map(({ to, icon, label }) => (
|
||||
<NavItem key={to} to={to} end icon={icon} label={label} isCollapsed={false} onClick={handleNavClick} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
{/* Settings + collapse toggle */}
|
||||
<div className="px-2 py-3 border-t border-gray-200 dark:border-slate-700 space-y-0.5">
|
||||
<NavLink
|
||||
{/* Footer: poll status card + settings + collapse */}
|
||||
<div className="px-[14px] pb-3 space-y-1">
|
||||
{/* Poll status card */}
|
||||
{!isCollapsed && devices.length > 0 && (
|
||||
<div
|
||||
className="px-3 py-[10px] rounded-[8px] mb-2 text-[11.5px] leading-[1.45]"
|
||||
style={{ background: 'var(--surface-2)', color: 'var(--ink-3)' }}
|
||||
>
|
||||
<div className="flex items-center gap-[6px] mb-1">
|
||||
<span
|
||||
className="w-[6px] h-[6px] rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: 'var(--good)',
|
||||
boxShadow: '0 0 0 2px rgba(141,224,138,0.2), 0 0 8px rgba(141,224,138,0.5)',
|
||||
}}
|
||||
/>
|
||||
<span className="font-semibold text-[11.5px]" style={{ color: 'var(--ink-2)' }}>
|
||||
{onlineCount === devices.length ? 'All polls healthy' : `${onlineCount}/${devices.length} reachable`}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mono text-[10.5px]">{onlineCount}/{devices.length} online</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings link */}
|
||||
<NavItem
|
||||
to="/settings"
|
||||
title={isCollapsed ? 'Settings' : undefined}
|
||||
icon={Settings}
|
||||
label="Settings"
|
||||
isCollapsed={isCollapsed}
|
||||
onClick={handleNavClick}
|
||||
className={({ isActive }) =>
|
||||
clsx(
|
||||
'flex items-center rounded-lg text-sm font-medium transition-colors duration-150',
|
||||
isCollapsed ? 'md:justify-center md:px-2 px-3 py-2.5 gap-3' : 'gap-3 px-3 py-2.5',
|
||||
isActive
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-400 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-900 dark:hover:text-slate-100'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Settings className="w-4 h-4 flex-shrink-0" />
|
||||
<span className={clsx(isCollapsed && 'md:hidden')}>Settings</span>
|
||||
</NavLink>
|
||||
/>
|
||||
|
||||
{/* Collapse toggle — desktop only */}
|
||||
<button
|
||||
onClick={() => setCollapsed(c => !c)}
|
||||
title={isCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
className={clsx(
|
||||
'hidden md:flex w-full items-center rounded-lg text-sm font-medium transition-colors duration-150 text-gray-500 dark:text-slate-500 hover:bg-gray-200 dark:hover:bg-slate-700 hover:text-gray-700 dark:hover:text-slate-300',
|
||||
isCollapsed ? 'justify-center px-2 py-2.5' : 'gap-3 px-3 py-2.5'
|
||||
'hidden md:flex w-full items-center rounded-[6px] text-[13px] font-medium transition-colors duration-150',
|
||||
isCollapsed ? 'justify-center px-2 py-[7px]' : 'gap-[10px] px-[10px] py-[7px]',
|
||||
'border-l-2 border-transparent hover:bg-surface-3'
|
||||
)}
|
||||
style={{ color: 'var(--ink-4)' }}
|
||||
>
|
||||
{isCollapsed ? (
|
||||
<ChevronRight className="w-4 h-4 flex-shrink-0" />
|
||||
<ChevronRight className="w-[15px] h-[15px] flex-shrink-0" />
|
||||
) : (
|
||||
<><ChevronLeft className="w-4 h-4 flex-shrink-0" />Collapse</>
|
||||
<><ChevronLeft className="w-[15px] h-[15px] flex-shrink-0" />Collapse</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Sun, Moon, LogOut, User, Menu } from 'lucide-react';
|
||||
import { Sun, Moon, LogOut, Menu } from 'lucide-react';
|
||||
import { useAuthStore } from '../../store/authStore';
|
||||
import { useThemeStore } from '../../store/themeStore';
|
||||
import GlobalSearch from './GlobalSearch';
|
||||
@@ -18,37 +18,69 @@ export default function TopBar({ onMenuClick }: TopBarProps) {
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
const initial = (user?.username?.[0] ?? 'A').toUpperCase();
|
||||
|
||||
return (
|
||||
<header className="h-14 flex-shrink-0 flex items-center gap-2 sm:gap-4 px-3 sm:px-6 bg-white dark:bg-slate-800 border-b border-gray-200 dark:border-slate-700">
|
||||
<header
|
||||
className="h-[54px] flex-shrink-0 flex items-center gap-3 px-[22px]"
|
||||
style={{ background: 'var(--bg)', borderBottom: '1px solid var(--line)' }}
|
||||
>
|
||||
{/* Hamburger — mobile only */}
|
||||
<button
|
||||
onClick={onMenuClick}
|
||||
className="md:hidden p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors flex-shrink-0"
|
||||
className="md:hidden p-2 rounded-lg transition-colors flex-shrink-0"
|
||||
style={{ color: 'var(--ink-3)' }}
|
||||
aria-label="Open menu"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<GlobalSearch />
|
||||
{/* Search */}
|
||||
<div className="flex-1">
|
||||
<GlobalSearch />
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{/* Live indicator */}
|
||||
<span
|
||||
className="hidden sm:flex items-center gap-[6px] px-[10px] py-[4px] rounded-full text-[11px] mono"
|
||||
style={{ color: 'var(--ink-3)', border: '1px solid var(--line)' }}
|
||||
>
|
||||
<span
|
||||
className="w-[6px] h-[6px] rounded-full flex-shrink-0"
|
||||
style={{
|
||||
background: 'var(--good)',
|
||||
boxShadow: '0 0 0 2px rgba(141,224,138,0.2), 0 0 6px rgba(141,224,138,0.5)',
|
||||
}}
|
||||
/>
|
||||
live
|
||||
</span>
|
||||
|
||||
<div className="flex items-center gap-1 sm:gap-2 flex-shrink-0 ml-auto">
|
||||
{/* Theme toggle */}
|
||||
<button
|
||||
onClick={toggleTheme}
|
||||
className="p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
|
||||
className="p-[7px] rounded-lg transition-colors"
|
||||
style={{ color: 'var(--ink-3)' }}
|
||||
title={`Switch to ${theme === 'light' ? 'dark' : 'light'} mode`}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<Moon className="w-4 h-4" />
|
||||
) : (
|
||||
<Sun className="w-4 h-4" />
|
||||
)}
|
||||
{theme === 'light' ? <Moon className="w-4 h-4" /> : <Sun className="w-4 h-4" />}
|
||||
</button>
|
||||
|
||||
{/* User info — hide label on very small screens */}
|
||||
<div className="flex items-center gap-2 px-2 sm:px-3 py-1.5 rounded-lg bg-gray-100 dark:bg-slate-700">
|
||||
<User className="w-3.5 h-3.5 text-gray-500 dark:text-slate-400 flex-shrink-0" />
|
||||
<span className="hidden sm:inline text-sm font-medium text-gray-700 dark:text-slate-200">
|
||||
{/* User chip */}
|
||||
<div
|
||||
className="flex items-center gap-[8px] px-[10px] py-[4px] rounded-[6px]"
|
||||
style={{ background: 'var(--surface-2)' }}
|
||||
>
|
||||
<div
|
||||
className="w-[22px] h-[22px] rounded-full flex items-center justify-center text-[11px] font-bold flex-shrink-0"
|
||||
style={{
|
||||
background: 'linear-gradient(135deg, var(--accent), var(--accent-2))',
|
||||
color: 'var(--accent-fg)',
|
||||
}}
|
||||
>
|
||||
{initial}
|
||||
</div>
|
||||
<span className="hidden sm:inline text-[13px] font-medium" style={{ color: 'var(--ink-2)' }}>
|
||||
{user?.username}
|
||||
</span>
|
||||
</div>
|
||||
@@ -56,7 +88,8 @@ export default function TopBar({ onMenuClick }: TopBarProps) {
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="p-2 rounded-lg text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700 hover:text-red-500 dark:hover:text-red-400 transition-colors"
|
||||
className="p-[7px] rounded-lg transition-colors hover:text-bad"
|
||||
style={{ color: 'var(--ink-4)' }}
|
||||
title="Logout"
|
||||
>
|
||||
<LogOut className="w-4 h-4" />
|
||||
|
||||
@@ -49,20 +49,49 @@ interface BondEditForm {
|
||||
members: string[];
|
||||
}
|
||||
|
||||
function portColor(port: SwitchPort, selected: boolean): string {
|
||||
if (selected) return '#3b82f6';
|
||||
if (port.disabled) return '#64748b';
|
||||
if (!port.running) return '#ef4444';
|
||||
if (port.type === 'bond') return '#d97706'; // amber for bond logical interface
|
||||
return '#22c55e';
|
||||
function portState(port: SwitchPort): 'up' | 'down' | 'disabled' {
|
||||
if (port.disabled) return 'disabled';
|
||||
if (port.running) return 'up';
|
||||
return 'down';
|
||||
}
|
||||
|
||||
function portBorderColor(port: SwitchPort, selected: boolean): string {
|
||||
if (selected) return '#1d4ed8';
|
||||
if (port.disabled) return '#475569';
|
||||
if (!port.running) return '#dc2626';
|
||||
if (port.type === 'bond') return '#b45309';
|
||||
return '#16a34a';
|
||||
function PortTile({
|
||||
port, selected, hovered, isMember,
|
||||
onClick, onMouseEnter, onMouseLeave, onDoubleClick,
|
||||
}: {
|
||||
port: SwitchPort; selected: boolean; hovered: boolean; isMember?: boolean;
|
||||
onClick: (e: React.MouseEvent) => void;
|
||||
onMouseEnter: () => void; onMouseLeave: () => void; onDoubleClick: () => void;
|
||||
}) {
|
||||
const state = portState(port);
|
||||
const bg = selected ? 'var(--accent)' : state === 'up' ? 'var(--port-up-bg)' : state === 'down' ? 'var(--port-down-bg)' : 'var(--surface-3)';
|
||||
const border = isMember && !selected ? 'var(--warn)' : selected ? 'var(--accent)' : state === 'up' ? 'var(--port-up-border)' : state === 'down' ? 'var(--port-down-border)' : 'var(--line)';
|
||||
const textColor = selected ? '#ffffff' : state === 'up' ? 'var(--good)' : state === 'down' ? 'var(--bad)' : 'var(--ink-4)';
|
||||
const ledBg = selected ? '#ffffff' : state === 'up' ? 'var(--good)' : 'transparent';
|
||||
return (
|
||||
<div
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onMouseLeave={onMouseLeave}
|
||||
onDoubleClick={onDoubleClick}
|
||||
title={`${port.name}${port.comment ? ` — ${port.comment}` : ''}${port.speed ? ` · ${port.speed}` : ''}`}
|
||||
style={{
|
||||
width: 46, height: 46, borderRadius: 4, position: 'relative', flexShrink: 0,
|
||||
background: bg, border: `1px solid ${border}`,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
cursor: 'pointer', color: textColor,
|
||||
fontFamily: 'Geist Mono, monospace', fontSize: 10, fontWeight: 600,
|
||||
opacity: hovered ? 0.8 : 1, transition: 'opacity 0.1s',
|
||||
}}
|
||||
>
|
||||
<div style={{
|
||||
position: 'absolute', top: 4, right: 4, width: 5, height: 5, borderRadius: 999,
|
||||
background: ledBg,
|
||||
boxShadow: state === 'up' && !selected ? '0 0 6px var(--accent)' : 'none',
|
||||
}} />
|
||||
<span>{portLabel(port.name)}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function portLabel(name: string): string {
|
||||
@@ -167,26 +196,31 @@ function PortTrafficGraph({
|
||||
tx: Math.round(p.tx),
|
||||
}));
|
||||
|
||||
const rxPeak = trafficData.length > 0 ? Math.max(...trafficData.map(d => d.rx)) : 0;
|
||||
const txPeak = trafficData.length > 0 ? Math.max(...trafficData.map(d => d.tx)) : 0;
|
||||
|
||||
return (
|
||||
<div className="card p-5 flex flex-col h-full">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-4 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{portName} — Throughput
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 mb-3 shrink-0">
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>
|
||||
{portName} · throughput
|
||||
</div>
|
||||
<div className="mono text-[11px] mt-[2px]" style={{ color: 'var(--ink-3)' }}>
|
||||
{range} · 30s buckets
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<div className="flex gap-[4px]">
|
||||
{TRAFFIC_RANGES.map((r) => (
|
||||
<button
|
||||
key={r}
|
||||
onClick={() => onRangeChange(r)}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded font-medium transition-colors',
|
||||
range === r
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300 hover:bg-gray-200 dark:hover:bg-slate-600'
|
||||
)}
|
||||
className="mono text-[11px] px-[8px] py-[4px] rounded-[5px] transition-colors"
|
||||
style={{
|
||||
background: range === r ? 'var(--surface-3)' : 'transparent',
|
||||
color: range === r ? 'var(--ink)' : 'var(--ink-3)',
|
||||
border: '1px solid var(--line)',
|
||||
}}
|
||||
>
|
||||
{r}
|
||||
</button>
|
||||
@@ -194,33 +228,45 @@ function PortTrafficGraph({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-6 mb-3 shrink-0">
|
||||
<div>
|
||||
<div className="mono text-[10px] uppercase tracking-widest" style={{ color: 'var(--ink-4)' }}>RX peak</div>
|
||||
<div className="mono num-tab text-[18px] font-semibold" style={{ color: 'var(--accent)' }}>{formatBps(rxPeak)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mono text-[10px] uppercase tracking-widest" style={{ color: 'var(--ink-4)' }}>TX peak</div>
|
||||
<div className="mono num-tab text-[18px] font-semibold" style={{ color: 'var(--violet)' }}>{formatBps(txPeak)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex-1 min-h-[200px] flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
<div className="flex-1 min-h-[160px] flex items-center justify-center mono text-[12px]" style={{ color: 'var(--ink-4)' }}>
|
||||
Loading traffic data…
|
||||
</div>
|
||||
) : trafficData.length === 0 ? (
|
||||
<div className="flex-1 min-h-[200px] flex items-center justify-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
No traffic data yet for {portName} in the selected range
|
||||
<div className="flex-1 min-h-[160px] flex items-center justify-center mono text-[12px]" style={{ color: 'var(--ink-4)' }}>
|
||||
No traffic data for {portName} in this range
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 min-h-[200px]">
|
||||
<div className="flex-1 min-h-[160px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={trafficData} margin={{ top: 4, right: 8, left: 0, bottom: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10 }} interval="preserveStartEnd" />
|
||||
<YAxis tickFormatter={formatBps} tick={{ fontSize: 10 }} width={72} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--line)" opacity={0.5} />
|
||||
<XAxis dataKey="time" tick={{ fontSize: 10, fill: 'var(--ink-4)' }} interval="preserveStartEnd" />
|
||||
<YAxis tickFormatter={formatBps} tick={{ fontSize: 10, fill: 'var(--ink-4)' }} width={72} />
|
||||
<Tooltip
|
||||
formatter={(v) => formatBps(v as number)}
|
||||
contentStyle={{
|
||||
background: 'var(--color-surface)',
|
||||
border: '1px solid var(--color-border)',
|
||||
background: 'var(--surface)',
|
||||
border: '1px solid var(--line)',
|
||||
borderRadius: '8px',
|
||||
fontSize: '12px',
|
||||
color: 'var(--ink)',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||
<Line type="monotone" dataKey="rx" stroke="#3b82f6" name="RX (download)" dot={false} strokeWidth={2} />
|
||||
<Line type="monotone" dataKey="tx" stroke="#10b981" name="TX (upload)" dot={false} strokeWidth={2} />
|
||||
<Legend wrapperStyle={{ fontSize: 11 }} />
|
||||
<Line type="monotone" dataKey="rx" stroke="var(--accent)" name="RX" dot={false} strokeWidth={1.6} />
|
||||
<Line type="monotone" dataKey="tx" stroke="var(--violet)" name="TX" dot={false} strokeWidth={1.6} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
@@ -343,24 +389,23 @@ function PortInfoCard({ deviceId, portName }: { deviceId: number; portName: stri
|
||||
|
||||
const isSfp = d['sfp-module-present'] === 'true';
|
||||
|
||||
const infoRow = (label: string, value: string | undefined) => {
|
||||
const infoRow = (label: string, value: string | undefined, valueColor?: string) => {
|
||||
if (!value || value === '' || value === 'none' || value === '0') return null;
|
||||
const isStatus = label === 'Status';
|
||||
const statusColor = isStatus ? (value.includes('ok') || value === 'true' ? 'var(--good)' : value.includes('err') ? 'var(--bad)' : 'var(--ink-2)') : undefined;
|
||||
return (
|
||||
<div key={label} className="flex justify-between items-start py-1 border-b border-gray-100 dark:border-slate-700 last:border-0">
|
||||
<span className="text-xs text-gray-500 dark:text-slate-400 shrink-0 mr-2">{label}</span>
|
||||
<span className="text-xs font-medium text-gray-900 dark:text-white text-right font-mono">{value}</span>
|
||||
<div key={label} style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0', borderBottom: '1px solid var(--line-soft)' }}>
|
||||
<span style={{ color: 'var(--ink-3)', fontSize: 12 }}>{label}</span>
|
||||
<span className="mono" style={{ color: valueColor ?? statusColor ?? 'var(--ink-2)', fontSize: 12, textAlign: 'right' }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card p-4 overflow-y-auto">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Cpu className="w-4 h-4 text-blue-500" />
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">Port Information</h3>
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold mb-3" style={{ color: 'var(--ink)' }}>Port info</div>
|
||||
|
||||
<div className="space-y-0">
|
||||
<div>
|
||||
{infoRow('Status', d['status'])}
|
||||
{infoRow('Rate', d['rate'])}
|
||||
{infoRow('Full Duplex', d['full-duplex'])}
|
||||
@@ -372,8 +417,8 @@ function PortInfoCard({ deviceId, portName }: { deviceId: number; portName: stri
|
||||
|
||||
{isSfp && (
|
||||
<>
|
||||
<div className="mt-3 mb-2 pt-2 border-t border-gray-200 dark:border-slate-600">
|
||||
<span className="text-xs font-semibold text-blue-600 dark:text-blue-400 uppercase tracking-wider">SFP Optic</span>
|
||||
<div style={{ marginTop: 12, marginBottom: 8, paddingTop: 8, borderTop: '1px solid var(--line)' }}>
|
||||
<span className="mono text-[10px] uppercase tracking-widest" style={{ color: 'var(--accent)' }}>SFP Optic</span>
|
||||
</div>
|
||||
<div className="space-y-0">
|
||||
{infoRow('Type', d['sfp-type'])}
|
||||
@@ -748,373 +793,184 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Selection info bar */}
|
||||
{selectedPorts.size > 0 && (
|
||||
<div className="flex items-center gap-4 p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-lg">
|
||||
<span className="text-sm font-medium text-blue-700 dark:text-blue-300">
|
||||
{selectedPorts.size} port{selectedPorts.size > 1 ? 's' : ''} selected:{' '}
|
||||
{Array.from(selectedPorts).join(', ')}
|
||||
</span>
|
||||
<button
|
||||
className="ml-auto text-xs text-blue-500 hover:text-blue-700"
|
||||
onClick={() => setSelectedPorts(new Set())}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
{canWrite && selectedPorts.size === 1 && (() => {
|
||||
const port = ports.find((p) => p.name === Array.from(selectedPorts)[0]);
|
||||
return port ? (
|
||||
<button className="btn-primary text-xs py-1" onClick={() => openEditPanel(port)}>
|
||||
Configure
|
||||
</button>
|
||||
) : null;
|
||||
})()}
|
||||
{canWrite && selectedPorts.size >= 2 && (() => {
|
||||
const selArr = Array.from(selectedPorts).map(n => ports.find(p => p.name === n)).filter((p): p is SwitchPort => !!p);
|
||||
const allEligible = selArr.every(p => !isBridge(p) && !isBond(p) && !bondMemberMap.has(p.name));
|
||||
if (!allEligible) return null;
|
||||
return (
|
||||
{/* Selection bar */}
|
||||
{selectedPorts.size > 0 && (() => {
|
||||
const singlePort = selectedPorts.size === 1 ? ports.find(p => p.name === Array.from(selectedPorts)[0]) : null;
|
||||
const selArr = Array.from(selectedPorts).map(n => ports.find(p => p.name === n)).filter((p): p is SwitchPort => !!p);
|
||||
const allEligible = selArr.every(p => !isBridge(p) && !isBond(p) && !bondMemberMap.has(p.name));
|
||||
return (
|
||||
<div style={{
|
||||
background: 'var(--accent-soft)', border: '1px solid var(--accent)',
|
||||
borderRadius: 8, padding: '10px 16px', display: 'flex', alignItems: 'center', gap: 10,
|
||||
}}>
|
||||
<span style={{ width: 7, height: 7, borderRadius: 999, background: 'var(--accent)', flexShrink: 0, boxShadow: '0 0 0 2px var(--accent)33, 0 0 8px var(--accent)88' }} />
|
||||
<span className="mono text-[12px]" style={{ color: 'var(--accent)' }}>
|
||||
{selectedPorts.size === 1 ? Array.from(selectedPorts)[0] : `${selectedPorts.size} ports`}
|
||||
</span>
|
||||
{singlePort && (
|
||||
<span className="text-[12px]" style={{ color: 'var(--ink-2)' }}>
|
||||
selected{singlePort.speed ? ` · ${singlePort.speed}` : ''}{singlePort.mtu ? ` · ${singlePort.mtu} MTU` : ''}{singlePort.running ? ' · link-ok' : ' · link-down'}
|
||||
</span>
|
||||
)}
|
||||
<div style={{ flex: 1 }} />
|
||||
<button
|
||||
className="text-[11px]"
|
||||
style={{ padding: '5px 10px', borderRadius: 5, background: 'transparent', color: 'var(--ink-3)', border: '1px solid var(--line)' }}
|
||||
onClick={() => setSelectedPorts(new Set())}
|
||||
>Clear</button>
|
||||
{canWrite && singlePort && (
|
||||
<button
|
||||
className="btn-secondary text-xs py-1 flex items-center gap-1.5"
|
||||
className="btn-primary text-[11px]"
|
||||
style={{ padding: '5px 10px' }}
|
||||
onClick={() => openEditPanel(singlePort)}
|
||||
>Configure</button>
|
||||
)}
|
||||
{canWrite && selectedPorts.size >= 2 && allEligible && (
|
||||
<button
|
||||
className="btn-secondary text-[11px] flex items-center gap-[6px]"
|
||||
style={{ padding: '5px 10px' }}
|
||||
onClick={() => {
|
||||
setCreateTrunkForm({
|
||||
name: `bond${bondPorts.length + 1}`,
|
||||
mode: '802.3ad',
|
||||
lacp_rate: 'slow',
|
||||
transmit_hash_policy: '',
|
||||
mtu: '',
|
||||
min_links: '',
|
||||
});
|
||||
setCreateTrunkForm({ name: `bond${bondPorts.length + 1}`, mode: '802.3ad', lacp_rate: 'slow', transmit_hash_policy: '', mtu: '', min_links: '' });
|
||||
setBondError('');
|
||||
setShowCreateTrunkModal(true);
|
||||
}}
|
||||
>
|
||||
<Link2 className="w-3.5 h-3.5" /> Create Trunk
|
||||
<Link2 className="w-3 h-3" /> Create Trunk
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* SVG switch chassis */}
|
||||
<div className="card p-4 overflow-x-auto">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||
Switch Ports ({ports.length} total)
|
||||
</h3>
|
||||
<div className="flex items-center gap-4 text-xs text-gray-500 dark:text-slate-400">
|
||||
{[['bg-green-500', 'Up'], ['bg-red-500', 'Down'], ['bg-slate-500', 'Disabled'], ['bg-blue-500', 'Selected']].map(([cls, label]) => (
|
||||
<span key={label} className="flex items-center gap-1.5">
|
||||
<span className={`w-3 h-3 rounded-sm ${cls}`} />{label}
|
||||
</span>
|
||||
))}
|
||||
{/* Faceplate card */}
|
||||
<div className="card" style={{ padding: '22px 26px', position: 'relative', overflow: 'hidden' }}>
|
||||
{/* Pinstripe overlay */}
|
||||
<div style={{ position: 'absolute', inset: 0, pointerEvents: 'none', backgroundImage: 'repeating-linear-gradient(45deg, transparent, transparent 9px, rgba(255,255,255,0.015) 9px, rgba(255,255,255,0.015) 10px)' }} />
|
||||
<div style={{ position: 'relative' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 18 }}>
|
||||
<div>
|
||||
<div className="text-[13px] font-semibold" style={{ color: 'var(--ink)' }}>
|
||||
Switch faceplate · {ports.length} ports
|
||||
</div>
|
||||
<div className="mono text-[11px] mt-[2px]" style={{ color: 'var(--ink-3)' }}>
|
||||
click to select · shift for multi · double-click to configure
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 14 }}>
|
||||
{([['up', 'var(--accent)', ports.filter(p => !p.disabled && p.running).length], ['down', 'var(--bad)', ports.filter(p => !p.disabled && !p.running).length], ['disabled', 'var(--ink-4)', ports.filter(p => p.disabled).length], ['selected', 'var(--accent)', selectedPorts.size]] as [string,string,number][]).map(([l, c, n]) => (
|
||||
<span key={l} className="mono text-[11px]" style={{ display: 'flex', alignItems: 'center', gap: 5, color: 'var(--ink-3)' }}>
|
||||
<span style={{ width: 9, height: 9, borderRadius: 2, background: c, opacity: l === 'disabled' ? 0.4 : 1, flexShrink: 0 }} />
|
||||
{l} <span className="num-tab" style={{ color: 'var(--ink-2)' }}>{n}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rack unit */}
|
||||
<div style={{
|
||||
background: 'linear-gradient(180deg, var(--faceplate-from), var(--faceplate-to))',
|
||||
border: '1px solid var(--faceplate-border)', borderRadius: 8, padding: '22px 26px',
|
||||
display: 'flex', gap: 30, alignItems: 'center', overflowX: 'auto',
|
||||
boxShadow: 'inset 0 0 0 2px var(--faceplate-inset), inset 0 1px 0 rgba(255,255,255,0.02)',
|
||||
}}>
|
||||
{/* ETH group */}
|
||||
{etherPorts.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--ink-4)', letterSpacing: '.1em' }}>ETH</div>
|
||||
{doubleRow ? (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 4 }}>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{topRowPorts.map(port => (
|
||||
<PortTile key={port.name} port={port} selected={selectedPorts.has(port.name)} hovered={hoveredPort === port.name} isMember={bondMemberMap.has(port.name)} onClick={e => togglePort(port.name, e)} onMouseEnter={() => setHoveredPort(port.name)} onMouseLeave={() => setHoveredPort(null)} onDoubleClick={() => canWrite && openEditPanel(port)} />
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{bottomRowPorts.map(port => (
|
||||
<PortTile key={port.name} port={port} selected={selectedPorts.has(port.name)} hovered={hoveredPort === port.name} isMember={bondMemberMap.has(port.name)} onClick={e => togglePort(port.name, e)} onMouseEnter={() => setHoveredPort(port.name)} onMouseLeave={() => setHoveredPort(null)} onDoubleClick={() => canWrite && openEditPanel(port)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{topRowPorts.map(port => (
|
||||
<PortTile key={port.name} port={port} selected={selectedPorts.has(port.name)} hovered={hoveredPort === port.name} isMember={bondMemberMap.has(port.name)} onClick={e => togglePort(port.name, e)} onMouseEnter={() => setHoveredPort(port.name)} onMouseLeave={() => setHoveredPort(null)} onDoubleClick={() => canWrite && openEditPanel(port)} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* SFP group */}
|
||||
{individualSfpPorts.length > 0 && (
|
||||
<div>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--ink-4)', letterSpacing: '.1em', marginBottom: 6 }}>
|
||||
SFP · 1–{individualSfpPorts.length}
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: 4, flexWrap: 'wrap', maxWidth: individualSfpPorts.length * 52 }}>
|
||||
{individualSfpPorts.map(port => (
|
||||
<PortTile key={port.name} port={port} selected={selectedPorts.has(port.name)} hovered={hoveredPort === port.name} onClick={e => togglePort(port.name, e)} onMouseEnter={() => setHoveredPort(port.name)} onMouseLeave={() => setHoveredPort(null)} onDoubleClick={() => canWrite && openEditPanel(port)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QSFP cages */}
|
||||
{qsfpCages.map(cage => {
|
||||
const isSingleMode = !!cage.singlePort;
|
||||
const laneCount = isSingleMode ? 4 : cage.lanes.length;
|
||||
const runningLanes = cage.lanes.filter(p => p.running && !p.disabled).length;
|
||||
const isSingleCable = !isSingleMode && runningLanes === 1;
|
||||
const refPort = cage.singlePort ?? cage.lanes[0];
|
||||
return (
|
||||
<div key={cage.key}>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--ink-4)', letterSpacing: '.1em', marginBottom: 6 }}>{cage.label}</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{Array.from({ length: laneCount }, (_, li) => {
|
||||
const lane = isSingleMode ? cage.singlePort! : cage.lanes[li];
|
||||
const visualPort: SwitchPort = (isSingleMode || isSingleCable) ? { ...refPort, running: refPort.running, disabled: refPort.disabled } : lane;
|
||||
return (
|
||||
<PortTile key={li} port={visualPort} selected={selectedPorts.has(lane.name)} hovered={hoveredPort === lane.name} onClick={e => togglePort(lane.name, e)} onMouseEnter={() => setHoveredPort(lane.name)} onMouseLeave={() => setHoveredPort(null)} onDoubleClick={() => canWrite && openEditPanel(lane)} />
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bridge group */}
|
||||
{bridgePorts.length > 0 && (
|
||||
<div style={{ marginLeft: 'auto', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--ink-4)', letterSpacing: '.1em' }}>BRIDGE</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{bridgePorts.map(port => (
|
||||
<PortTile key={port.name} port={port} selected={selectedPorts.has(port.name)} hovered={hoveredPort === port.name} onClick={e => togglePort(port.name, e)} onMouseEnter={() => setHoveredPort(port.name)} onMouseLeave={() => setHoveredPort(null)} onDoubleClick={() => canWrite && openEditPanel(port)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bond group */}
|
||||
{bondPorts.length > 0 && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 6 }}>
|
||||
<div className="mono" style={{ fontSize: 10, color: 'var(--ink-4)', letterSpacing: '.1em' }}>BOND</div>
|
||||
<div style={{ display: 'flex', gap: 4 }}>
|
||||
{bondPorts.map(port => (
|
||||
<PortTile key={port.name} port={port} selected={selectedPorts.has(port.name)} hovered={hoveredPort === port.name} onClick={e => togglePort(port.name, e)} onMouseEnter={() => setHoveredPort(port.name)} onMouseLeave={() => setHoveredPort(null)} onDoubleClick={() => canWrite && openEditPanel(port)} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svg
|
||||
width={chassisW}
|
||||
height={chassisH}
|
||||
className="overflow-visible"
|
||||
>
|
||||
{/* Chassis body */}
|
||||
<rect x={0} y={0} width={chassisW} height={chassisH} rx={8} fill="currentColor" className="text-gray-800 dark:text-slate-700" />
|
||||
<text x={PAD} y={14} fill="#94a3b8" fontSize={9} fontFamily="monospace">PORTS</text>
|
||||
|
||||
{/* SFP section divider and label */}
|
||||
{sfpPorts.length > 0 && (
|
||||
<>
|
||||
<line
|
||||
x1={etherSectionW + sfpDivider / 2} y1={16}
|
||||
x2={etherSectionW + sfpDivider / 2} y2={chassisH - 12}
|
||||
stroke="#475569" strokeWidth={1} strokeDasharray="3,3"
|
||||
/>
|
||||
<text x={etherSectionW + sfpDivider / 2 + 4} y={14} fill="#94a3b8" fontSize={9} fontFamily="monospace">SFP</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{topRowPorts.map((port, i) => {
|
||||
const x = PAD + i * (PORT_W + GAP);
|
||||
const y = topRowY;
|
||||
const selected = selectedPorts.has(port.name);
|
||||
const hovered = hoveredPort === port.name;
|
||||
const isMember = bondMemberMap.has(port.name);
|
||||
return (
|
||||
<g key={port.name} className="cursor-pointer"
|
||||
onClick={(e) => togglePort(port.name, e)}
|
||||
onMouseEnter={() => setHoveredPort(port.name)}
|
||||
onMouseLeave={() => setHoveredPort(null)}
|
||||
onDoubleClick={() => canWrite && openEditPanel(port)}
|
||||
>
|
||||
<rect x={x} y={y} width={PORT_W} height={PORT_H} rx={3}
|
||||
fill={portColor(port, selected)}
|
||||
stroke={isMember && !selected ? '#d97706' : portBorderColor(port, selected)}
|
||||
strokeWidth={selected ? 2 : isMember ? 2 : 1}
|
||||
strokeDasharray={isMember && !selected ? '4,2' : undefined}
|
||||
opacity={hovered ? 0.85 : 1}
|
||||
/>
|
||||
<text x={x + PORT_W / 2} y={y + PORT_H / 2 + 4} textAnchor="middle" fill="white"
|
||||
fontSize={10} fontWeight="600" fontFamily="monospace">
|
||||
{portLabel(port.name)}
|
||||
</text>
|
||||
{hovered && (
|
||||
<foreignObject x={Math.max(0, x - 50)} y={y - 78} width={210} height={90}>
|
||||
<PortTooltip port={port} />
|
||||
</foreignObject>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{bottomRowPorts.map((port, i) => {
|
||||
const x = PAD + i * (PORT_W + GAP);
|
||||
const y = bottomRowY;
|
||||
const selected = selectedPorts.has(port.name);
|
||||
const hovered = hoveredPort === port.name;
|
||||
const isMember = bondMemberMap.has(port.name);
|
||||
return (
|
||||
<g key={port.name} className="cursor-pointer"
|
||||
onClick={(e) => togglePort(port.name, e)}
|
||||
onMouseEnter={() => setHoveredPort(port.name)}
|
||||
onMouseLeave={() => setHoveredPort(null)}
|
||||
onDoubleClick={() => canWrite && openEditPanel(port)}
|
||||
>
|
||||
<rect x={x} y={y} width={PORT_W} height={PORT_H} rx={3}
|
||||
fill={portColor(port, selected)}
|
||||
stroke={isMember && !selected ? '#d97706' : portBorderColor(port, selected)}
|
||||
strokeWidth={selected ? 2 : isMember ? 2 : 1}
|
||||
strokeDasharray={isMember && !selected ? '4,2' : undefined}
|
||||
opacity={hovered ? 0.85 : 1}
|
||||
/>
|
||||
<text x={x + PORT_W / 2} y={y + PORT_H / 2 + 4} textAnchor="middle" fill="white"
|
||||
fontSize={10} fontWeight="600" fontFamily="monospace">
|
||||
{portLabel(port.name)}
|
||||
</text>
|
||||
{hovered && (
|
||||
<foreignObject x={Math.max(0, x - 50)} y={chassisH} width={210} height={90}>
|
||||
<PortTooltip port={port} />
|
||||
</foreignObject>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{individualSfpPorts.map((port, i) => {
|
||||
const x = sfpStartX + i * (PORT_W + GAP);
|
||||
const y = sfpY;
|
||||
const selected = selectedPorts.has(port.name);
|
||||
const hovered = hoveredPort === port.name;
|
||||
return (
|
||||
<g key={port.name} className="cursor-pointer"
|
||||
onClick={(e) => togglePort(port.name, e)}
|
||||
onMouseEnter={() => setHoveredPort(port.name)}
|
||||
onMouseLeave={() => setHoveredPort(null)}
|
||||
onDoubleClick={() => canWrite && openEditPanel(port)}
|
||||
>
|
||||
<rect x={x} y={y} width={PORT_W} height={PORT_H} rx={3}
|
||||
fill={portColor(port, selected)} stroke={portBorderColor(port, selected)}
|
||||
strokeWidth={selected ? 2 : 1}
|
||||
strokeDasharray="3,2"
|
||||
opacity={hovered ? 0.85 : 1}
|
||||
/>
|
||||
<text x={x + PORT_W / 2} y={y + PORT_H / 2 + 4} textAnchor="middle" fill="white"
|
||||
fontSize={10} fontWeight="600" fontFamily="monospace">
|
||||
{portLabel(port.name)}
|
||||
</text>
|
||||
{hovered && (
|
||||
<foreignObject x={Math.max(0, x - 50)} y={sfpY - 78} width={210} height={90}>
|
||||
<PortTooltip port={port} />
|
||||
</foreignObject>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* QSFP section divider, label, and cage groups */}
|
||||
{qsfpCages.length > 0 && (
|
||||
<>
|
||||
<line
|
||||
x1={qsfpStartX - qsfpDivider / 2} y1={16}
|
||||
x2={qsfpStartX - qsfpDivider / 2} y2={chassisH - 12}
|
||||
stroke="#475569" strokeWidth={1} strokeDasharray="3,3"
|
||||
/>
|
||||
<text x={qsfpStartX - qsfpDivider / 2 + 4} y={14} fill="#94a3b8" fontSize={9} fontFamily="monospace">QSFP</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{qsfpCages.map((cage) => {
|
||||
// Single-mode cage: 4 virtual lanes all sharing the one port's status.
|
||||
// Breakout cage: real lanes, BUT if only 1 lane is running treat as single-cable
|
||||
// connection and show all lanes green.
|
||||
const isSingleMode = !!cage.singlePort;
|
||||
const laneCount = isSingleMode ? 4 : cage.lanes.length;
|
||||
const runningLanes = cage.lanes.filter(p => p.running && !p.disabled).length;
|
||||
const isSingleCable = !isSingleMode && runningLanes === 1;
|
||||
|
||||
const cageW = getCageW(laneCount);
|
||||
const prevW = qsfpCages.slice(0, qsfpCages.indexOf(cage))
|
||||
.reduce((acc, c) => acc + getCageW(c.singlePort ? 4 : c.lanes.length) + GAP, 0);
|
||||
const cageX = qsfpStartX + prevW;
|
||||
const cageY = sfpY;
|
||||
|
||||
const refPort = cage.singlePort ?? cage.lanes[0];
|
||||
const anySelected = cage.singlePort
|
||||
? selectedPorts.has(cage.singlePort.name)
|
||||
: cage.lanes.some(p => selectedPorts.has(p.name));
|
||||
|
||||
return (
|
||||
<g key={cage.key}>
|
||||
{/* Outer cage outline */}
|
||||
<rect x={cageX} y={cageY} width={cageW} height={PORT_H} rx={3}
|
||||
fill={anySelected ? 'rgba(59,130,246,0.12)' : 'rgba(71,85,105,0.25)'}
|
||||
stroke={anySelected ? '#3b82f6' : '#64748b'}
|
||||
strokeWidth={anySelected ? 1.5 : 1}
|
||||
/>
|
||||
{/* Cage name label */}
|
||||
<text x={cageX + cageW / 2} y={cageY + CAGE_LABEL_H - 2}
|
||||
textAnchor="middle" fill="#94a3b8" fontSize={8} fontFamily="monospace">
|
||||
{cage.label}
|
||||
</text>
|
||||
{/* Lane ports */}
|
||||
{Array.from({ length: laneCount }, (_, li) => {
|
||||
// For single-mode or single-cable: all lanes mirror the reference port
|
||||
const lane = isSingleMode ? cage.singlePort! : cage.lanes[li];
|
||||
// In single-cable mode, override visual status to show all as "up"
|
||||
const visualPort: SwitchPort = (isSingleMode || isSingleCable)
|
||||
? { ...refPort, running: refPort.running, disabled: refPort.disabled }
|
||||
: lane;
|
||||
|
||||
const laneX = cageX + CAGE_PAD_X + li * (LANE_W + LANE_GAP);
|
||||
const laneY = cageY + CAGE_LABEL_H;
|
||||
const laneSelected = selectedPorts.has(lane.name);
|
||||
const laneHovered = hoveredPort === lane.name;
|
||||
|
||||
return (
|
||||
<g key={li} className="cursor-pointer"
|
||||
onClick={(e) => togglePort(lane.name, e)}
|
||||
onMouseEnter={() => setHoveredPort(lane.name)}
|
||||
onMouseLeave={() => setHoveredPort(null)}
|
||||
onDoubleClick={() => canWrite && openEditPanel(lane)}
|
||||
>
|
||||
<rect x={laneX} y={laneY} width={LANE_W} height={CAGE_LANE_H} rx={2}
|
||||
fill={portColor(visualPort, laneSelected)}
|
||||
stroke={portBorderColor(visualPort, laneSelected)}
|
||||
strokeWidth={laneSelected ? 2 : 1}
|
||||
opacity={laneHovered ? 0.8 : 1}
|
||||
/>
|
||||
<text x={laneX + LANE_W / 2} y={laneY + CAGE_LANE_H / 2 + 3}
|
||||
textAnchor="middle" fill="white" fontSize={9} fontWeight="600" fontFamily="monospace">
|
||||
{isSingleMode ? String(li + 1) : lane.name.split('-').pop()}
|
||||
</text>
|
||||
{laneHovered && (
|
||||
<foreignObject x={Math.max(0, laneX - 60)} y={cageY - 78} width={210} height={90}>
|
||||
<PortTooltip port={lane} />
|
||||
</foreignObject>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bridge section divider and label */}
|
||||
{bridgePorts.length > 0 && (
|
||||
<>
|
||||
<line
|
||||
x1={bridgeStartX - bridgeDivider / 2} y1={16}
|
||||
x2={bridgeStartX - bridgeDivider / 2} y2={chassisH - 12}
|
||||
stroke="#475569" strokeWidth={1} strokeDasharray="3,3"
|
||||
/>
|
||||
<text x={bridgeStartX - bridgeDivider / 2 + 4} y={14} fill="#94a3b8" fontSize={9} fontFamily="monospace">BRIDGE</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{bridgePorts.map((port, i) => {
|
||||
const x = bridgeStartX + i * (PORT_W + GAP);
|
||||
const y = bridgeY;
|
||||
const selected = selectedPorts.has(port.name);
|
||||
const hovered = hoveredPort === port.name;
|
||||
return (
|
||||
<g key={port.name} className="cursor-pointer"
|
||||
onClick={(e) => togglePort(port.name, e)}
|
||||
onMouseEnter={() => setHoveredPort(port.name)}
|
||||
onMouseLeave={() => setHoveredPort(null)}
|
||||
onDoubleClick={() => canWrite && openEditPanel(port)}
|
||||
>
|
||||
<rect x={x} y={y} width={PORT_W} height={PORT_H} rx={3}
|
||||
fill={portColor(port, selected)} stroke={portBorderColor(port, selected)}
|
||||
strokeWidth={selected ? 2 : 1}
|
||||
strokeDasharray="5,3"
|
||||
opacity={hovered ? 0.85 : 1}
|
||||
/>
|
||||
<text x={x + PORT_W / 2} y={y + PORT_H / 2 + 4} textAnchor="middle" fill="white"
|
||||
fontSize={10} fontWeight="600" fontFamily="monospace">
|
||||
{portLabel(port.name)}
|
||||
</text>
|
||||
{hovered && (
|
||||
<foreignObject x={Math.max(0, x - 50)} y={bridgeY - 78} width={210} height={90}>
|
||||
<PortTooltip port={port} />
|
||||
</foreignObject>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Bond section divider and label */}
|
||||
{bondPorts.length > 0 && (
|
||||
<>
|
||||
<line
|
||||
x1={bondStartX - bondDivider / 2} y1={16}
|
||||
x2={bondStartX - bondDivider / 2} y2={chassisH - 12}
|
||||
stroke="#475569" strokeWidth={1} strokeDasharray="3,3"
|
||||
/>
|
||||
<text x={bondStartX - bondDivider / 2 + 4} y={14} fill="#94a3b8" fontSize={9} fontFamily="monospace">BOND</text>
|
||||
</>
|
||||
)}
|
||||
|
||||
{bondPorts.map((port, i) => {
|
||||
const x = bondStartX + i * (PORT_W + GAP);
|
||||
const y = bridgeY;
|
||||
const selected = selectedPorts.has(port.name);
|
||||
const hovered = hoveredPort === port.name;
|
||||
return (
|
||||
<g key={port.name} className="cursor-pointer"
|
||||
onClick={(e) => togglePort(port.name, e)}
|
||||
onMouseEnter={() => setHoveredPort(port.name)}
|
||||
onMouseLeave={() => setHoveredPort(null)}
|
||||
onDoubleClick={() => canWrite && openEditPanel(port)}
|
||||
>
|
||||
<rect x={x} y={y} width={PORT_W} height={PORT_H} rx={3}
|
||||
fill={portColor(port, selected)} stroke={portBorderColor(port, selected)}
|
||||
strokeWidth={selected ? 2 : 1}
|
||||
strokeDasharray="6,2"
|
||||
opacity={hovered ? 0.85 : 1}
|
||||
/>
|
||||
<text x={x + PORT_W / 2} y={y + PORT_H / 2 + 4} textAnchor="middle" fill="white"
|
||||
fontSize={10} fontWeight="600" fontFamily="monospace">
|
||||
{portLabel(port.name)}
|
||||
</text>
|
||||
{hovered && (
|
||||
<foreignObject x={Math.max(0, x - 50)} y={bridgeY - 78} width={210} height={90}>
|
||||
<PortTooltip port={port} />
|
||||
</foreignObject>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mt-3">
|
||||
Click to select · Shift+click to multi-select · Double-click to configure
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Port throughput graph + info card — shown when exactly one port is selected */}
|
||||
{/* Port throughput + info — shown when exactly one port is selected */}
|
||||
{selectedPorts.size === 1 && (() => {
|
||||
const portName = Array.from(selectedPorts)[0];
|
||||
return (
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4 items-stretch">
|
||||
<div className="md:col-span-2">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-stretch">
|
||||
<div className="md:col-span-1">
|
||||
<PortTrafficGraph
|
||||
deviceId={deviceId}
|
||||
portName={portName}
|
||||
@@ -1122,7 +978,7 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
|
||||
onRangeChange={setTrafficRange}
|
||||
/>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<div className="md:col-span-1">
|
||||
<PortPacketGraph
|
||||
deviceId={deviceId}
|
||||
portName={portName}
|
||||
@@ -1139,74 +995,74 @@ export default function SwitchPortDiagram({ deviceId, autoOpenBridge, onBridgeOp
|
||||
{/* Port table */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Port</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Status</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Speed</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">MTU</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">PVID</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">MAC</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Comment</th>
|
||||
<th className="table-header px-4 py-2.5 w-12" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{sortedPorts.map((port) => (
|
||||
<tr
|
||||
key={port.name}
|
||||
className={clsx(
|
||||
'hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer',
|
||||
selectedPorts.has(port.name) && 'bg-blue-50 dark:bg-blue-900/20'
|
||||
)}
|
||||
onClick={(e) => togglePort(port.name, e)}
|
||||
>
|
||||
<td className="px-4 py-2 font-mono font-medium text-gray-900 dark:text-white">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{port.name}
|
||||
{bondMemberMap.has(port.name) && (
|
||||
<span className="text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 px-1.5 py-0.5 rounded font-sans">
|
||||
{bondMemberMap.get(port.name)}
|
||||
</span>
|
||||
)}
|
||||
{isBond(port) && (
|
||||
<span className="text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400 px-1.5 py-0.5 rounded font-sans">
|
||||
BOND
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2">
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 text-xs font-medium',
|
||||
port.disabled ? 'text-gray-500' : port.running ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'
|
||||
)}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full',
|
||||
port.disabled ? 'bg-gray-400' : port.running ? 'bg-green-500' : 'bg-red-500'
|
||||
)} />
|
||||
{port.disabled ? 'Disabled' : port.running ? 'Up' : 'Down'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-gray-500 dark:text-slate-400">{port.speed || '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-500 dark:text-slate-400">{port.mtu || '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-500 dark:text-slate-400">{port.bridgeInfo?.pvid ?? '—'}</td>
|
||||
<td className="px-4 py-2 font-mono text-xs text-gray-400 dark:text-slate-500">{port.mac_address || '—'}</td>
|
||||
<td className="px-4 py-2 text-gray-500 dark:text-slate-400">{port.comment || '—'}</td>
|
||||
<td className="px-4 py-2">
|
||||
{canWrite && (
|
||||
<button
|
||||
className="p-1 rounded text-gray-400 hover:text-blue-500 transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); openEditPanel(port); }}
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
{['PORT', 'STATUS', 'SPEED', 'MTU', 'PVID', 'MAC', 'COMMENT', ''].map((h, i) => (
|
||||
<th key={i} className="table-header px-4 py-[10px] text-left">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedPorts.map((port, i) => {
|
||||
const state = portState(port);
|
||||
const stateColor = state === 'up' ? 'var(--good)' : state === 'down' ? 'var(--bad)' : 'var(--ink-4)';
|
||||
const isSelected = selectedPorts.has(port.name);
|
||||
return (
|
||||
<tr
|
||||
key={port.name}
|
||||
className="cursor-pointer transition-colors"
|
||||
style={{
|
||||
borderBottom: i < sortedPorts.length - 1 ? '1px solid var(--line-soft)' : 'none',
|
||||
background: isSelected ? 'var(--accent-soft)' : 'transparent',
|
||||
}}
|
||||
onMouseEnter={e => { if (!isSelected) e.currentTarget.style.background = 'var(--surface-2)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = isSelected ? 'var(--accent-soft)' : 'transparent'; }}
|
||||
onClick={(e) => togglePort(port.name, e)}
|
||||
>
|
||||
<td className="px-4 py-[10px]">
|
||||
<div className="flex items-center gap-[6px]">
|
||||
<span className="mono text-[12px] font-medium" style={{ color: 'var(--ink)' }}>{port.name}</span>
|
||||
{bondMemberMap.has(port.name) && (
|
||||
<span className="mono text-[9.5px] px-[5px] py-[2px] rounded" style={{ background: 'var(--warn-bg)', color: 'var(--warn)' }}>
|
||||
{bondMemberMap.get(port.name)}
|
||||
</span>
|
||||
)}
|
||||
{isBond(port) && (
|
||||
<span className="mono text-[9.5px] px-[5px] py-[2px] rounded" style={{ background: 'var(--warn-bg)', color: 'var(--warn)' }}>BOND</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-[10px]">
|
||||
<span className="inline-flex items-center gap-[5px] mono text-[11px] font-medium" style={{ color: stateColor }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: 999, background: stateColor, flexShrink: 0 }} />
|
||||
{port.disabled ? 'Disabled' : port.running ? 'Up' : 'Down'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-[10px]"><span className="mono text-[11.5px]" style={{ color: 'var(--ink-3)' }}>{port.speed || '—'}</span></td>
|
||||
<td className="px-4 py-[10px]"><span className="mono num-tab text-[11.5px]" style={{ color: 'var(--ink-3)' }}>{port.mtu || '—'}</span></td>
|
||||
<td className="px-4 py-[10px]"><span className="mono num-tab text-[11.5px]" style={{ color: 'var(--ink-3)' }}>{port.bridgeInfo?.pvid ?? '—'}</span></td>
|
||||
<td className="px-4 py-[10px]"><span className="mono text-[11px]" style={{ color: 'var(--ink-4)' }}>{port.mac_address || '—'}</span></td>
|
||||
<td className="px-4 py-[10px]"><span className="text-[12px]" style={{ color: 'var(--ink-3)' }}>{port.comment || '—'}</span></td>
|
||||
<td className="px-4 py-[10px]">
|
||||
{canWrite && (
|
||||
<button
|
||||
className="p-1 rounded transition-colors"
|
||||
style={{ color: 'var(--ink-4)' }}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--accent)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--ink-4)')}
|
||||
onClick={(e) => { e.stopPropagation(); openEditPanel(port); }}
|
||||
title="Configure"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+220
-77
@@ -4,146 +4,289 @@
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--color-bg: theme('colors.white');
|
||||
--color-surface: theme('colors.gray.50');
|
||||
--color-border: theme('colors.gray.200');
|
||||
--color-text: theme('colors.gray.900');
|
||||
--color-text-muted: theme('colors.gray.500');
|
||||
--color-sidebar-bg: theme('colors.slate.100');
|
||||
--color-sidebar-text: theme('colors.slate.700');
|
||||
--color-sidebar-active: theme('colors.blue.600');
|
||||
/* ── surfaces (warm-neutral light) ── */
|
||||
--bg: #f8f7f4;
|
||||
--surface: #ffffff;
|
||||
--surface-2: #f2f1ec;
|
||||
--surface-3: #e8e6e0;
|
||||
|
||||
/* ── lines ── */
|
||||
--line: #d4d1c8;
|
||||
--line-soft: #dedad3;
|
||||
|
||||
/* ── text ── */
|
||||
--ink: #1a1a16;
|
||||
--ink-2: #3d3c35;
|
||||
--ink-3: #6b6960;
|
||||
--ink-4: #9e9b92;
|
||||
|
||||
/* ── accent (blue) ── */
|
||||
--accent: #2563eb;
|
||||
--accent-2: #1d4ed8;
|
||||
--accent-soft: #eff6ff;
|
||||
--accent-fg: #ffffff;
|
||||
|
||||
/* ── port faceplate ── */
|
||||
--faceplate-from: #eae8e3;
|
||||
--faceplate-to: #d8d6d0;
|
||||
--faceplate-border: #c4c1b8;
|
||||
--faceplate-inset: #b8b5ad;
|
||||
--port-up-bg: #dcf5e0;
|
||||
--port-up-border: #6abf72;
|
||||
--port-down-bg: #fde8e8;
|
||||
--port-down-border: #e07070;
|
||||
|
||||
/* ── status ── */
|
||||
--good: #2d7a2a;
|
||||
--good-bg: #dcf5dc;
|
||||
--warn: #8a6200;
|
||||
--warn-bg: #fef3c7;
|
||||
--bad: #b83333;
|
||||
--bad-bg: #fee2e2;
|
||||
--info: #1a5ea8;
|
||||
--info-bg: #dbeafe;
|
||||
--violet: #6b3db8;
|
||||
--violet-bg: #ede9fe;
|
||||
|
||||
/* ── legacy aliases (used by older components) ── */
|
||||
--color-bg: var(--bg);
|
||||
--color-surface: var(--surface);
|
||||
--color-border: var(--line);
|
||||
--color-text: var(--ink);
|
||||
--color-text-muted: var(--ink-3);
|
||||
--color-sidebar-bg: var(--surface);
|
||||
--color-sidebar-text: var(--ink-2);
|
||||
--color-sidebar-active: var(--accent);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--color-bg: #0f172a;
|
||||
--color-surface: #1e293b;
|
||||
--color-border: #334155;
|
||||
--color-text: #f1f5f9;
|
||||
--color-text-muted: #94a3b8;
|
||||
--color-sidebar-bg: #0f172a;
|
||||
--color-sidebar-text: #cbd5e1;
|
||||
--color-sidebar-active: #3b82f6;
|
||||
/* ── surfaces (warm-neutral dark) ── */
|
||||
--bg: #0e0f12;
|
||||
--surface: #15171b;
|
||||
--surface-2: #1c1f24;
|
||||
--surface-3: #23262c;
|
||||
|
||||
/* ── lines ── */
|
||||
--line: #2a2e35;
|
||||
--line-soft: #20232a;
|
||||
|
||||
/* ── text ── */
|
||||
--ink: #f1efe9;
|
||||
--ink-2: #c2bfb6;
|
||||
--ink-3: #8a877e;
|
||||
--ink-4: #5d5b54;
|
||||
|
||||
/* ── accent (blue) ── */
|
||||
--accent: #60a5fa;
|
||||
--accent-2: #3b82f6;
|
||||
--accent-soft: rgba(96, 165, 250, 0.1);
|
||||
--accent-fg: #0e0f12;
|
||||
|
||||
/* ── port faceplate ── */
|
||||
--faceplate-from: #0a0c0e;
|
||||
--faceplate-to: #13171a;
|
||||
--faceplate-border: #2a2e35;
|
||||
--faceplate-inset: #1f2329;
|
||||
--port-up-bg: #1c2918;
|
||||
--port-up-border: #3b6f3a;
|
||||
--port-down-bg: #291417;
|
||||
--port-down-border: #5b2024;
|
||||
|
||||
/* ── status ── */
|
||||
--good: #8de08a;
|
||||
--good-bg: rgba(141, 224, 138, 0.12);
|
||||
--warn: #f0c87a;
|
||||
--warn-bg: rgba(240, 200, 122, 0.12);
|
||||
--bad: #f08a8a;
|
||||
--bad-bg: rgba(240, 138, 138, 0.12);
|
||||
--info: #9ec5ff;
|
||||
--info-bg: rgba(158, 197, 255, 0.12);
|
||||
--violet: #c0a8f5;
|
||||
--violet-bg: rgba(192, 168, 245, 0.12);
|
||||
|
||||
/* ── legacy aliases ── */
|
||||
--color-bg: var(--bg);
|
||||
--color-surface: var(--surface);
|
||||
--color-border: var(--line);
|
||||
--color-text: var(--ink);
|
||||
--color-text-muted: var(--ink-3);
|
||||
--color-sidebar-bg: var(--bg);
|
||||
--color-sidebar-text: var(--ink-2);
|
||||
--color-sidebar-active: var(--accent);
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-white dark:bg-slate-900 text-gray-900 dark:text-slate-100;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--ink);
|
||||
font-family: 'Geist', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-feature-settings: 'ss01';
|
||||
}
|
||||
|
||||
* {
|
||||
@apply border-gray-200 dark:border-slate-700;
|
||||
border-color: var(--line);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
@apply w-1.5 h-1.5;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
@apply bg-transparent;
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
@apply bg-gray-300 dark:bg-slate-600 rounded-full;
|
||||
background: var(--line);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
/* Monospace utility — for IPs, MACs, versions, timestamps */
|
||||
.mono {
|
||||
font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'ss01';
|
||||
}
|
||||
|
||||
/* Tabular number alignment */
|
||||
.num-tab {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'tnum';
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* ── Cards ── */
|
||||
.card {
|
||||
@apply bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-xl shadow-sm;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.card-subtle {
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* ── Buttons ── */
|
||||
.btn-primary {
|
||||
@apply bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg text-sm font-medium
|
||||
background: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
@apply px-4 py-2 rounded-lg text-sm font-semibold
|
||||
transition-colors duration-150 disabled:opacity-50 disabled:cursor-not-allowed;
|
||||
}
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: var(--accent-2);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-100 hover:bg-gray-200 dark:bg-slate-700 dark:hover:bg-slate-600
|
||||
text-gray-700 dark:text-slate-200 px-4 py-2 rounded-lg text-sm font-medium
|
||||
background: transparent;
|
||||
color: var(--ink-2);
|
||||
border: 1px solid var(--line);
|
||||
@apply px-4 py-2 rounded-lg text-sm font-medium
|
||||
transition-colors duration-150;
|
||||
}
|
||||
.btn-secondary:hover {
|
||||
background: var(--surface-3);
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg text-sm font-medium
|
||||
transition-colors duration-150;
|
||||
}
|
||||
|
||||
/* ── Form elements ── */
|
||||
.input {
|
||||
@apply w-full px-3 py-2 bg-white dark:bg-slate-700 border border-gray-300 dark:border-slate-600
|
||||
rounded-lg text-sm text-gray-900 dark:text-slate-100 placeholder-gray-400
|
||||
dark:placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500
|
||||
focus:border-transparent;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
color: var(--ink);
|
||||
@apply w-full px-3 py-2 rounded-lg text-sm
|
||||
focus:outline-none focus:ring-2 focus:border-transparent;
|
||||
--tw-ring-color: var(--accent);
|
||||
}
|
||||
.input::placeholder {
|
||||
color: var(--ink-4);
|
||||
}
|
||||
|
||||
.label {
|
||||
@apply block text-sm font-medium text-gray-700 dark:text-slate-300 mb-1;
|
||||
color: var(--ink-3);
|
||||
@apply block text-sm font-medium mb-1;
|
||||
}
|
||||
|
||||
/* React Flow default controls are light-theme SVGs; fix contrast in dark mode */
|
||||
/* ── Status pills ── */
|
||||
.status-online {
|
||||
background: var(--good-bg);
|
||||
color: var(--good);
|
||||
}
|
||||
.status-offline {
|
||||
background: var(--bad-bg);
|
||||
color: var(--bad);
|
||||
}
|
||||
.status-unknown {
|
||||
background: var(--surface-2);
|
||||
color: var(--ink-3);
|
||||
}
|
||||
|
||||
/* ── Severity pills ── */
|
||||
.severity-error {
|
||||
background: var(--bad-bg);
|
||||
color: var(--bad);
|
||||
}
|
||||
.severity-warning {
|
||||
background: var(--warn-bg);
|
||||
color: var(--warn);
|
||||
}
|
||||
.severity-info {
|
||||
background: var(--info-bg);
|
||||
color: var(--info);
|
||||
}
|
||||
|
||||
/* ── Table utilities ── */
|
||||
.table-header {
|
||||
color: var(--ink-4);
|
||||
@apply text-left text-[10.5px] font-medium uppercase tracking-[0.08em];
|
||||
}
|
||||
|
||||
/* Zebra striping — uses :where() so explicit bg on <tr> always wins */
|
||||
:where(.table-zebra > tr:nth-child(even)) {
|
||||
background: var(--surface-2);
|
||||
}
|
||||
|
||||
/* ── React Flow topology overrides ── */
|
||||
.topology-reactflow.dark .react-flow__controls {
|
||||
@apply border-slate-600 shadow-lg;
|
||||
border-color: var(--line);
|
||||
@apply shadow-lg;
|
||||
}
|
||||
.topology-reactflow.dark .react-flow__controls-button {
|
||||
@apply bg-slate-600 border-slate-500;
|
||||
color: #f1f5f9;
|
||||
background: var(--surface-2);
|
||||
border-color: var(--line);
|
||||
color: var(--ink);
|
||||
}
|
||||
.topology-reactflow.dark .react-flow__controls-button:hover {
|
||||
@apply bg-slate-500 border-slate-400;
|
||||
color: #fff;
|
||||
background: var(--surface-3);
|
||||
}
|
||||
.topology-reactflow.dark .react-flow__controls-button svg {
|
||||
fill: #f1f5f9;
|
||||
stroke: #f1f5f9;
|
||||
fill: var(--ink);
|
||||
stroke: var(--ink);
|
||||
}
|
||||
.topology-reactflow.dark .react-flow__controls-button:disabled {
|
||||
@apply opacity-40;
|
||||
}
|
||||
.topology-reactflow.dark .react-flow__minimap {
|
||||
@apply bg-slate-700 border border-slate-600;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.topology-reactflow.dark .react-flow__minimap-mask {
|
||||
fill: rgb(15 23 42 / 0.65);
|
||||
fill: rgba(14, 15, 18, 0.65);
|
||||
}
|
||||
.topology-reactflow.dark .react-flow__minimap-node {
|
||||
@apply fill-slate-500 stroke-slate-400;
|
||||
fill: var(--surface-3);
|
||||
stroke: var(--line);
|
||||
}
|
||||
|
||||
.topology-reactflow {
|
||||
--topology-edge-label: #64748b;
|
||||
--topology-seg-edge-label: #b45309;
|
||||
}
|
||||
.dark .topology-reactflow {
|
||||
--topology-edge-label: #cbd5e1;
|
||||
--topology-seg-edge-label: #fcd34d;
|
||||
--topology-edge-label: var(--ink-3);
|
||||
--topology-seg-edge-label: var(--warn);
|
||||
}
|
||||
.dark .topology-reactflow .react-flow__edge-textbg {
|
||||
fill: rgb(30 41 59 / 0.92);
|
||||
}
|
||||
|
||||
.table-header {
|
||||
@apply text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wider;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
@apply bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400;
|
||||
}
|
||||
.status-offline {
|
||||
@apply bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400;
|
||||
}
|
||||
.status-unknown {
|
||||
@apply bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400;
|
||||
}
|
||||
|
||||
.severity-error {
|
||||
@apply bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400;
|
||||
}
|
||||
.severity-warning {
|
||||
@apply bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-400;
|
||||
}
|
||||
.severity-info {
|
||||
@apply bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400;
|
||||
}
|
||||
|
||||
/* Zebra striping — uses :where() so explicit bg classes on <tr> always win */
|
||||
:where(.table-zebra > tr:nth-child(even)) {
|
||||
@apply bg-gray-50 dark:bg-slate-700/30;
|
||||
fill: var(--surface-2);
|
||||
fill-opacity: 0.92;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,14 +257,14 @@ export default function ClientsPage() {
|
||||
<span className="text-xs text-green-600 dark:text-green-400">{purgeResult}</span>
|
||||
)}
|
||||
<div className="flex items-center gap-1.5 ml-auto">
|
||||
<RefreshCw className="w-3.5 h-3.5 text-gray-400 dark:text-slate-500" />
|
||||
<RefreshCw className="w-3.5 h-3.5 text-ink-4" />
|
||||
<select
|
||||
value={String(refreshInterval)}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value;
|
||||
handleIntervalChange(raw === 'null' ? null : Number(raw));
|
||||
}}
|
||||
className="text-sm rounded-lg border border-gray-200 dark:border-slate-600 bg-white dark:bg-slate-700 text-gray-700 dark:text-slate-200 px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
className="text-sm rounded-lg border border-line bg-surface-2 text-ink px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-accent"
|
||||
>
|
||||
{REFRESH_OPTIONS.map((o) => (
|
||||
<option key={String(o.value)} value={String(o.value)}>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+361
-318
@@ -1,23 +1,11 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Router,
|
||||
Wifi,
|
||||
Trash2,
|
||||
ChevronRight,
|
||||
Search,
|
||||
Radar,
|
||||
ArrowUpCircle,
|
||||
Cpu,
|
||||
Pencil,
|
||||
ArrowUpDown,
|
||||
ArrowUp,
|
||||
ArrowDown,
|
||||
Plus, RefreshCw, Router, Wifi, Trash2, ChevronRight, Search,
|
||||
Radar, ArrowUpCircle, Cpu, Pencil, ArrowUpDown, ArrowUp, ArrowDown,
|
||||
} from 'lucide-react';
|
||||
import { devicesApi, topologyApi } from '../services/api';
|
||||
import { devicesApi, topologyApi, metricsApi } from '../services/api';
|
||||
import type { Device } from '../types';
|
||||
import type { DiscoveredDevice } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
@@ -48,10 +36,13 @@ function SortableHeader({
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 text-inherit hover:text-gray-900 dark:hover:text-white transition-colors',
|
||||
'inline-flex items-center gap-1.5 text-inherit transition-colors',
|
||||
align === 'center' && 'justify-center',
|
||||
align === 'right' && 'justify-end'
|
||||
)}
|
||||
style={{ color: 'inherit' }}
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--ink)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'inherit')}
|
||||
title={`Sort by ${label}`}
|
||||
>
|
||||
<span>{label}</span>
|
||||
@@ -62,32 +53,58 @@ function SortableHeader({
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ status }: { status: Device['status'] }) {
|
||||
function GlowDot({ status }: { status: Device['status'] }) {
|
||||
const color = status === 'online' ? 'var(--good)' : status === 'offline' ? 'var(--bad)' : 'var(--ink-4)';
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-medium',
|
||||
status === 'online' && 'status-online',
|
||||
status === 'offline' && 'status-offline',
|
||||
status === 'unknown' && 'status-unknown'
|
||||
)}
|
||||
style={{
|
||||
width: 8, height: 8, borderRadius: 999, display: 'inline-block',
|
||||
background: color, flexShrink: 0,
|
||||
boxShadow: status === 'online' ? `0 0 0 2px ${color}33, 0 0 8px ${color}88` : 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TypePill({ type }: { type: Device['device_type'] }) {
|
||||
const map: Record<string, { label: string; color: string }> = {
|
||||
wireless_ap: { label: 'AP', color: 'var(--info)' },
|
||||
switch: { label: 'SW', color: 'var(--accent)' },
|
||||
router: { label: 'RTR', color: 'var(--violet)' },
|
||||
};
|
||||
const { label, color } = map[type as string] ?? { label: (type as string)?.slice(0, 3)?.toUpperCase() ?? '?', color: 'var(--ink-3)' };
|
||||
return (
|
||||
<span
|
||||
className="mono text-[10.5px] font-medium px-[6px] py-[2px] rounded-full"
|
||||
style={{ color, border: '1px solid var(--line)' }}
|
||||
>
|
||||
<span
|
||||
className={clsx(
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
status === 'online' && 'bg-green-500',
|
||||
status === 'offline' && 'bg-red-500',
|
||||
status === 'unknown' && 'bg-gray-400'
|
||||
)}
|
||||
/>
|
||||
{status}
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function DeviceTypeIcon({ type }: { type: Device['device_type'] }) {
|
||||
if (type === 'wireless_ap') return <Wifi className="w-5 h-5 text-blue-500" />;
|
||||
return <Router className="w-5 h-5 text-blue-500" />;
|
||||
function CpuValue({ value }: { value: number | undefined }) {
|
||||
if (value == null) return <span className="mono text-[11px]" style={{ color: 'var(--ink-4)' }}>—</span>;
|
||||
const color = value > 70 ? 'var(--bad)' : value > 40 ? 'var(--warn)' : 'var(--accent)';
|
||||
return (
|
||||
<span className="mono num-tab text-[12px] font-medium" style={{ color }}>{value}%</span>
|
||||
);
|
||||
}
|
||||
|
||||
function LoadSparkline({ data }: { data: number[] }) {
|
||||
if (data.length < 2) return <span className="mono text-[11px]" style={{ color: 'var(--ink-4)' }}>—</span>;
|
||||
const max = Math.max(...data) || 1;
|
||||
const w = 72, h = 20;
|
||||
const pts = data.map((v, i) => [
|
||||
(i / (data.length - 1)) * w,
|
||||
h - (v / max) * (h - 2) - 1,
|
||||
]);
|
||||
const d = 'M' + pts.map(p => `${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(' L');
|
||||
return (
|
||||
<svg width={w} height={h} style={{ display: 'block' }}>
|
||||
<path d={d} fill="none" stroke="var(--ink-3)" strokeWidth="1.2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DevicesPage() {
|
||||
@@ -107,6 +124,8 @@ export default function DevicesPage() {
|
||||
key: 'discovered_at',
|
||||
dir: 'desc',
|
||||
});
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'online' | 'offline' | 'updates'>('all');
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
|
||||
const { data: devices = [], isLoading } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
@@ -133,14 +152,39 @@ export default function DevicesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch 1h CPU history for all devices (for LOAD sparkline column)
|
||||
const cpuHistoryResults = useQueries({
|
||||
queries: devices.map(d => ({
|
||||
queryKey: ['device-resources-history', d.id],
|
||||
queryFn: () => metricsApi.deviceResources(d.id, '1h').then(r => r.data),
|
||||
staleTime: 60_000,
|
||||
enabled: d.status === 'online',
|
||||
})),
|
||||
});
|
||||
const cpuHistories = useMemo(() => {
|
||||
const map: Record<number, number[]> = {};
|
||||
devices.forEach((d, i) => {
|
||||
const pts = cpuHistoryResults[i]?.data ?? [];
|
||||
map[d.id] = pts.map((p: { cpu_load?: number }) => p.cpu_load ?? 0).filter((v: number) => v > 0);
|
||||
});
|
||||
return map;
|
||||
}, [devices, cpuHistoryResults]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const base = devices.filter(
|
||||
(d) =>
|
||||
!search ||
|
||||
d.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||
d.ip_address.includes(search) ||
|
||||
d.model?.toLowerCase().includes(search.toLowerCase())
|
||||
);
|
||||
const base = devices.filter(d => {
|
||||
if (search &&
|
||||
!d.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||
!d.ip_address.includes(search) &&
|
||||
!d.model?.toLowerCase().includes(search.toLowerCase())
|
||||
) return false;
|
||||
if (statusFilter === 'online' && d.status !== 'online') return false;
|
||||
if (statusFilter === 'offline' && d.status !== 'offline') return false;
|
||||
if (statusFilter === 'updates' && !d.firmware_update_available && !d.routerboard_upgrade_available) return false;
|
||||
if (typeFilter === 'AP' && d.device_type !== 'wireless_ap') return false;
|
||||
if (typeFilter === 'SW' && d.device_type !== 'switch') return false;
|
||||
if (typeFilter === 'RTR' && d.device_type !== 'router') return false;
|
||||
return true;
|
||||
});
|
||||
const sorted = [...base].sort((a, b) => {
|
||||
const val = (x: Device): string | number => {
|
||||
switch (deviceSort.key) {
|
||||
@@ -203,19 +247,21 @@ export default function DevicesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const onlineCount = devices.filter(d => d.status === 'online').length;
|
||||
const updatesCount = devices.filter(d => d.firmware_update_available || d.routerboard_upgrade_available).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{/* Page header */}
|
||||
<div className="flex flex-wrap items-baseline gap-3">
|
||||
<h1 className="text-[28px] font-semibold leading-none" style={{ color: 'var(--ink)', letterSpacing: '-0.01em' }}>
|
||||
Devices
|
||||
{devices.length > 0 && (
|
||||
<span className="ml-2 text-sm font-normal text-gray-500 dark:text-slate-400">
|
||||
({devices.filter((d) => d.status === 'online').length}/{devices.length} online)
|
||||
</span>
|
||||
)}
|
||||
</h1>
|
||||
<span className="mono text-[11px]" style={{ color: 'var(--ink-3)' }}>
|
||||
{devices.length} total · {onlineCount} online{updatesCount > 0 ? ` · ${updatesCount} update${updatesCount !== 1 ? 's' : ''}` : ''}
|
||||
</span>
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
onClick={async () => {
|
||||
setSearching(true);
|
||||
@@ -230,220 +276,240 @@ export default function DevicesPage() {
|
||||
}
|
||||
}}
|
||||
disabled={searching}
|
||||
className="btn-secondary flex items-center gap-2"
|
||||
className="btn-secondary flex items-center gap-2 text-[12px] py-[6px]"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', searching && 'animate-spin')} />
|
||||
{searching ? 'Searching…' : 'Search for Devices'}
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', searching && 'animate-spin')} />
|
||||
{searching ? 'Searching…' : 'Discover'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setAddPrefill(undefined); setShowAddModal(true); }}
|
||||
className="btn-primary flex items-center gap-2"
|
||||
className="btn-primary flex items-center gap-2 text-[12px] py-[6px]"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Device
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add device
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="relative w-full sm:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
className="input pl-9"
|
||||
placeholder="Search devices..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
{/* Filter bar */}
|
||||
<div className="card-subtle flex flex-wrap items-center gap-[6px] p-[6px]">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-[160px] px-[10px] py-[6px]" style={{ color: 'var(--ink-3)' }}>
|
||||
<Search className="w-3.5 h-3.5 flex-shrink-0" />
|
||||
<input
|
||||
type="text"
|
||||
className="bg-transparent text-[13px] outline-none w-full"
|
||||
style={{ color: 'var(--ink)' }}
|
||||
placeholder="Filter devices…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{(['all', 'online', 'offline', 'updates'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setStatusFilter(f)}
|
||||
className="mono text-[11.5px] px-[10px] py-[4px] rounded-[5px] transition-colors capitalize"
|
||||
style={{
|
||||
background: statusFilter === f ? 'var(--surface-3)' : 'transparent',
|
||||
color: statusFilter === f ? 'var(--ink)' : 'var(--ink-3)',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
<div className="w-px h-[18px] mx-1" style={{ background: 'var(--line)' }} />
|
||||
{(['AP', 'SW', 'RTR'] as const).map(t => {
|
||||
const colors: Record<string, string> = { AP: 'var(--info)', SW: 'var(--accent)', RTR: 'var(--violet)' };
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTypeFilter(f => f === t ? null : t)}
|
||||
className="mono text-[11px] px-[8px] py-[4px] rounded-[5px] transition-colors"
|
||||
style={{
|
||||
background: typeFilter === t ? 'var(--surface-3)' : 'transparent',
|
||||
color: typeFilter === t ? colors[t] : 'var(--ink-3)',
|
||||
border: '1px solid var(--line)',
|
||||
}}
|
||||
>
|
||||
{t}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Device list */}
|
||||
{/* Device table */}
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-48 text-gray-400">Loading...</div>
|
||||
<div className="flex items-center justify-center h-48" style={{ color: 'var(--ink-4)' }}>Loading…</div>
|
||||
) : filtered.length === 0 ? (
|
||||
<div className="card p-12 flex flex-col items-center gap-4 text-center">
|
||||
<div className="w-16 h-16 bg-gray-100 dark:bg-slate-700 rounded-full flex items-center justify-center">
|
||||
<Router className="w-8 h-8 text-gray-400" />
|
||||
<div className="w-16 h-16 rounded-full flex items-center justify-center" style={{ background: 'var(--surface-2)' }}>
|
||||
<Router className="w-8 h-8" style={{ color: 'var(--ink-4)' }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-gray-700 dark:text-slate-300 font-medium">
|
||||
{search ? 'No devices match your search' : 'No devices added yet'}
|
||||
<p className="font-medium" style={{ color: 'var(--ink-2)' }}>
|
||||
{search || statusFilter !== 'all' || typeFilter ? 'No devices match your filters' : 'No devices added yet'}
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
{!search && 'Click "Add Device" to connect your first Mikrotik device'}
|
||||
<p className="text-[13px] mt-1" style={{ color: 'var(--ink-4)' }}>
|
||||
{!search && statusFilter === 'all' && !typeFilter && 'Click "Add device" to connect your first Mikrotik device'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-3">
|
||||
<SortableHeader
|
||||
label="Device"
|
||||
active={deviceSort.key === 'name'}
|
||||
dir={deviceSort.dir}
|
||||
onClick={() => flipSort(deviceSort, 'name', setDeviceSort)}
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header px-4 py-3">
|
||||
<SortableHeader
|
||||
label="IP Address"
|
||||
active={deviceSort.key === 'ip_address'}
|
||||
dir={deviceSort.dir}
|
||||
onClick={() => flipSort(deviceSort, 'ip_address', setDeviceSort)}
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header px-4 py-3">
|
||||
<SortableHeader
|
||||
label="Model"
|
||||
active={deviceSort.key === 'model'}
|
||||
dir={deviceSort.dir}
|
||||
onClick={() => flipSort(deviceSort, 'model', setDeviceSort)}
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header px-4 py-3">
|
||||
<SortableHeader
|
||||
label="ROS Version"
|
||||
active={deviceSort.key === 'ros_version'}
|
||||
dir={deviceSort.dir}
|
||||
onClick={() => flipSort(deviceSort, 'ros_version', setDeviceSort)}
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header px-4 py-3">
|
||||
<SortableHeader
|
||||
label="Status"
|
||||
active={deviceSort.key === 'status'}
|
||||
dir={deviceSort.dir}
|
||||
onClick={() => flipSort(deviceSort, 'status', setDeviceSort)}
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header px-4 py-3">
|
||||
<SortableHeader
|
||||
label="Last Seen"
|
||||
active={deviceSort.key === 'last_seen'}
|
||||
dir={deviceSort.dir}
|
||||
onClick={() => flipSort(deviceSort, 'last_seen', setDeviceSort)}
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header px-4 py-3 w-28">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{filtered.map((device) => (
|
||||
<tr
|
||||
key={device.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-slate-700/50 cursor-pointer transition-colors"
|
||||
onClick={() => navigate(`/devices/${device.id}`)}
|
||||
>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<DeviceTypeIcon type={device.device_type} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{device.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">
|
||||
{{ router: 'Router', switch: 'Switch', wireless_ap: 'Wireless AP', other: 'Other' }[device.device_type] ?? device.device_type}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-mono text-gray-600 dark:text-slate-400">
|
||||
{device.ip_address}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-slate-400">
|
||||
{device.model || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-600 dark:text-slate-400">
|
||||
<span className="flex items-center gap-1.5">
|
||||
{device.ros_version || '—'}
|
||||
{device.firmware_update_available && (
|
||||
<span
|
||||
title={device.latest_ros_version ? `RouterOS update available: ${device.latest_ros_version}` : 'RouterOS update available'}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<ArrowUpCircle className="w-3.5 h-3.5 text-amber-500" />
|
||||
</span>
|
||||
)}
|
||||
{device.routerboard_upgrade_available && (
|
||||
<span
|
||||
title={device.upgrade_firmware_version ? `RouterBOOT upgrade available: ${device.upgrade_firmware_version}` : 'RouterBOOT upgrade available'}
|
||||
className="flex-shrink-0"
|
||||
>
|
||||
<Cpu className="w-3.5 h-3.5 text-blue-400" />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<StatusDot status={device.status} />
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-400 dark:text-slate-500">
|
||||
{device.last_seen
|
||||
? new Date(device.last_seen).toLocaleString()
|
||||
: '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div
|
||||
className="flex items-center gap-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
{[
|
||||
{ key: null, label: '', w: 22 },
|
||||
{ key: 'name', label: 'DEVICE', w: null },
|
||||
{ key: null, label: 'TYPE', w: 72 },
|
||||
{ key: 'model', label: 'MODEL', w: null },
|
||||
{ key: 'ip_address', label: 'IP ADDRESS', w: null },
|
||||
{ key: 'ros_version',label: 'ROS', w: 110 },
|
||||
{ key: null, label: 'CPU', w: 110 },
|
||||
{ key: null, label: 'LOAD', w: 100 },
|
||||
{ key: 'last_seen', label: 'SEEN', w: 90 },
|
||||
{ key: null, label: '', w: 56 },
|
||||
].map(({ key, label, w }, i) => (
|
||||
<th
|
||||
key={i}
|
||||
className="table-header px-4 py-[10px] text-left"
|
||||
style={w ? { width: w } : undefined}
|
||||
>
|
||||
{canWrite && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => syncMutation.mutate(device.id)}
|
||||
disabled={syncMutation.isPending}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
title="Sync now"
|
||||
>
|
||||
<RefreshCw
|
||||
className={clsx('w-3.5 h-3.5', syncMutation.isPending && 'animate-spin')}
|
||||
/>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditDevice(device)}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
|
||||
title="Edit device"
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{deleteConfirm === device.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(device.id)}
|
||||
className="text-xs text-red-500 hover:text-red-600 font-medium px-1"
|
||||
>
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="text-xs text-gray-400 hover:text-gray-600 px-1"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(device.id)}
|
||||
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
|
||||
title="Delete device"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4 text-gray-300 dark:text-slate-600" />
|
||||
</div>
|
||||
</td>
|
||||
{key ? (
|
||||
<SortableHeader
|
||||
label={label}
|
||||
active={deviceSort.key === key as DeviceSortKey}
|
||||
dir={deviceSort.dir}
|
||||
onClick={() => flipSort(deviceSort, key as DeviceSortKey, setDeviceSort)}
|
||||
/>
|
||||
) : label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filtered.map((device, i) => {
|
||||
const cpuSpark = cpuHistories[device.id] ?? [];
|
||||
return (
|
||||
<tr
|
||||
key={device.id}
|
||||
className="cursor-pointer transition-colors"
|
||||
style={{
|
||||
borderBottom: i < filtered.length - 1 ? '1px solid var(--line-soft)' : 'none',
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--surface-2)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
onClick={() => navigate(`/devices/${device.id}`)}
|
||||
>
|
||||
<td className="px-4 py-[12px]">
|
||||
<GlowDot status={device.status} />
|
||||
</td>
|
||||
<td className="px-4 py-[12px]">
|
||||
<div className="text-[13px] font-medium" style={{ color: 'var(--ink)' }}>{device.name}</div>
|
||||
</td>
|
||||
<td className="px-4 py-[12px]">
|
||||
<TypePill type={device.device_type} />
|
||||
</td>
|
||||
<td className="px-4 py-[12px]">
|
||||
<span className="mono text-[11.5px]" style={{ color: 'var(--ink-2)' }}>{device.model || '—'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-[12px]">
|
||||
<span className="mono num-tab text-[12px]" style={{ color: 'var(--ink-2)' }}>{device.ip_address}</span>
|
||||
</td>
|
||||
<td className="px-4 py-[12px]">
|
||||
<span
|
||||
className="mono text-[11.5px] flex items-center gap-[4px]"
|
||||
style={{ color: device.firmware_update_available || device.routerboard_upgrade_available ? 'var(--warn)' : 'var(--ink-3)' }}
|
||||
>
|
||||
{(device.firmware_update_available || device.routerboard_upgrade_available) && (
|
||||
<span
|
||||
className="text-[9px] font-bold px-[4px] py-[1px] rounded-[3px]"
|
||||
style={{ background: 'var(--warn)', color: 'var(--accent-fg)' }}
|
||||
>↑</span>
|
||||
)}
|
||||
{device.ros_version || '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-[12px]">
|
||||
<CpuValue value={(() => { const arr = cpuHistories[device.id]; return arr && arr.length > 0 ? arr[arr.length - 1] : undefined; })()} />
|
||||
</td>
|
||||
<td className="px-4 py-[12px]">
|
||||
<LoadSparkline data={cpuSpark} />
|
||||
</td>
|
||||
<td className="px-4 py-[12px]">
|
||||
<span className="mono text-[11px]" style={{ color: 'var(--ink-3)' }}>
|
||||
{device.last_seen
|
||||
? new Date(device.last_seen).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
: '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-[12px]">
|
||||
<div
|
||||
className="flex items-center justify-end gap-1"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{canWrite && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => syncMutation.mutate(device.id)}
|
||||
disabled={syncMutation.isPending}
|
||||
className="p-1.5 rounded transition-colors"
|
||||
style={{ color: 'var(--ink-4)' }}
|
||||
title="Sync now"
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--accent)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--ink-4)')}
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', syncMutation.isPending && 'animate-spin')} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setEditDevice(device)}
|
||||
className="p-1.5 rounded transition-colors"
|
||||
style={{ color: 'var(--ink-4)' }}
|
||||
title="Edit device"
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--ink-2)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--ink-4)')}
|
||||
>
|
||||
<Pencil className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
{deleteConfirm === device.id ? (
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={() => deleteMutation.mutate(device.id)}
|
||||
className="text-[11px] font-medium px-1"
|
||||
style={{ color: 'var(--bad)' }}
|
||||
>Confirm</button>
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(null)}
|
||||
className="text-[11px] px-1"
|
||||
style={{ color: 'var(--ink-4)' }}
|
||||
>Cancel</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(device.id)}
|
||||
className="p-1.5 rounded transition-colors"
|
||||
style={{ color: 'var(--ink-4)' }}
|
||||
title="Delete device"
|
||||
onMouseEnter={e => (e.currentTarget.style.color = 'var(--bad)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.color = 'var(--ink-4)')}
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<ChevronRight className="w-4 h-4" style={{ color: 'var(--ink-4)' }} />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -452,20 +518,16 @@ export default function DevicesPage() {
|
||||
{discoveredList.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Radar className="w-4 h-4 text-amber-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-300">
|
||||
<Radar className="w-4 h-4" style={{ color: 'var(--warn)' }} />
|
||||
<h2 className="text-[13px] font-semibold" style={{ color: 'var(--ink-2)' }}>
|
||||
Discovered Devices
|
||||
</h2>
|
||||
<span className="text-xs text-gray-400 dark:text-slate-500">
|
||||
— Mikrotik neighbors seen by your managed devices, not yet added to management
|
||||
<span className="mono text-[11px]" style={{ color: 'var(--ink-4)' }}>
|
||||
— MikroTik neighbors seen by managed devices, not yet added
|
||||
</span>
|
||||
<label
|
||||
className={clsx(
|
||||
'ml-auto flex items-center gap-2 text-xs select-none',
|
||||
duplicateCount === 0
|
||||
? 'text-gray-300 dark:text-slate-600 cursor-not-allowed'
|
||||
: 'text-gray-600 dark:text-slate-400 cursor-pointer'
|
||||
)}
|
||||
className="ml-auto flex items-center gap-2 select-none mono text-[11px]"
|
||||
style={{ color: duplicateCount === 0 ? 'var(--ink-4)' : 'var(--ink-3)', cursor: duplicateCount === 0 ? 'not-allowed' : 'pointer' }}
|
||||
title={
|
||||
duplicateCount === 0
|
||||
? 'No duplicates of already-managed devices detected'
|
||||
@@ -474,20 +536,19 @@ export default function DevicesPage() {
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="rounded border-gray-300 dark:border-slate-600"
|
||||
checked={hideDuplicates}
|
||||
disabled={duplicateCount === 0}
|
||||
onChange={(e) => setHideDuplicates(e.target.checked)}
|
||||
/>
|
||||
Hide duplicates of managed devices
|
||||
Hide duplicates
|
||||
{duplicateCount > 0 && (
|
||||
<span className="text-gray-400 dark:text-slate-500">({duplicateCount})</span>
|
||||
<span style={{ color: 'var(--ink-4)' }}>({duplicateCount})</span>
|
||||
)}
|
||||
</label>
|
||||
{canWrite && visibleDiscovered.length > 0 && (
|
||||
<button
|
||||
onClick={() => setShowTryAllModal(true)}
|
||||
className="btn-primary text-xs py-1.5 px-3"
|
||||
className="btn-primary text-[12px] py-[5px] px-3"
|
||||
title="Try adding all discovered devices with one credential method"
|
||||
>
|
||||
Try All
|
||||
@@ -495,107 +556,89 @@ export default function DevicesPage() {
|
||||
)}
|
||||
</div>
|
||||
{visibleDiscovered.length === 0 ? (
|
||||
<div className="card p-4 text-xs text-gray-500 dark:text-slate-400 text-center">
|
||||
<div className="card p-4 text-center mono text-[11px]" style={{ color: 'var(--ink-4)' }}>
|
||||
All {discoveredList.length} discovered neighbor(s) are already managed devices.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-700/50">
|
||||
<th className="table-header px-4 py-2.5 text-left">
|
||||
<SortableHeader
|
||||
label="Identity"
|
||||
active={discoveredSort.key === 'identity'}
|
||||
dir={discoveredSort.dir}
|
||||
onClick={() => flipSort(discoveredSort, 'identity', setDiscoveredSort)}
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">
|
||||
<SortableHeader
|
||||
label="IP Address"
|
||||
active={discoveredSort.key === 'address'}
|
||||
dir={discoveredSort.dir}
|
||||
onClick={() => flipSort(discoveredSort, 'address', setDiscoveredSort)}
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">
|
||||
<SortableHeader
|
||||
label="MAC Address"
|
||||
active={discoveredSort.key === 'mac_address'}
|
||||
dir={discoveredSort.dir}
|
||||
onClick={() => flipSort(discoveredSort, 'mac_address', setDiscoveredSort)}
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">
|
||||
<SortableHeader
|
||||
label="Seen By"
|
||||
active={discoveredSort.key === 'seen_by'}
|
||||
dir={discoveredSort.dir}
|
||||
onClick={() => flipSort(discoveredSort, 'seen_by', setDiscoveredSort)}
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">
|
||||
<SortableHeader
|
||||
label="Last Seen"
|
||||
active={discoveredSort.key === 'discovered_at'}
|
||||
dir={discoveredSort.dir}
|
||||
onClick={() => flipSort(discoveredSort, 'discovered_at', setDiscoveredSort)}
|
||||
/>
|
||||
</th>
|
||||
<th className="table-header px-4 py-2.5 w-36" />
|
||||
<tr style={{ borderBottom: '1px solid var(--line)' }}>
|
||||
{(['Identity', 'IP Address', 'MAC Address', 'Seen By', 'Last Seen', ''] as const).map((label, i) => (
|
||||
<th key={i} className="table-header px-4 py-[10px] text-left" style={i === 5 ? { width: 140 } : undefined}>
|
||||
{label === 'Identity' && (
|
||||
<SortableHeader label="IDENTITY" active={discoveredSort.key === 'identity'} dir={discoveredSort.dir} onClick={() => flipSort(discoveredSort, 'identity', setDiscoveredSort)} />
|
||||
)}
|
||||
{label === 'IP Address' && (
|
||||
<SortableHeader label="IP ADDRESS" active={discoveredSort.key === 'address'} dir={discoveredSort.dir} onClick={() => flipSort(discoveredSort, 'address', setDiscoveredSort)} />
|
||||
)}
|
||||
{label === 'MAC Address' && (
|
||||
<SortableHeader label="MAC" active={discoveredSort.key === 'mac_address'} dir={discoveredSort.dir} onClick={() => flipSort(discoveredSort, 'mac_address', setDiscoveredSort)} />
|
||||
)}
|
||||
{label === 'Seen By' && (
|
||||
<SortableHeader label="SEEN BY" active={discoveredSort.key === 'seen_by'} dir={discoveredSort.dir} onClick={() => flipSort(discoveredSort, 'seen_by', setDiscoveredSort)} />
|
||||
)}
|
||||
{label === 'Last Seen' && (
|
||||
<SortableHeader label="LAST SEEN" active={discoveredSort.key === 'discovered_at'} dir={discoveredSort.dir} onClick={() => flipSort(discoveredSort, 'discovered_at', setDiscoveredSort)} />
|
||||
)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
<tbody>
|
||||
{visibleDiscovered.map((d, i) => (
|
||||
<tr
|
||||
key={d.mac_address || d.address || i}
|
||||
className={clsx(
|
||||
'hover:bg-amber-50/50 dark:hover:bg-amber-900/10',
|
||||
d.duplicate_of_device_id != null && 'opacity-70'
|
||||
)}
|
||||
style={{
|
||||
borderBottom: i < visibleDiscovered.length - 1 ? '1px solid var(--line-soft)' : 'none',
|
||||
opacity: d.duplicate_of_device_id != null ? 0.6 : 1,
|
||||
}}
|
||||
onMouseEnter={e => (e.currentTarget.style.background = 'var(--surface-2)')}
|
||||
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}
|
||||
>
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">
|
||||
<td className="px-4 py-[11px]">
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{d.identity || '—'}</span>
|
||||
<span className="text-[13px] font-medium" style={{ color: 'var(--ink)' }}>{d.identity || '—'}</span>
|
||||
{d.duplicate_of_device_id != null && (
|
||||
<span
|
||||
className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300"
|
||||
className="mono text-[9.5px] uppercase tracking-wide px-[6px] py-[2px] rounded"
|
||||
style={{ background: 'var(--good-bg)', color: 'var(--good)' }}
|
||||
title={`Matches managed device: ${d.duplicate_of_device_name ?? ''}`}
|
||||
>
|
||||
Already managed
|
||||
{d.duplicate_of_device_name ? ` · ${d.duplicate_of_device_name}` : ''}
|
||||
managed{d.duplicate_of_device_name ? ` · ${d.duplicate_of_device_name}` : ''}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-gray-600 dark:text-slate-400">
|
||||
<td className="px-4 py-[11px]">
|
||||
{d.address ? (
|
||||
d.address
|
||||
<span className="mono num-tab text-[12px]" style={{ color: 'var(--ink-2)' }}>{d.address}</span>
|
||||
) : (
|
||||
<span className="text-amber-500 dark:text-amber-400 italic text-xs font-sans">
|
||||
<span className="mono text-[11px] italic" style={{ color: 'var(--warn)' }}>
|
||||
Not detected — enter manually
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-500">
|
||||
{d.mac_address || '—'}
|
||||
<td className="px-4 py-[11px]">
|
||||
<span className="mono text-[11px]" style={{ color: 'var(--ink-3)' }}>{d.mac_address || '—'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-500 dark:text-slate-400 text-xs">
|
||||
{d.seen_by}
|
||||
<td className="px-4 py-[11px]">
|
||||
<span className="text-[12px]" style={{ color: 'var(--ink-3)' }}>{d.seen_by}</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-400 dark:text-slate-500 text-xs">
|
||||
{new Date(d.discovered_at).toLocaleString()}
|
||||
<td className="px-4 py-[11px]">
|
||||
<span className="mono text-[11px]" style={{ color: 'var(--ink-4)' }}>
|
||||
{new Date(d.discovered_at).toLocaleString()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<td className="px-4 py-[11px]">
|
||||
{canWrite && d.duplicate_of_device_id == null && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setAddPrefill({ name: d.identity || '', ip_address: d.address });
|
||||
setShowAddModal(true);
|
||||
}}
|
||||
className="btn-primary text-xs py-1 px-3 flex items-center gap-1"
|
||||
className="btn-primary text-[12px] py-[5px] px-3 flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3 h-3" />
|
||||
Add to Manager
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.10.8 Beta';
|
||||
export const APP_VERSION = 'v0.10.9 Beta';
|
||||
|
||||
@@ -5,6 +5,7 @@ export default {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
// Legacy brand palette — kept for backward compat with existing components
|
||||
brand: {
|
||||
50: '#eff6ff',
|
||||
100: '#dbeafe',
|
||||
@@ -21,11 +22,35 @@ export default {
|
||||
light: '#f1f5f9',
|
||||
dark: '#0f172a',
|
||||
},
|
||||
// Design token colors — reference CSS vars so light/dark switch automatically
|
||||
bg: 'var(--bg)',
|
||||
surface: 'var(--surface)',
|
||||
'surface-2': 'var(--surface-2)',
|
||||
'surface-3': 'var(--surface-3)',
|
||||
line: 'var(--line)',
|
||||
'line-soft': 'var(--line-soft)',
|
||||
ink: 'var(--ink)',
|
||||
'ink-2': 'var(--ink-2)',
|
||||
'ink-3': 'var(--ink-3)',
|
||||
'ink-4': 'var(--ink-4)',
|
||||
accent: 'var(--accent)',
|
||||
'accent-2': 'var(--accent-2)',
|
||||
'accent-soft': 'var(--accent-soft)',
|
||||
good: 'var(--good)',
|
||||
warn: 'var(--warn)',
|
||||
bad: 'var(--bad)',
|
||||
info: 'var(--info)',
|
||||
violet: 'var(--violet)',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Geist', 'ui-sans-serif', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['Geist Mono', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
|
||||
},
|
||||
animation: {
|
||||
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
|
||||
'fade-in': 'fadeIn 0.2s ease-out',
|
||||
'slide-in': 'slideIn 0.2s ease-out',
|
||||
'glow-pulse': 'glowPulse 2s ease-in-out infinite',
|
||||
},
|
||||
keyframes: {
|
||||
fadeIn: {
|
||||
@@ -36,6 +61,10 @@ export default {
|
||||
'0%': { transform: 'translateX(-10px)', opacity: '0' },
|
||||
'100%': { transform: 'translateX(0)', opacity: '1' },
|
||||
},
|
||||
glowPulse: {
|
||||
'0%, 100%': { opacity: '1' },
|
||||
'50%': { opacity: '0.4' },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user