feat(ui): navigation reorganization — consolidate duplicate pages, grouped sidebar (v0.16.4-beta)
UI/UX consolidation pass to reduce nav sprawl (~24 destinations -> ~17): - Merge Router Settings + Switch Settings (copy-paste duplicates) into one Network Services > Discovery & SNMP page with an All/Routers/Switches scope selector; LLDP + SNMP status tables merge both fleets with a type pill, apply fans out per scope - Retire Routers/Switches overview pages; the Devices type filter now lives in the URL (?type=AP|SW|RTR) and legacy routes redirect to the pre-filtered view. Wireless overview kept (RF Health / Rogue APs are unique content) - Fold Wireless Clients into Clients: All/Wired/Wireless segmented filter (URL-synced); wireless view swaps the Type column for SSID + signal quality columns - Regroup sidebar into Monitor / Network / Services / Operations sections; Devices and Wireless are collapsible groups (auto-open on active section, manual collapse always wins); Wireless group renders only when wireless APs exist - Rename Syslog -> Logging with a cross-link to live Events; group the Settings tabs into Platform / My Account / Fleet sections - Legacy routes (/routers, /switches, /routers/settings, /switches/settings, /wireless/clients) redirect so bookmarks keep working Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
662d7852fa
commit
dd1b6b7d13
+8
-10
@@ -11,13 +11,8 @@ import TopologyPage from './pages/TopologyPage';
|
||||
import BackupsPage from './pages/BackupsPage';
|
||||
import FirmwarePage from './pages/FirmwarePage';
|
||||
import SettingsPage from './pages/SettingsPage';
|
||||
import SwitchesOverviewPage from './pages/SwitchesOverviewPage';
|
||||
import SwitchesSettingsPage from './pages/SwitchesSettingsPage';
|
||||
import RoutersPage from './pages/RoutersPage';
|
||||
import RouterSettingsPage from './pages/RouterSettingsPage';
|
||||
import WirelessPage from './pages/WirelessPage';
|
||||
import WirelessSettingsPage from './pages/WirelessSettingsPage';
|
||||
import WirelessClientsPage from './pages/WirelessClientsPage';
|
||||
import GuestWifiPage from './pages/GuestWifiPage';
|
||||
import ClientDetailPage from './pages/ClientDetailPage';
|
||||
import NetworkServicesOverviewPage from './pages/NetworkServicesOverviewPage';
|
||||
@@ -27,6 +22,7 @@ import NetworkServicesNTPPage from './pages/NetworkServicesNTPPage';
|
||||
import NetworkServicesWireGuardPage from './pages/NetworkServicesWireGuardPage';
|
||||
import NetworkServicesSyslogPage from './pages/NetworkServicesSyslogPage';
|
||||
import NetworkServicesNetflowPage from './pages/NetworkServicesNetflowPage';
|
||||
import NetworkServicesDiscoveryPage from './pages/NetworkServicesDiscoveryPage';
|
||||
import TrafficAnalyticsPage from './pages/TrafficAnalyticsPage';
|
||||
import SecurityPage from './pages/SecurityPage';
|
||||
|
||||
@@ -60,13 +56,14 @@ export default function App() {
|
||||
<Route path="backups" element={<BackupsPage />} />
|
||||
<Route path="firmware" element={<FirmwarePage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="switches" element={<SwitchesOverviewPage />} />
|
||||
<Route path="switches/settings" element={<SwitchesSettingsPage />} />
|
||||
<Route path="routers" element={<RoutersPage />} />
|
||||
<Route path="routers/settings" element={<RouterSettingsPage />} />
|
||||
{/* Legacy routes — consolidated in the v0.16.4 UI reorganization */}
|
||||
<Route path="switches" element={<Navigate to="/devices?type=SW" replace />} />
|
||||
<Route path="switches/settings" element={<Navigate to="/network-services/discovery" replace />} />
|
||||
<Route path="routers" element={<Navigate to="/devices?type=RTR" replace />} />
|
||||
<Route path="routers/settings" element={<Navigate to="/network-services/discovery" replace />} />
|
||||
<Route path="wireless/clients" element={<Navigate to="/clients?type=wireless" replace />} />
|
||||
<Route path="wireless" element={<WirelessPage />} />
|
||||
<Route path="wireless/settings" element={<WirelessSettingsPage />} />
|
||||
<Route path="wireless/clients" element={<WirelessClientsPage />} />
|
||||
<Route path="wireless/guest" element={<GuestWifiPage />} />
|
||||
<Route path="network-services" element={<NetworkServicesOverviewPage />} />
|
||||
<Route path="network-services/dhcp" element={<NetworkServicesDHCPPage />} />
|
||||
@@ -75,6 +72,7 @@ export default function App() {
|
||||
<Route path="network-services/wireguard" element={<NetworkServicesWireGuardPage />} />
|
||||
<Route path="network-services/syslog" element={<NetworkServicesSyslogPage />} />
|
||||
<Route path="network-services/netflow" element={<NetworkServicesNetflowPage />} />
|
||||
<Route path="network-services/discovery" element={<NetworkServicesDiscoveryPage />} />
|
||||
<Route path="traffic" element={<TrafficAnalyticsPage />} />
|
||||
<Route path="security" element={<SecurityPage />} />
|
||||
</Route>
|
||||
|
||||
@@ -4,49 +4,41 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
LayoutDashboard, Router, Users, Bell, GitBranch, HardDrive,
|
||||
Settings, Network, ChevronLeft, ChevronRight, Layers, ChevronDown, SlidersHorizontal, X, Wifi,
|
||||
Server, Globe, Clock, Shield, FileText, Activity, BarChart3, Ticket, ArrowUpCircle,
|
||||
Server, Globe, Clock, Shield, FileText, Activity, BarChart3, Ticket, ArrowUpCircle, Radio,
|
||||
LayoutGrid,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { APP_VERSION } from '../../version';
|
||||
import { devicesApi } from '../../services/api';
|
||||
|
||||
const navItems = [
|
||||
const monitorItems = [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/devices', icon: Router, label: 'Devices' },
|
||||
{ to: '/clients', icon: Users, label: 'Clients' },
|
||||
{ to: '/traffic', icon: Activity, label: 'Traffic' },
|
||||
{ to: '/security', icon: Shield, label: 'Security' },
|
||||
{ to: '/events', icon: Bell, label: 'Events' },
|
||||
{ to: '/topology', icon: GitBranch, label: 'Topology' },
|
||||
{ to: '/backups', icon: HardDrive, label: 'Backups' },
|
||||
{ to: '/firmware', icon: ArrowUpCircle, label: 'Firmware' },
|
||||
];
|
||||
|
||||
const switchSubItems = [
|
||||
{ to: '/switches', icon: Layers, label: 'Overview' },
|
||||
{ to: '/switches/settings', icon: SlidersHorizontal, label: 'Settings' },
|
||||
];
|
||||
|
||||
const routerSubItems = [
|
||||
{ to: '/routers', icon: Router, label: 'Overview' },
|
||||
{ to: '/routers/settings', icon: SlidersHorizontal, label: 'Settings' },
|
||||
{ to: '/traffic', icon: Activity, label: 'Traffic' },
|
||||
{ to: '/events', icon: Bell, label: 'Events' },
|
||||
{ to: '/security', icon: Shield, label: 'Security' },
|
||||
];
|
||||
|
||||
const wirelessSubItems = [
|
||||
{ to: '/wireless', icon: Wifi, label: 'Overview' },
|
||||
{ to: '/wireless/clients', icon: Users, label: 'Clients' },
|
||||
{ to: '/wireless/settings', icon: SlidersHorizontal, label: 'Radios & SSIDs' },
|
||||
{ to: '/wireless/guest', icon: Ticket, label: 'Guest WiFi' },
|
||||
{ to: '/wireless/settings', icon: SlidersHorizontal, label: 'Settings' },
|
||||
];
|
||||
|
||||
const networkServicesSubItems = [
|
||||
{ to: '/network-services', icon: Network, label: 'Overview' },
|
||||
{ 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', icon: Network, label: 'Overview' },
|
||||
{ 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: 'Logging' },
|
||||
{ to: '/network-services/netflow', icon: BarChart3, label: 'NetFlow' },
|
||||
{ to: '/network-services/discovery', icon: Radio, label: 'Discovery & SNMP' },
|
||||
];
|
||||
|
||||
const operationsItems = [
|
||||
{ to: '/firmware', icon: ArrowUpCircle, label: 'Firmware' },
|
||||
{ to: '/backups', icon: HardDrive, label: 'Backups' },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
@@ -91,6 +83,20 @@ function NavItem({
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ label, isCollapsed }: { label: string; isCollapsed: boolean }) {
|
||||
if (isCollapsed) {
|
||||
return <div className="hidden md:block my-[8px] mx-2" style={{ height: 1, background: 'var(--line)' }} />;
|
||||
}
|
||||
return (
|
||||
<div
|
||||
className="px-[10px] pt-[14px] pb-[4px] text-[10px] font-semibold uppercase tracking-[0.08em] select-none"
|
||||
style={{ color: 'var(--ink-4)' }}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GroupHeader({
|
||||
icon: Icon,
|
||||
label,
|
||||
@@ -119,6 +125,39 @@ function GroupHeader({
|
||||
);
|
||||
}
|
||||
|
||||
/** Collapsed-sidebar icon link that represents a whole group. */
|
||||
function CollapsedGroupLink({
|
||||
to,
|
||||
icon: Icon,
|
||||
label,
|
||||
groupActive,
|
||||
onClick,
|
||||
}: {
|
||||
to: string;
|
||||
icon: React.ElementType;
|
||||
label: string;
|
||||
groupActive: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<NavLink
|
||||
to={to}
|
||||
title={label}
|
||||
onClick={onClick}
|
||||
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 || groupActive
|
||||
? 'bg-surface-3 text-ink border-accent'
|
||||
: 'text-ink-2 hover:bg-surface-3 hover:text-ink border-transparent'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Icon className="w-[17px] h-[17px] flex-shrink-0" />
|
||||
</NavLink>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const location = useLocation();
|
||||
@@ -135,21 +174,20 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
|
||||
const onlineCount = devices.filter(d => d.status === 'online').length;
|
||||
|
||||
const switchesActive = location.pathname.startsWith('/switches');
|
||||
const [switchesOpen, setSwitchesOpen] = useState(switchesActive);
|
||||
const effectiveSwitchesOpen = switchesOpen || switchesActive;
|
||||
|
||||
const routersActive = location.pathname.startsWith('/routers');
|
||||
const [routersOpen, setRoutersOpen] = useState(routersActive);
|
||||
const effectiveRoutersOpen = routersOpen || routersActive;
|
||||
// Collapsible groups: until the user touches the chevron (pref === null) a
|
||||
// group is open exactly when its section is active; after that the manual
|
||||
// choice wins, so collapse always works — even inside the active section.
|
||||
const devicesActive = location.pathname.startsWith('/devices');
|
||||
const [devicesOpenPref, setDevicesOpenPref] = useState<boolean | null>(null);
|
||||
const devicesOpen = devicesOpenPref ?? devicesActive;
|
||||
|
||||
const wirelessActive = location.pathname.startsWith('/wireless');
|
||||
const [wirelessOpen, setWirelessOpen] = useState(wirelessActive);
|
||||
const effectiveWirelessOpen = wirelessOpen || wirelessActive;
|
||||
const [wirelessOpenPref, setWirelessOpenPref] = useState<boolean | null>(null);
|
||||
const wirelessOpen = wirelessOpenPref ?? wirelessActive;
|
||||
|
||||
const networkServicesActive = location.pathname.startsWith('/network-services');
|
||||
const [networkServicesOpen, setNetworkServicesOpen] = useState(networkServicesActive);
|
||||
const effectiveNetworkServicesOpen = networkServicesOpen || networkServicesActive;
|
||||
const [networkServicesOpenPref, setNetworkServicesOpenPref] = useState<boolean | null>(null);
|
||||
const networkServicesOpen = networkServicesOpenPref ?? networkServicesActive;
|
||||
|
||||
const isCollapsed = collapsed;
|
||||
|
||||
@@ -157,6 +195,16 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
onMobileClose();
|
||||
};
|
||||
|
||||
// Devices sub-links deep-link into the type-filtered device list.
|
||||
const deviceTypeLinks = [
|
||||
{ to: '/devices', icon: LayoutGrid, label: 'All Devices' },
|
||||
...(hasRouters ? [{ to: '/devices?type=RTR', icon: Router, label: 'Routers' }] : []),
|
||||
...(hasSwitches ? [{ to: '/devices?type=SW', icon: Layers, label: 'Switches' }] : []),
|
||||
...(hasWireless ? [{ to: '/devices?type=AP', icon: Wifi, label: 'Wireless APs' }] : []),
|
||||
];
|
||||
// With a single device type the sub-links add nothing over the plain entry.
|
||||
const showDevicesGroup = deviceTypeLinks.length > 2;
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={clsx(
|
||||
@@ -196,119 +244,73 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
|
||||
{/* Navigation */}
|
||||
<nav className="flex-1 px-[14px] py-3 space-y-[1px] overflow-y-auto">
|
||||
{navItems.map(({ to, icon, label }) => (
|
||||
{/* ── Monitor ── */}
|
||||
<SectionLabel label="Monitor" isCollapsed={isCollapsed} />
|
||||
{monitorItems.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)' }} />
|
||||
{/* ── Network ── */}
|
||||
<SectionLabel label="Network" isCollapsed={isCollapsed} />
|
||||
|
||||
{/* Devices — collapsible group when more than one device type exists */}
|
||||
{isCollapsed ? (
|
||||
<CollapsedGroupLink to="/devices" icon={Router} label="Devices" groupActive={devicesActive} onClick={handleNavClick} />
|
||||
) : !showDevicesGroup ? (
|
||||
<NavItem to="/devices" icon={Router} label="Devices" isCollapsed={false} onClick={handleNavClick} />
|
||||
) : (
|
||||
<>
|
||||
<GroupHeader
|
||||
icon={Router}
|
||||
label="Devices"
|
||||
isActive={devicesActive}
|
||||
isOpen={devicesOpen}
|
||||
onToggle={() => setDevicesOpenPref(!devicesOpen)}
|
||||
/>
|
||||
{devicesOpen && (
|
||||
<div className="ml-4 pl-3 space-y-[1px]" style={{ borderLeft: '1px solid var(--line)' }}>
|
||||
{deviceTypeLinks.map(({ to, icon: Icon, label }) => {
|
||||
const [path, query] = to.split('?');
|
||||
const isActive = location.pathname === path &&
|
||||
(query ? location.search === `?${query}` : !location.search.includes('type='));
|
||||
return (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
onClick={handleNavClick}
|
||||
className={clsx(
|
||||
'flex items-center gap-[10px] rounded-[6px] text-[13px] font-medium transition-colors duration-150 px-[10px] py-[7px] border-l-2',
|
||||
isActive
|
||||
? 'bg-surface-3 text-ink font-semibold border-accent pl-[8px]'
|
||||
: 'text-ink-2 hover:bg-surface-3 hover:text-ink border-transparent'
|
||||
)}
|
||||
>
|
||||
<Icon className="w-[17px] h-[17px] flex-shrink-0" />
|
||||
<span>{label}</span>
|
||||
</NavLink>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Switches group */}
|
||||
{hasSwitches && (
|
||||
isCollapsed ? (
|
||||
<NavLink
|
||||
to="/switches"
|
||||
title="Switches"
|
||||
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 || switchesActive
|
||||
? 'bg-surface-3 text-ink border-accent'
|
||||
: 'text-ink-2 hover:bg-surface-3 hover:text-ink border-transparent'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Layers className="w-[17px] h-[17px] flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<GroupHeader
|
||||
icon={Layers}
|
||||
label="Switches"
|
||||
isActive={switchesActive}
|
||||
isOpen={effectiveSwitchesOpen}
|
||||
onToggle={() => setSwitchesOpen(o => !o)}
|
||||
/>
|
||||
{effectiveSwitchesOpen && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
<NavItem to="/clients" icon={Users} label="Clients" isCollapsed={isCollapsed} onClick={handleNavClick} />
|
||||
|
||||
{/* Routers group */}
|
||||
{hasRouters && (
|
||||
isCollapsed ? (
|
||||
<NavLink
|
||||
to="/routers"
|
||||
title="Routers"
|
||||
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 || routersActive
|
||||
? 'bg-surface-3 text-ink border-accent'
|
||||
: 'text-ink-2 hover:bg-surface-3 hover:text-ink border-transparent'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Router className="w-[17px] h-[17px] flex-shrink-0" />
|
||||
</NavLink>
|
||||
) : (
|
||||
<>
|
||||
<GroupHeader
|
||||
icon={Router}
|
||||
label="Routers"
|
||||
isActive={routersActive}
|
||||
isOpen={effectiveRoutersOpen}
|
||||
onToggle={() => setRoutersOpen(o => !o)}
|
||||
/>
|
||||
{effectiveRoutersOpen && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Wireless group */}
|
||||
{/* Wireless group (only when wireless APs are managed) */}
|
||||
{hasWireless && (
|
||||
isCollapsed ? (
|
||||
<NavLink
|
||||
to="/wireless"
|
||||
title="Wireless"
|
||||
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 || wirelessActive
|
||||
? 'bg-surface-3 text-ink border-accent'
|
||||
: 'text-ink-2 hover:bg-surface-3 hover:text-ink border-transparent'
|
||||
)
|
||||
}
|
||||
>
|
||||
<Wifi className="w-[17px] h-[17px] flex-shrink-0" />
|
||||
</NavLink>
|
||||
<CollapsedGroupLink to="/wireless" icon={Wifi} label="Wireless" groupActive={wirelessActive} onClick={handleNavClick} />
|
||||
) : (
|
||||
<>
|
||||
<GroupHeader
|
||||
icon={Wifi}
|
||||
label="Wireless"
|
||||
isActive={wirelessActive}
|
||||
isOpen={effectiveWirelessOpen}
|
||||
onToggle={() => setWirelessOpen(o => !o)}
|
||||
isOpen={wirelessOpen}
|
||||
onToggle={() => setWirelessOpenPref(!wirelessOpen)}
|
||||
/>
|
||||
{effectiveWirelessOpen && (
|
||||
{wirelessOpen && (
|
||||
<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} />
|
||||
@@ -319,44 +321,34 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 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-[17px] h-[17px] 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>
|
||||
{/* ── Services ── */}
|
||||
<SectionLabel label="Services" isCollapsed={isCollapsed} />
|
||||
{isCollapsed ? (
|
||||
<CollapsedGroupLink to="/network-services" icon={Network} label="Network Services" groupActive={networkServicesActive} onClick={handleNavClick} />
|
||||
) : (
|
||||
<>
|
||||
<GroupHeader
|
||||
icon={Network}
|
||||
label="Network Services"
|
||||
isActive={networkServicesActive}
|
||||
isOpen={networkServicesOpen}
|
||||
onToggle={() => setNetworkServicesOpenPref(!networkServicesOpen)}
|
||||
/>
|
||||
{networkServicesOpen && (
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Operations ── */}
|
||||
<SectionLabel label="Operations" isCollapsed={isCollapsed} />
|
||||
{operationsItems.map(({ to, icon, label }) => (
|
||||
<NavItem key={to} to={to} icon={icon} label={label} isCollapsed={isCollapsed} onClick={handleNavClick} />
|
||||
))}
|
||||
</nav>
|
||||
|
||||
{/* Footer: poll status card + settings + collapse */}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import {
|
||||
Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown,
|
||||
ChevronLeft, ChevronRight, RefreshCw,
|
||||
@@ -61,6 +61,20 @@ function CategoryIcon({ category }: { category?: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function signalLabel(dbm: number): string {
|
||||
if (dbm >= -55) return 'Excellent';
|
||||
if (dbm >= -65) return 'Good';
|
||||
if (dbm >= -75) return 'Fair';
|
||||
return 'Poor';
|
||||
}
|
||||
|
||||
function signalColor(dbm: number): string {
|
||||
if (dbm >= -55) return 'text-green-600 dark:text-green-400';
|
||||
if (dbm >= -65) return 'text-lime-600 dark:text-lime-400';
|
||||
if (dbm >= -75) return 'text-yellow-600 dark:text-yellow-400';
|
||||
return 'text-red-500 dark:text-red-400';
|
||||
}
|
||||
|
||||
function formatDataBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
@@ -174,6 +188,21 @@ export default function ClientsPage() {
|
||||
const canWrite = useCanWrite();
|
||||
const [search, setSearch] = useState('');
|
||||
const [showAll, setShowAll] = useState(false);
|
||||
// Wired/wireless filter lives in the URL (?type=wireless) so the sidebar and
|
||||
// the legacy /wireless/clients route can deep-link to a pre-filtered view.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const rawType = searchParams.get('type');
|
||||
const typeFilter: 'wired' | 'wireless' | null =
|
||||
rawType === 'wired' || rawType === 'wireless' ? rawType : null;
|
||||
const setTypeFilter = (t: 'wired' | 'wireless' | null) => {
|
||||
setSearchParams(prev => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (t) next.set('type', t); else next.delete('type');
|
||||
return next;
|
||||
}, { replace: true });
|
||||
setPage(0);
|
||||
};
|
||||
const isWireless = typeFilter === 'wireless';
|
||||
const [page, setPage] = useState(0);
|
||||
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
||||
const [sortCol, setSortCol] = useState<string>('last_seen');
|
||||
@@ -204,10 +233,10 @@ export default function ClientsPage() {
|
||||
};
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['clients', { search, showAll, page, pageSize, sortCol, sortDir }],
|
||||
queryKey: ['clients', { search, showAll, typeFilter, page, pageSize, sortCol, sortDir }],
|
||||
queryFn: () =>
|
||||
clientsApi
|
||||
.list({ search: search || undefined, active: showAll ? undefined : true, limit: pageSize, offset: page * pageSize, sort: sortCol, dir: sortDir })
|
||||
.list({ search: search || undefined, active: showAll ? undefined : true, client_type: typeFilter ?? undefined, limit: pageSize, offset: page * pageSize, sort: sortCol, dir: sortDir })
|
||||
.then((r) => r.data),
|
||||
refetchInterval: refreshInterval ?? false,
|
||||
// Keep the current rows on screen while a sort/page/search refetch is in
|
||||
@@ -266,6 +295,27 @@ export default function ClientsPage() {
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||
/>
|
||||
</div>
|
||||
{/* Wired / wireless segmented filter */}
|
||||
<div className="flex rounded-lg border border-gray-300 dark:border-slate-600 overflow-hidden">
|
||||
{([
|
||||
{ key: null, label: 'All' },
|
||||
{ key: 'wired' as const, label: 'Wired' },
|
||||
{ key: 'wireless' as const, label: 'Wireless' },
|
||||
]).map(({ key, label }) => (
|
||||
<button
|
||||
key={label}
|
||||
onClick={() => setTypeFilter(key)}
|
||||
className={clsx(
|
||||
'px-3 py-2 text-sm font-medium transition-colors',
|
||||
typeFilter === key
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 text-gray-600 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setShowAll((s) => !s); setPage(0); }}
|
||||
className={clsx(
|
||||
@@ -329,14 +379,10 @@ export default function ClientsPage() {
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm table-fixed">
|
||||
<colgroup>
|
||||
<col style={{ width: '22%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
<col style={{ width: '13%' }} />
|
||||
<col style={{ width: '11%' }} />
|
||||
<col style={{ width: '7%' }} />
|
||||
<col style={{ width: '13%' }} />
|
||||
<col style={{ width: '10%' }} />
|
||||
<col style={{ width: '12%' }} />
|
||||
{(isWireless
|
||||
? ['20%', '11%', '11%', '11%', '9%', '6%', '12%', '9%', '11%']
|
||||
: ['22%', '12%', '13%', '11%', '7%', '13%', '10%', '12%']
|
||||
).map((w, i) => <col key={i} style={{ width: w }} />)}
|
||||
{showAll && <col style={{ width: '6%' }} />}
|
||||
</colgroup>
|
||||
<thead>
|
||||
@@ -344,27 +390,34 @@ export default function ClientsPage() {
|
||||
{([
|
||||
{ col: 'hostname', label: 'Host / Vendor / MAC', align: 'left' },
|
||||
{ col: 'ip_address', label: 'IP Address', align: 'left' },
|
||||
{ col: 'client_type', label: 'Type', align: 'left' },
|
||||
// When filtered to wireless, the Type column is redundant —
|
||||
// show SSID + signal quality instead (not server-sortable).
|
||||
...(isWireless
|
||||
? [
|
||||
{ col: 'ssid', label: 'SSID', align: 'left' as const, noSort: true },
|
||||
{ col: 'signal_strength', label: 'Signal', align: 'left' as const, noSort: true },
|
||||
]
|
||||
: [{ col: 'client_type', label: 'Type', align: 'left' as const }]),
|
||||
{ col: 'interface_name', label: 'Port', align: 'left' },
|
||||
{ col: 'vlan_id', label: 'VLAN', align: 'left' },
|
||||
{ col: 'device_name', label: 'Device', align: 'left' },
|
||||
{ col: 'traffic_today_bytes', label: 'Data (today)', align: 'left' },
|
||||
{ col: 'last_seen', label: 'Last Seen', align: 'left' },
|
||||
] as { col: string; label: string; align: 'left' | 'right' }[]).map(({ col, label, align }) => (
|
||||
] as { col: string; label: string; align: 'left' | 'right'; noSort?: boolean }[]).map(({ col, label, align, noSort }) => (
|
||||
<th
|
||||
key={col}
|
||||
className={clsx('table-header px-4 py-2.5 cursor-pointer select-none whitespace-nowrap', `text-${align}`)}
|
||||
onClick={() => toggleSort(col)}
|
||||
className={clsx('table-header px-4 py-2.5 select-none whitespace-nowrap', !noSort && 'cursor-pointer', `text-${align}`)}
|
||||
onClick={noSort ? undefined : () => toggleSort(col)}
|
||||
>
|
||||
<span className="inline-flex items-center gap-1">
|
||||
{label}
|
||||
{sortCol === col ? (
|
||||
{!noSort && (sortCol === col ? (
|
||||
sortDir === 'asc'
|
||||
? <ChevronUp className="w-3 h-3 text-blue-500" />
|
||||
: <ChevronDown className="w-3 h-3 text-blue-500" />
|
||||
) : (
|
||||
<ChevronsUpDown className="w-3 h-3 text-gray-300 dark:text-slate-600" />
|
||||
)}
|
||||
))}
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
@@ -413,26 +466,44 @@ export default function ClientsPage() {
|
||||
<td className="px-4 py-2.5 font-mono text-gray-600 dark:text-slate-400 truncate">
|
||||
{client.ip_address || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 text-xs font-medium',
|
||||
client.client_type === 'wireless'
|
||||
? 'text-purple-600 dark:text-purple-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
)}
|
||||
>
|
||||
{client.client_type === 'wireless' ? (
|
||||
<Wifi className="w-3 h-3" />
|
||||
) : (
|
||||
<Network className="w-3 h-3" />
|
||||
)}
|
||||
{client.client_type}
|
||||
{client.signal_strength != null && (
|
||||
<span className="text-gray-400 ml-1">({client.signal_strength} dBm)</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
{isWireless ? (
|
||||
<>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-600 dark:text-slate-400 truncate">
|
||||
{(client as Client & { ssid?: string }).ssid || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs whitespace-nowrap">
|
||||
{client.signal_strength != null ? (
|
||||
<span className={clsx('font-medium', signalColor(client.signal_strength))}>
|
||||
{client.signal_strength} dBm
|
||||
<span className="ml-1 font-normal opacity-75">({signalLabel(client.signal_strength)})</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-slate-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
</>
|
||||
) : (
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={clsx(
|
||||
'inline-flex items-center gap-1 text-xs font-medium',
|
||||
client.client_type === 'wireless'
|
||||
? 'text-purple-600 dark:text-purple-400'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
)}
|
||||
>
|
||||
{client.client_type === 'wireless' ? (
|
||||
<Wifi className="w-3 h-3" />
|
||||
) : (
|
||||
<Network className="w-3 h-3" />
|
||||
)}
|
||||
{client.client_type}
|
||||
{client.signal_strength != null && (
|
||||
<span className="text-gray-400 ml-1">({client.signal_strength} dBm)</span>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
)}
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400 truncate">
|
||||
{client.interface_name || '—'}
|
||||
</td>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Plus, RefreshCw, Router, Wifi, Trash2, ChevronRight, Search,
|
||||
@@ -125,7 +125,18 @@ export default function DevicesPage() {
|
||||
dir: 'desc',
|
||||
});
|
||||
const [statusFilter, setStatusFilter] = useState<'all' | 'online' | 'offline' | 'updates'>('all');
|
||||
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||
// Type filter lives in the URL (?type=AP|SW|RTR) so the sidebar and legacy
|
||||
// /routers and /switches routes can deep-link to a pre-filtered view.
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const rawType = searchParams.get('type');
|
||||
const typeFilter = rawType === 'AP' || rawType === 'SW' || rawType === 'RTR' ? rawType : null;
|
||||
const setTypeFilter = (t: string | null) => {
|
||||
setSearchParams(prev => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (t) next.set('type', t); else next.delete('type');
|
||||
return next;
|
||||
}, { replace: true });
|
||||
};
|
||||
const [tagFilter, setTagFilter] = useState<number | null>(null);
|
||||
|
||||
const { data: devices = [], isLoading } = useQuery({
|
||||
@@ -333,7 +344,7 @@ export default function DevicesPage() {
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTypeFilter(f => f === t ? null : t)}
|
||||
onClick={() => setTypeFilter(typeFilter === t ? null : t)}
|
||||
className="mono text-[11px] px-[8px] py-[4px] rounded-[5px] transition-colors"
|
||||
style={{
|
||||
background: typeFilter === t ? 'var(--surface-3)' : 'transparent',
|
||||
|
||||
+174
-57
@@ -1,13 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
Radio, RefreshCw, CheckCircle, XCircle, AlertCircle, Check,
|
||||
HelpCircle, Shield, Eye, EyeOff,
|
||||
HelpCircle, Shield, Eye, EyeOff, Router as RouterIcon, Layers,
|
||||
} from 'lucide-react';
|
||||
import { routersApi } from '../services/api';
|
||||
import { routersApi, switchesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type Scope = 'all' | 'routers' | 'switches';
|
||||
|
||||
interface LldpRow {
|
||||
id: number; name: string; ip_address: string;
|
||||
enabled: boolean | null; protocol: string | null; error?: string;
|
||||
kind: 'router' | 'switch';
|
||||
}
|
||||
|
||||
interface SnmpRow {
|
||||
id: number; name: string; ip_address: string;
|
||||
enabled: boolean | null; community_name?: string; version?: string;
|
||||
auth_protocol?: string; priv_protocol?: string;
|
||||
contact?: string; location?: string; trap_target?: string;
|
||||
error?: string;
|
||||
kind: 'router' | 'switch';
|
||||
}
|
||||
|
||||
interface SnmpForm {
|
||||
enabled: boolean;
|
||||
community_name: string;
|
||||
@@ -34,6 +51,30 @@ const DEFAULT_SNMP: SnmpForm = {
|
||||
priv_password: '',
|
||||
};
|
||||
|
||||
const SCOPES: { key: Scope; label: string }[] = [
|
||||
{ key: 'all', label: 'All Devices' },
|
||||
{ key: 'routers', label: 'Routers' },
|
||||
{ key: 'switches', label: 'Switches' },
|
||||
];
|
||||
|
||||
function scopeNoun(scope: Scope): string {
|
||||
return scope === 'routers' ? 'routers' : scope === 'switches' ? 'switches' : 'routers & switches';
|
||||
}
|
||||
|
||||
function KindPill({ kind }: { kind: 'router' | 'switch' }) {
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium',
|
||||
kind === 'router'
|
||||
? 'bg-violet-100 dark:bg-violet-900/30 text-violet-700 dark:text-violet-300'
|
||||
: 'bg-emerald-100 dark:bg-emerald-900/30 text-emerald-700 dark:text-emerald-300'
|
||||
)}>
|
||||
{kind === 'router' ? <RouterIcon className="w-3 h-3" /> : <Layers className="w-3 h-3" />}
|
||||
{kind === 'router' ? 'Router' : 'Switch'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PasswordInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
@@ -58,19 +99,48 @@ function PasswordInput({ value, onChange, placeholder }: { value: string; onChan
|
||||
);
|
||||
}
|
||||
|
||||
export default function RouterSettingsPage() {
|
||||
export default function NetworkServicesDiscoveryPage() {
|
||||
const canWrite = useCanWrite();
|
||||
const [scope, setScope] = useState<Scope>('all');
|
||||
|
||||
const inScope = <T extends { kind: 'router' | 'switch' }>(rows: T[]) =>
|
||||
scope === 'all' ? rows : rows.filter(r => (scope === 'routers' ? r.kind === 'router' : r.kind === 'switch'));
|
||||
|
||||
// ── LLDP state ──────────────────────────────────────────────────────────────
|
||||
const [lldpApplyResult, setLldpApplyResult] = useState<{ applied: number; total: number } | null>(null);
|
||||
const [lldpApplyError, setLldpApplyError] = useState('');
|
||||
|
||||
const { data: lldpStatuses = [], isLoading: lldpLoading, refetch: refetchLldp, isFetching: lldpFetching } =
|
||||
useQuery({ queryKey: ['routers-lldp'], queryFn: () => routersApi.getLldpStatus().then(r => r.data) });
|
||||
const routerLldp = useQuery({
|
||||
queryKey: ['routers-lldp'],
|
||||
queryFn: () => routersApi.getLldpStatus().then(r => r.data),
|
||||
});
|
||||
const switchLldp = useQuery({
|
||||
queryKey: ['switches-lldp'],
|
||||
queryFn: () => switchesApi.getLldpStatus().then(r => r.data),
|
||||
});
|
||||
|
||||
const lldpLoading = routerLldp.isLoading || switchLldp.isLoading;
|
||||
const lldpFetching = routerLldp.isFetching || switchLldp.isFetching;
|
||||
const refetchLldp = () => { routerLldp.refetch(); switchLldp.refetch(); };
|
||||
|
||||
const lldpAll: LldpRow[] = [
|
||||
...(routerLldp.data ?? []).map(r => ({ ...r, kind: 'router' as const })),
|
||||
...(switchLldp.data ?? []).map(r => ({ ...r, kind: 'switch' as const })),
|
||||
];
|
||||
const lldpStatuses = inScope(lldpAll);
|
||||
|
||||
const setLldpMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => routersApi.setLldp(enabled),
|
||||
onSuccess: (res) => { setLldpApplyResult({ applied: res.data.applied, total: res.data.total }); setLldpApplyError(''); refetchLldp(); },
|
||||
mutationFn: async (enabled: boolean) => {
|
||||
const calls = [];
|
||||
if (scope !== 'switches') calls.push(routersApi.setLldp(enabled));
|
||||
if (scope !== 'routers') calls.push(switchesApi.setLldp(enabled));
|
||||
const results = await Promise.all(calls);
|
||||
return results.reduce(
|
||||
(acc, r) => ({ applied: acc.applied + r.data.applied, total: acc.total + r.data.total }),
|
||||
{ applied: 0, total: 0 },
|
||||
);
|
||||
},
|
||||
onSuccess: (res) => { setLldpApplyResult(res); setLldpApplyError(''); refetchLldp(); },
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setLldpApplyError(msg || 'Failed to apply LLDP settings');
|
||||
@@ -88,36 +158,59 @@ export default function RouterSettingsPage() {
|
||||
|
||||
const sf = (patch: Partial<SnmpForm>) => { setSnmpForm(f => ({ ...f, ...patch })); setSnmpFormEdited(true); };
|
||||
|
||||
const { data: snmpStatuses = [], isLoading: snmpLoading, refetch: refetchSnmp, isFetching: snmpFetching } =
|
||||
useQuery({
|
||||
queryKey: ['routers-snmp'],
|
||||
queryFn: () => routersApi.getSnmpStatus().then(r => {
|
||||
const first = r.data.find(s => s.enabled != null && !s.error);
|
||||
if (first && !snmpFormEdited) {
|
||||
setSnmpForm({
|
||||
enabled: first.enabled ?? true,
|
||||
community_name: first.community_name ?? 'public',
|
||||
version: (first.version as 'v1' | 'v2c' | 'v3') ?? 'v2c',
|
||||
contact: first.contact ?? '',
|
||||
location: first.location ?? '',
|
||||
trap_target: first.trap_target ?? '',
|
||||
auth_protocol: first.auth_protocol ?? 'MD5',
|
||||
auth_password: '',
|
||||
priv_protocol: first.priv_protocol ?? 'none',
|
||||
priv_password: '',
|
||||
});
|
||||
}
|
||||
return r.data;
|
||||
}),
|
||||
});
|
||||
const routerSnmp = useQuery({
|
||||
queryKey: ['routers-snmp'],
|
||||
queryFn: () => routersApi.getSnmpStatus().then(r => r.data),
|
||||
});
|
||||
const switchSnmp = useQuery({
|
||||
queryKey: ['switches-snmp'],
|
||||
queryFn: () => switchesApi.getSnmpStatus().then(r => r.data),
|
||||
});
|
||||
|
||||
const snmpLoading = routerSnmp.isLoading || switchSnmp.isLoading;
|
||||
const snmpFetching = routerSnmp.isFetching || switchSnmp.isFetching;
|
||||
const refetchSnmp = () => { routerSnmp.refetch(); switchSnmp.refetch(); };
|
||||
|
||||
const snmpAll: SnmpRow[] = [
|
||||
...(routerSnmp.data ?? []).map(r => ({ ...r, kind: 'router' as const })),
|
||||
...(switchSnmp.data ?? []).map(r => ({ ...r, kind: 'switch' as const })),
|
||||
];
|
||||
const snmpStatuses = inScope(snmpAll);
|
||||
|
||||
// Pre-populate the form from the first successful device result, unless the
|
||||
// user has already started editing.
|
||||
useEffect(() => {
|
||||
if (snmpFormEdited) return;
|
||||
const first = snmpAll.find(s => s.enabled != null && !s.error);
|
||||
if (first) {
|
||||
setSnmpForm({
|
||||
enabled: first.enabled ?? true,
|
||||
community_name: first.community_name ?? 'public',
|
||||
version: (first.version as 'v1' | 'v2c' | 'v3') ?? 'v2c',
|
||||
contact: first.contact ?? '',
|
||||
location: first.location ?? '',
|
||||
trap_target: first.trap_target ?? '',
|
||||
auth_protocol: first.auth_protocol ?? 'MD5',
|
||||
auth_password: '',
|
||||
priv_protocol: first.priv_protocol ?? 'none',
|
||||
priv_password: '',
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [routerSnmp.data, switchSnmp.data]);
|
||||
|
||||
const setSnmpMutation = useMutation({
|
||||
mutationFn: () => routersApi.setSnmp(snmpForm),
|
||||
onSuccess: (res) => {
|
||||
setSnmpApplyResult({ applied: res.data.applied, total: res.data.total });
|
||||
setSnmpApplyError('');
|
||||
refetchSnmp();
|
||||
mutationFn: async () => {
|
||||
const calls = [];
|
||||
if (scope !== 'switches') calls.push(routersApi.setSnmp(snmpForm));
|
||||
if (scope !== 'routers') calls.push(switchesApi.setSnmp(snmpForm));
|
||||
const results = await Promise.all(calls);
|
||||
return results.reduce(
|
||||
(acc, r) => ({ applied: acc.applied + r.data.applied, total: acc.total + r.data.total }),
|
||||
{ applied: 0, total: 0 },
|
||||
);
|
||||
},
|
||||
onSuccess: (res) => { setSnmpApplyResult(res); setSnmpApplyError(''); refetchSnmp(); },
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setSnmpApplyError(msg || 'Failed to apply SNMP settings');
|
||||
@@ -129,11 +222,31 @@ export default function RouterSettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Router Settings</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Network-wide configuration applied to all managed routers
|
||||
</p>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Discovery & SNMP</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Network-wide LLDP discovery and SNMP monitoring configuration
|
||||
</p>
|
||||
</div>
|
||||
{/* Scope selector */}
|
||||
<div className="flex rounded-lg border border-gray-300 dark:border-slate-600 overflow-hidden">
|
||||
{SCOPES.map(s => (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
onClick={() => { setScope(s.key); setLldpApplyResult(null); setSnmpApplyResult(null); }}
|
||||
className={clsx(
|
||||
'px-4 py-1.5 text-sm font-medium transition-colors',
|
||||
scope === s.key
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 text-gray-600 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
{s.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── LLDP Card ──────────────────────────────────────────────────────────── */}
|
||||
@@ -147,7 +260,7 @@ export default function RouterSettingsPage() {
|
||||
Link Layer Discovery Protocol (LLDP)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
||||
LLDP lets your routers announce themselves to neighboring devices and collect
|
||||
LLDP lets your devices announce themselves to neighbors and collect
|
||||
neighbor information. Enabling LLDP improves the accuracy of the network topology map.
|
||||
</p>
|
||||
</div>
|
||||
@@ -161,26 +274,27 @@ export default function RouterSettingsPage() {
|
||||
'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400'
|
||||
)}>
|
||||
{allEnabled ? <CheckCircle className="w-4 h-4" /> : allDisabled ? <XCircle className="w-4 h-4" /> : <HelpCircle className="w-4 h-4" />}
|
||||
{allEnabled ? 'LLDP is enabled on all online routers' :
|
||||
allDisabled ? 'LLDP is disabled on all online routers' :
|
||||
'LLDP state is mixed across routers'}
|
||||
{allEnabled ? `LLDP is enabled on all online ${scopeNoun(scope)}` :
|
||||
allDisabled ? `LLDP is disabled on all online ${scopeNoun(scope)}` :
|
||||
`LLDP state is mixed across ${scopeNoun(scope)}`}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lldpLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Checking LLDP status on all routers…
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Checking LLDP status on all {scopeNoun(scope)}…
|
||||
</div>
|
||||
) : lldpStatuses.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">
|
||||
No online routers found. Routers must be online to check or change LLDP settings.
|
||||
No online {scopeNoun(scope)} found. Devices must be online to check or change LLDP settings.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-slate-700/50 border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Router</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Device</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Type</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">IP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">LLDP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Protocols</th>
|
||||
@@ -188,8 +302,9 @@ export default function RouterSettingsPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{lldpStatuses.map(r => (
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<tr key={`${r.kind}-${r.id}`} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{r.name}</td>
|
||||
<td className="px-4 py-2.5"><KindPill kind={r.kind} /></td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{r.ip_address}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{r.error ? (
|
||||
@@ -214,7 +329,7 @@ export default function RouterSettingsPage() {
|
||||
|
||||
{lldpApplyResult && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Check className="w-4 h-4" /> Applied to {lldpApplyResult.applied} of {lldpApplyResult.total} routers
|
||||
<Check className="w-4 h-4" /> Applied to {lldpApplyResult.applied} of {lldpApplyResult.total} {scopeNoun(scope)}
|
||||
</div>
|
||||
)}
|
||||
{lldpApplyError && (
|
||||
@@ -260,7 +375,7 @@ export default function RouterSettingsPage() {
|
||||
Simple Network Management Protocol (SNMP)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
||||
Configure SNMP on all managed routers. Settings are applied network-wide.
|
||||
Configure SNMP across your managed {scopeNoun(scope)}. Settings are applied network-wide.
|
||||
SNMPv3 provides authentication and optional encryption for secure monitoring.
|
||||
</p>
|
||||
</div>
|
||||
@@ -272,7 +387,7 @@ export default function RouterSettingsPage() {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Enable SNMP</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Allow SNMP polling and trap generation on all routers
|
||||
Allow SNMP polling and trap generation on all {scopeNoun(scope)}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -390,17 +505,18 @@ export default function RouterSettingsPage() {
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Per-router SNMP status table */}
|
||||
{/* Per-device SNMP status table */}
|
||||
{snmpLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Checking SNMP status on all routers…
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Checking SNMP status on all {scopeNoun(scope)}…
|
||||
</div>
|
||||
) : snmpStatuses.length > 0 && (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-slate-700/50 border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Router</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Device</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Type</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">IP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">SNMP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Version</th>
|
||||
@@ -409,8 +525,9 @@ export default function RouterSettingsPage() {
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{snmpStatuses.map(r => (
|
||||
<tr key={r.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<tr key={`${r.kind}-${r.id}`} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{r.name}</td>
|
||||
<td className="px-4 py-2.5"><KindPill kind={r.kind} /></td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{r.ip_address}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{r.error ? (
|
||||
@@ -446,7 +563,7 @@ export default function RouterSettingsPage() {
|
||||
|
||||
{snmpApplyResult && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Check className="w-4 h-4" /> Applied to {snmpApplyResult.applied} of {snmpApplyResult.total} routers
|
||||
<Check className="w-4 h-4" /> Applied to {snmpApplyResult.applied} of {snmpApplyResult.total} {scopeNoun(scope)}
|
||||
</div>
|
||||
)}
|
||||
{snmpApplyError && (
|
||||
@@ -468,7 +585,7 @@ export default function RouterSettingsPage() {
|
||||
>
|
||||
{setSnmpMutation.isPending
|
||||
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Applying…</>
|
||||
: <><Check className="w-3.5 h-3.5" /> Apply to All Routers</>}
|
||||
: <><Check className="w-3.5 h-3.5" /> Apply to {scope === 'all' ? 'All Devices' : scope === 'routers' ? 'All Routers' : 'All Switches'}</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -60,7 +60,7 @@ export default function NetworkServicesOverviewPage() {
|
||||
{ key: 'dns', label: 'DNS', icon: Globe, href: '/network-services/dns' },
|
||||
{ key: 'ntp', label: 'NTP', icon: Clock, href: '/network-services/ntp' },
|
||||
{ key: 'wireguard', label: 'WireGuard', icon: Shield, href: '/network-services/wireguard' },
|
||||
{ key: 'syslog', label: 'Syslog', icon: FileText, href: '/network-services/syslog' },
|
||||
{ key: 'syslog', label: 'Logging', icon: FileText, href: '/network-services/syslog' },
|
||||
] as const;
|
||||
|
||||
// KPI: for each service, how many devices have it active?
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
FileText, RefreshCw, AlertTriangle, Plus, Pencil, Trash2, X, Save,
|
||||
@@ -517,9 +518,12 @@ export default function NetworkServicesSyslogPage() {
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Syslog</h1>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Logging</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">
|
||||
Logging actions and routing rules across your fleet. Coverage shows how many devices share each entry.
|
||||
Syslog actions and routing rules across your fleet. Coverage shows how many devices share each entry.{' '}
|
||||
<Link to="/events" className="text-blue-600 dark:text-blue-400 hover:underline">
|
||||
View live events →
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
{onlineDevices.length > 0 && (
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Router, CheckCircle, XCircle, HelpCircle, ExternalLink, RefreshCw } from 'lucide-react';
|
||||
import { routersApi } from '../services/api';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface RouterDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
model?: string;
|
||||
status: string;
|
||||
last_seen?: string;
|
||||
ros_version?: string;
|
||||
firmware_version?: string;
|
||||
rack_name?: string;
|
||||
rack_slot?: string;
|
||||
ifaces_up: number;
|
||||
ifaces_down: number;
|
||||
ifaces_disabled: number;
|
||||
ifaces_total: number;
|
||||
}
|
||||
|
||||
function timeAgo(ts: string | undefined): string {
|
||||
if (!ts) return 'Never';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (diff < 60_000) return 'Just now';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
status === 'online' ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' :
|
||||
status === 'offline' ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' :
|
||||
'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400'
|
||||
)}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full',
|
||||
status === 'online' ? 'bg-green-500' : status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||||
)} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function RoutersPage() {
|
||||
const { data: routers = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['routers'],
|
||||
queryFn: () => routersApi.overview().then(r => r.data as unknown as RouterDevice[]),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const online = routers.filter(r => r.status === 'online').length;
|
||||
const offline = routers.filter(r => r.status === 'offline').length;
|
||||
const unknown = routers.filter(r => r.status !== 'online' && r.status !== 'offline').length;
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Total Routers', value: routers.length, icon: Router, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
||||
{ label: 'Online', value: online, icon: CheckCircle, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-900/20' },
|
||||
{ label: 'Offline', value: offline, icon: XCircle, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-900/20' },
|
||||
{ label: 'Unknown', value: unknown, icon: HelpCircle, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-900/20' },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center h-64 text-gray-400"><RefreshCw className="w-5 h-5 animate-spin mr-2" />Loading routers…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Routers</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Overview of all managed router devices
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
<Link to="/devices" className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<ExternalLink className="w-3.5 h-3.5" /> Manage Devices
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{kpis.map(({ label, value, icon: Icon, color, bg }) => (
|
||||
<div key={label} className="card p-5 flex items-center gap-4">
|
||||
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0', bg)}>
|
||||
<Icon className={clsx('w-5 h-5', color)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{value}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{routers.length === 0 && (
|
||||
<div className="card p-12 text-center">
|
||||
<Router className="w-12 h-12 text-gray-300 dark:text-slate-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500 dark:text-slate-400 font-medium">No routers found</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
Add devices with type <span className="font-mono">router</span> to see them here.
|
||||
</p>
|
||||
<Link to="/devices" className="btn-primary inline-flex items-center gap-2 mt-4 text-sm">
|
||||
<ExternalLink className="w-4 h-4" /> Manage Devices
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Router cards grid */}
|
||||
{routers.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{routers.map(router => {
|
||||
const ifacePct = router.ifaces_total > 0
|
||||
? Math.round((router.ifaces_up / router.ifaces_total) * 100)
|
||||
: 0;
|
||||
return (
|
||||
<div key={router.id} className="card p-5 flex flex-col gap-4">
|
||||
{/* Card header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">{router.name}</h3>
|
||||
<p className="text-xs font-mono text-gray-500 dark:text-slate-400 mt-0.5">{router.ip_address}</p>
|
||||
</div>
|
||||
<StatusBadge status={router.status} />
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
||||
{router.model && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">Model</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium truncate">{router.model}</span>
|
||||
</>
|
||||
)}
|
||||
{router.ros_version && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">RouterOS</span>
|
||||
<span className="text-gray-900 dark:text-white font-mono">{router.ros_version}</span>
|
||||
</>
|
||||
)}
|
||||
{router.rack_name && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">Rack</span>
|
||||
<span className="text-gray-900 dark:text-white">{router.rack_name}{router.rack_slot ? ` / ${router.rack_slot}` : ''}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-500 dark:text-slate-400">Last seen</span>
|
||||
<span className="text-gray-900 dark:text-white">{timeAgo(router.last_seen)}</span>
|
||||
</div>
|
||||
|
||||
{/* Interface utilisation */}
|
||||
{router.ifaces_total > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500 dark:text-slate-400">Interfaces</span>
|
||||
<span className="text-gray-700 dark:text-slate-300">
|
||||
<span className="text-green-600 dark:text-green-400 font-medium">{router.ifaces_up}</span>
|
||||
{' / '}
|
||||
{router.ifaces_total} up
|
||||
{router.ifaces_down > 0 && <span className="text-red-500 ml-1">· {router.ifaces_down} down</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 rounded-full transition-all"
|
||||
style={{ width: `${ifacePct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
<Link
|
||||
to={`/devices/${router.id}`}
|
||||
className="btn-secondary text-xs py-1.5 text-center"
|
||||
>
|
||||
View Device →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -464,41 +464,67 @@ export default function SettingsPage() {
|
||||
const setCfg = (key: string, val: unknown) =>
|
||||
setChForm((f) => ({ ...f, config: { ...f.config, [key]: val } }));
|
||||
|
||||
const tabs = [
|
||||
{ key: 'general' as const, label: 'General', icon: Settings },
|
||||
{ key: 'users' as const, label: 'Users & Roles', icon: Users },
|
||||
{ key: 'credentials' as const, label: 'Device Credentials', icon: KeyRound },
|
||||
{ key: 'security' as const, label: 'My Password', icon: Key },
|
||||
{ key: 'certificate' as const, label: 'Certificate', icon: Lock },
|
||||
{ key: 'alerting' as const, label: 'Alerting', icon: Bell },
|
||||
{ key: 'automation' as const, label: 'Automation', icon: Zap },
|
||||
...(isAdmin ? [
|
||||
{ key: 'templates' as const, label: 'Config Templates', icon: FileText },
|
||||
{ key: 'tags' as const, label: 'Tags', icon: ShieldCheck },
|
||||
{ key: 'maintenance' as const, label: 'Maintenance', icon: ShieldAlert },
|
||||
{ key: 'audit' as const, label: 'Audit Log', icon: ClipboardList },
|
||||
] : []),
|
||||
// Tabs are grouped by audience: platform administration, the signed-in
|
||||
// user's own account, and fleet-facing operational configuration.
|
||||
const tabGroups = [
|
||||
{
|
||||
label: 'Platform',
|
||||
tabs: [
|
||||
{ key: 'general' as const, label: 'General', icon: Settings },
|
||||
{ key: 'users' as const, label: 'Users & Roles', icon: Users },
|
||||
{ key: 'certificate' as const, label: 'Certificate', icon: Lock },
|
||||
...(isAdmin ? [{ key: 'audit' as const, label: 'Audit Log', icon: ClipboardList }] : []),
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'My Account',
|
||||
tabs: [
|
||||
{ key: 'security' as const, label: 'My Password', icon: Key },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Fleet',
|
||||
tabs: [
|
||||
{ key: 'credentials' as const, label: 'Device Credentials', icon: KeyRound },
|
||||
{ key: 'alerting' as const, label: 'Alerting', icon: Bell },
|
||||
{ key: 'automation' as const, label: 'Automation', icon: Zap },
|
||||
...(isAdmin ? [
|
||||
{ key: 'templates' as const, label: 'Config Templates', icon: FileText },
|
||||
{ key: 'tags' as const, label: 'Tags', icon: ShieldCheck },
|
||||
{ key: 'maintenance' as const, label: 'Maintenance', icon: ShieldAlert },
|
||||
] : []),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Settings</h1>
|
||||
|
||||
<div className="flex gap-1 border-b border-gray-200 dark:border-slate-700">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={clsx(
|
||||
'px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px flex items-center gap-2',
|
||||
activeTab === tab.key
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
<div className="flex flex-wrap gap-x-6 gap-y-2 border-b border-gray-200 dark:border-slate-700">
|
||||
{tabGroups.map((group, gi) => (
|
||||
<div key={group.label} className={clsx(gi > 0 && 'sm:border-l sm:border-gray-200 sm:dark:border-slate-700 sm:pl-6')}>
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-slate-500 select-none pt-1">
|
||||
{group.label}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
{group.tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.key}
|
||||
onClick={() => setActiveTab(tab.key)}
|
||||
className={clsx(
|
||||
'px-3 py-2 text-sm font-medium border-b-2 transition-colors -mb-px flex items-center gap-2 whitespace-nowrap',
|
||||
activeTab === tab.key
|
||||
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300'
|
||||
)}
|
||||
>
|
||||
<tab.icon className="w-4 h-4" />
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Layers, CheckCircle, XCircle, HelpCircle, ExternalLink, RefreshCw } from 'lucide-react';
|
||||
import { switchesApi } from '../services/api';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface SwitchDevice {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
model?: string;
|
||||
status: string;
|
||||
last_seen?: string;
|
||||
ros_version?: string;
|
||||
rack_name?: string;
|
||||
rack_slot?: string;
|
||||
ports_up: number;
|
||||
ports_down: number;
|
||||
ports_disabled: number;
|
||||
ports_total: number;
|
||||
}
|
||||
|
||||
function timeAgo(ts: string | undefined): string {
|
||||
if (!ts) return 'Never';
|
||||
const diff = Date.now() - new Date(ts).getTime();
|
||||
if (diff < 60_000) return 'Just now';
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
return (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
status === 'online' ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' :
|
||||
status === 'offline' ? 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400' :
|
||||
'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-400'
|
||||
)}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full',
|
||||
status === 'online' ? 'bg-green-500' : status === 'offline' ? 'bg-red-500' : 'bg-gray-400'
|
||||
)} />
|
||||
{status.charAt(0).toUpperCase() + status.slice(1)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SwitchesOverviewPage() {
|
||||
const { data: switches = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['switches'],
|
||||
queryFn: () => switchesApi.list().then(r => r.data as unknown as SwitchDevice[]),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
const online = switches.filter(s => s.status === 'online').length;
|
||||
const offline = switches.filter(s => s.status === 'offline').length;
|
||||
const unknown = switches.filter(s => s.status !== 'online' && s.status !== 'offline').length;
|
||||
|
||||
const kpis = [
|
||||
{ label: 'Total Switches', value: switches.length, icon: Layers, color: 'text-blue-600 dark:text-blue-400', bg: 'bg-blue-50 dark:bg-blue-900/20' },
|
||||
{ label: 'Online', value: online, icon: CheckCircle, color: 'text-green-600 dark:text-green-400', bg: 'bg-green-50 dark:bg-green-900/20' },
|
||||
{ label: 'Offline', value: offline, icon: XCircle, color: 'text-red-600 dark:text-red-400', bg: 'bg-red-50 dark:bg-red-900/20' },
|
||||
{ label: 'Unknown', value: unknown, icon: HelpCircle, color: 'text-yellow-600 dark:text-yellow-400', bg: 'bg-yellow-50 dark:bg-yellow-900/20' },
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return <div className="flex items-center justify-center h-64 text-gray-400"><RefreshCw className="w-5 h-5 animate-spin mr-2" />Loading switches…</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Switches</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Overview of all managed switch devices
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button onClick={() => refetch()} disabled={isFetching} className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} /> Refresh
|
||||
</button>
|
||||
<Link to="/devices" className="btn-secondary flex items-center gap-1.5 text-xs py-1.5">
|
||||
<ExternalLink className="w-3.5 h-3.5" /> Manage Devices
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* KPI cards */}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{kpis.map(({ label, value, icon: Icon, color, bg }) => (
|
||||
<div key={label} className="card p-5 flex items-center gap-4">
|
||||
<div className={clsx('w-10 h-10 rounded-lg flex items-center justify-center flex-shrink-0', bg)}>
|
||||
<Icon className={clsx('w-5 h-5', color)} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{value}</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">{label}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* No switches */}
|
||||
{switches.length === 0 && (
|
||||
<div className="card p-12 text-center">
|
||||
<Layers className="w-12 h-12 text-gray-300 dark:text-slate-600 mx-auto mb-3" />
|
||||
<p className="text-gray-500 dark:text-slate-400 font-medium">No switches found</p>
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 mt-1">
|
||||
Add devices with type <span className="font-mono">switch</span> to see them here.
|
||||
</p>
|
||||
<Link to="/devices" className="btn-primary inline-flex items-center gap-2 mt-4 text-sm">
|
||||
<ExternalLink className="w-4 h-4" /> Manage Devices
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Switch cards grid */}
|
||||
{switches.length > 0 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
|
||||
{switches.map(sw => {
|
||||
const portPct = sw.ports_total > 0 ? Math.round((sw.ports_up / sw.ports_total) * 100) : 0;
|
||||
return (
|
||||
<div key={sw.id} className="card p-5 flex flex-col gap-4">
|
||||
{/* Card header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white truncate">{sw.name}</h3>
|
||||
<p className="text-xs font-mono text-gray-500 dark:text-slate-400 mt-0.5">{sw.ip_address}</p>
|
||||
</div>
|
||||
<StatusBadge status={sw.status} />
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-xs">
|
||||
{sw.model && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">Model</span>
|
||||
<span className="text-gray-900 dark:text-white font-medium truncate">{sw.model}</span>
|
||||
</>
|
||||
)}
|
||||
{sw.ros_version && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">RouterOS</span>
|
||||
<span className="text-gray-900 dark:text-white font-mono">{sw.ros_version}</span>
|
||||
</>
|
||||
)}
|
||||
{sw.rack_name && (
|
||||
<>
|
||||
<span className="text-gray-500 dark:text-slate-400">Rack</span>
|
||||
<span className="text-gray-900 dark:text-white">{sw.rack_name}{sw.rack_slot ? ` / ${sw.rack_slot}` : ''}</span>
|
||||
</>
|
||||
)}
|
||||
<span className="text-gray-500 dark:text-slate-400">Last seen</span>
|
||||
<span className="text-gray-900 dark:text-white">{timeAgo(sw.last_seen)}</span>
|
||||
</div>
|
||||
|
||||
{/* Port utilisation */}
|
||||
{sw.ports_total > 0 && (
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs mb-1.5">
|
||||
<span className="text-gray-500 dark:text-slate-400">Ports</span>
|
||||
<span className="text-gray-700 dark:text-slate-300">
|
||||
<span className="text-green-600 dark:text-green-400 font-medium">{sw.ports_up}</span>
|
||||
{' / '}
|
||||
{sw.ports_total} up
|
||||
{sw.ports_down > 0 && <span className="text-red-500 ml-1">· {sw.ports_down} down</span>}
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-full h-1.5 bg-gray-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 rounded-full transition-all"
|
||||
style={{ width: `${portPct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Link */}
|
||||
<Link
|
||||
to={`/devices/${sw.id}`}
|
||||
className="btn-secondary text-xs py-1.5 text-center"
|
||||
>
|
||||
View Device →
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,526 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import {
|
||||
Radio, RefreshCw, CheckCircle, XCircle, AlertCircle, Check,
|
||||
HelpCircle, Shield, Eye, EyeOff,
|
||||
} from 'lucide-react';
|
||||
import { switchesApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
import clsx from 'clsx';
|
||||
|
||||
// ─── SNMP form state ──────────────────────────────────────────────────────────
|
||||
interface SnmpForm {
|
||||
enabled: boolean;
|
||||
community_name: string;
|
||||
version: 'v1' | 'v2c' | 'v3';
|
||||
contact: string;
|
||||
location: string;
|
||||
trap_target: string;
|
||||
auth_protocol: string;
|
||||
auth_password: string;
|
||||
priv_protocol: string;
|
||||
priv_password: string;
|
||||
}
|
||||
|
||||
const DEFAULT_SNMP: SnmpForm = {
|
||||
enabled: true,
|
||||
community_name: 'public',
|
||||
version: 'v2c',
|
||||
contact: '',
|
||||
location: '',
|
||||
trap_target: '',
|
||||
auth_protocol: 'MD5',
|
||||
auth_password: '',
|
||||
priv_protocol: 'none',
|
||||
priv_password: '',
|
||||
};
|
||||
|
||||
function PasswordInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||
const [show, setShow] = useState(false);
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
type={show ? 'text' : 'password'}
|
||||
className="input pr-9"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
autoComplete="new-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-slate-300"
|
||||
onClick={() => setShow(s => !s)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{show ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Page ─────────────────────────────────────────────────────────────────────
|
||||
export default function SwitchesSettingsPage() {
|
||||
const canWrite = useCanWrite();
|
||||
|
||||
// ── LLDP state ────────────────────────────────────────────────────────────
|
||||
const [lldpApplyResult, setLldpApplyResult] = useState<{ applied: number; total: number } | null>(null);
|
||||
const [lldpApplyError, setLldpApplyError] = useState('');
|
||||
|
||||
const { data: lldpStatuses = [], isLoading: lldpLoading, refetch: refetchLldp, isFetching: lldpFetching } =
|
||||
useQuery({ queryKey: ['switches-lldp'], queryFn: () => switchesApi.getLldpStatus().then(r => r.data) });
|
||||
|
||||
const setLldpMutation = useMutation({
|
||||
mutationFn: (enabled: boolean) => switchesApi.setLldp(enabled),
|
||||
onSuccess: (res) => { setLldpApplyResult({ applied: res.data.applied, total: res.data.total }); setLldpApplyError(''); refetchLldp(); },
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setLldpApplyError(msg || 'Failed to apply LLDP settings');
|
||||
},
|
||||
});
|
||||
|
||||
const allEnabled = lldpStatuses.length > 0 && lldpStatuses.every(s => s.enabled === true);
|
||||
const allDisabled = lldpStatuses.length > 0 && lldpStatuses.every(s => s.enabled === false);
|
||||
|
||||
// ── SNMP state ────────────────────────────────────────────────────────────
|
||||
const [snmpForm, setSnmpForm] = useState<SnmpForm>(DEFAULT_SNMP);
|
||||
const [snmpApplyResult, setSnmpApplyResult] = useState<{ applied: number; total: number } | null>(null);
|
||||
const [snmpApplyError, setSnmpApplyError] = useState('');
|
||||
const [snmpFormEdited, setSnmpFormEdited] = useState(false);
|
||||
|
||||
const sf = (patch: Partial<SnmpForm>) => { setSnmpForm(f => ({ ...f, ...patch })); setSnmpFormEdited(true); };
|
||||
|
||||
const { data: snmpStatuses = [], isLoading: snmpLoading, refetch: refetchSnmp, isFetching: snmpFetching } =
|
||||
useQuery({
|
||||
queryKey: ['switches-snmp'],
|
||||
staleTime: 60_000,
|
||||
queryFn: () => switchesApi.getSnmpStatus().then(r => {
|
||||
// Pre-populate form from first successful result (if form not yet manually edited)
|
||||
const first = r.data.find(s => s.enabled != null && !s.error);
|
||||
if (first && !snmpFormEdited) {
|
||||
setSnmpForm({
|
||||
enabled: first.enabled ?? true,
|
||||
community_name: first.community_name ?? 'public',
|
||||
version: (first.version as 'v1' | 'v2c' | 'v3') ?? 'v2c',
|
||||
contact: first.contact ?? '',
|
||||
location: first.location ?? '',
|
||||
trap_target: first.trap_target ?? '',
|
||||
auth_protocol: first.auth_protocol ?? 'MD5',
|
||||
auth_password: '',
|
||||
priv_protocol: first.priv_protocol ?? 'none',
|
||||
priv_password: '',
|
||||
});
|
||||
}
|
||||
return r.data;
|
||||
}),
|
||||
});
|
||||
|
||||
const setSnmpMutation = useMutation({
|
||||
mutationFn: () => switchesApi.setSnmp(snmpForm),
|
||||
onSuccess: (res) => {
|
||||
setSnmpApplyResult({ applied: res.data.applied, total: res.data.total });
|
||||
setSnmpApplyError('');
|
||||
refetchSnmp();
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||
setSnmpApplyError(msg || 'Failed to apply SNMP settings');
|
||||
},
|
||||
});
|
||||
|
||||
const isV3 = snmpForm.version === 'v3';
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Switch Settings</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Network-wide configuration applied to all managed switches
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── LLDP Card ────────────────────────────────────────────────────────── */}
|
||||
<div className="card p-6 space-y-5">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 bg-blue-50 dark:bg-blue-900/20 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Radio className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">
|
||||
Link Layer Discovery Protocol (LLDP)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
||||
LLDP lets your switches announce themselves to neighboring devices and collect
|
||||
neighbor information. Enabling LLDP improves the accuracy of the network topology map.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!lldpLoading && lldpStatuses.length > 0 && (
|
||||
<div className={clsx(
|
||||
'flex items-center gap-2 px-3 py-2 rounded-lg text-sm',
|
||||
allEnabled ? 'bg-green-50 dark:bg-green-900/20 text-green-700 dark:text-green-400' :
|
||||
allDisabled ? 'bg-red-50 dark:bg-red-900/20 text-red-700 dark:text-red-400' :
|
||||
'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-400'
|
||||
)}>
|
||||
{allEnabled ? <CheckCircle className="w-4 h-4" /> : allDisabled ? <XCircle className="w-4 h-4" /> : <HelpCircle className="w-4 h-4" />}
|
||||
{allEnabled ? 'LLDP is enabled on all online switches' :
|
||||
allDisabled ? 'LLDP is disabled on all online switches' :
|
||||
'LLDP state is mixed across switches'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lldpLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Checking LLDP status on all switches…
|
||||
</div>
|
||||
) : lldpStatuses.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500">
|
||||
No online switches found. Switches must be online to check or change LLDP settings.
|
||||
</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-slate-700/50 border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Switch</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">IP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">LLDP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Protocols</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{lldpStatuses.map(sw => (
|
||||
<tr key={sw.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{sw.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{sw.ip_address}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{sw.error ? (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> Error</span>
|
||||
) : sw.enabled === null ? (
|
||||
<span className="text-xs text-gray-400">Unknown</span>
|
||||
) : sw.enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400"><CheckCircle className="w-3.5 h-3.5" /> Enabled</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500 dark:text-red-400"><XCircle className="w-3.5 h-3.5" /> Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{sw.protocol && sw.protocol !== 'unknown' ? sw.protocol : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lldpApplyResult && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Check className="w-4 h-4" /> Applied to {lldpApplyResult.applied} of {lldpApplyResult.total} switches
|
||||
</div>
|
||||
)}
|
||||
{lldpApplyError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{lldpApplyError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button onClick={() => refetchLldp()} disabled={lldpFetching} className="btn-secondary flex items-center gap-1.5 text-sm">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', lldpFetching && 'animate-spin')} /> Refresh Status
|
||||
</button>
|
||||
<button
|
||||
disabled={setLldpMutation.isPending || lldpLoading || lldpStatuses.length === 0}
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
onClick={() => { setLldpApplyResult(null); setLldpMutation.mutate(true); }}
|
||||
>
|
||||
{setLldpMutation.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <CheckCircle className="w-3.5 h-3.5" />}
|
||||
Enable LLDP on All
|
||||
</button>
|
||||
<button
|
||||
disabled={setLldpMutation.isPending || lldpLoading || lldpStatuses.length === 0}
|
||||
className="btn-secondary flex items-center gap-1.5 text-sm text-red-600 dark:text-red-400 border-red-200 dark:border-red-800 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
onClick={() => { setLldpApplyResult(null); setLldpMutation.mutate(false); }}
|
||||
>
|
||||
{setLldpMutation.isPending ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <XCircle className="w-3.5 h-3.5" />}
|
||||
Disable LLDP on All
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── SNMP Card ─────────────────────────────────────────────────────────── */}
|
||||
<div className="card p-6 space-y-5">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="w-9 h-9 bg-purple-50 dark:bg-purple-900/20 rounded-lg flex items-center justify-center flex-shrink-0 mt-0.5">
|
||||
<Shield className="w-4 h-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h2 className="font-semibold text-gray-900 dark:text-white">
|
||||
Simple Network Management Protocol (SNMP)
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
||||
Configure SNMP on all managed switches. Settings are applied network-wide.
|
||||
SNMPv3 provides authentication and optional encryption for secure monitoring.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configuration form */}
|
||||
<fieldset disabled={!canWrite} className="space-y-4 disabled:opacity-60">
|
||||
{/* Enable toggle */}
|
||||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-slate-700/40 rounded-lg">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Enable SNMP</p>
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Allow SNMP polling and trap generation on all switches
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sf({ enabled: !snmpForm.enabled })}
|
||||
className={clsx(
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
snmpForm.enabled ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600'
|
||||
)}
|
||||
>
|
||||
<span className={clsx(
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform',
|
||||
snmpForm.enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
)} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Version */}
|
||||
<div>
|
||||
<label className="label">SNMP Version</label>
|
||||
<div className="flex rounded-lg border border-gray-300 dark:border-slate-600 overflow-hidden w-fit">
|
||||
{(['v1', 'v2c', 'v3'] as const).map(v => (
|
||||
<button
|
||||
key={v}
|
||||
type="button"
|
||||
onClick={() => sf({ version: v })}
|
||||
className={clsx(
|
||||
'px-5 py-2 text-sm font-medium transition-colors',
|
||||
snmpForm.version === v
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-white dark:bg-slate-800 text-gray-600 dark:text-slate-300 hover:bg-gray-50 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
{v.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 mt-1.5">
|
||||
{snmpForm.version === 'v1' && 'SNMPv1 — community string, no encryption. Legacy use only.'}
|
||||
{snmpForm.version === 'v2c' && 'SNMPv2c — community string, supports 64-bit counters. Recommended for read-only monitoring.'}
|
||||
{snmpForm.version === 'v3' && 'SNMPv3 — username-based with authentication and optional encryption. Most secure.'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Community / Username */}
|
||||
<div>
|
||||
<label className="label">{isV3 ? 'Username' : 'Community Name'}</label>
|
||||
<input
|
||||
className="input max-w-xs"
|
||||
value={snmpForm.community_name}
|
||||
onChange={e => sf({ community_name: e.target.value })}
|
||||
placeholder={isV3 ? 'snmpv3user' : 'public'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Global info */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Contact</label>
|
||||
<input
|
||||
className="input"
|
||||
value={snmpForm.contact}
|
||||
onChange={e => sf({ contact: e.target.value })}
|
||||
placeholder="admin@example.com"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Location</label>
|
||||
<input
|
||||
className="input"
|
||||
value={snmpForm.location}
|
||||
onChange={e => sf({ location: e.target.value })}
|
||||
placeholder="Server Room A"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="label">Trap Destination (optional)</label>
|
||||
<input
|
||||
className="input max-w-xs"
|
||||
value={snmpForm.trap_target}
|
||||
onChange={e => sf({ trap_target: e.target.value })}
|
||||
placeholder="192.168.1.100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SNMPv3 section */}
|
||||
{isV3 && (
|
||||
<div className="border border-purple-200 dark:border-purple-800/50 rounded-lg p-4 space-y-4 bg-purple-50/30 dark:bg-purple-900/10">
|
||||
<p className="text-xs font-semibold text-purple-700 dark:text-purple-400 uppercase tracking-wide">
|
||||
SNMPv3 Security
|
||||
</p>
|
||||
|
||||
{/* Auth */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Authentication Protocol</label>
|
||||
<select
|
||||
className="input"
|
||||
value={snmpForm.auth_protocol}
|
||||
onChange={e => sf({ auth_protocol: e.target.value })}
|
||||
>
|
||||
<option value="MD5">MD5</option>
|
||||
<option value="SHA1">SHA1</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label">Authentication Password</label>
|
||||
<PasswordInput
|
||||
value={snmpForm.auth_password}
|
||||
onChange={v => sf({ auth_password: v })}
|
||||
placeholder="Leave blank to keep current"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="label">Privacy (Encryption) Protocol</label>
|
||||
<select
|
||||
className="input"
|
||||
value={snmpForm.priv_protocol}
|
||||
onChange={e => sf({ priv_protocol: e.target.value })}
|
||||
>
|
||||
<option value="none">None (auth only)</option>
|
||||
<option value="DES">DES</option>
|
||||
<option value="AES">AES</option>
|
||||
</select>
|
||||
</div>
|
||||
{snmpForm.priv_protocol !== 'none' && (
|
||||
<div>
|
||||
<label className="label">Privacy Password</label>
|
||||
<PasswordInput
|
||||
value={snmpForm.priv_password}
|
||||
onChange={v => sf({ priv_password: v })}
|
||||
placeholder="Leave blank to keep current"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-500 dark:text-slate-400">
|
||||
Security level:{' '}
|
||||
<span className="font-medium text-purple-700 dark:text-purple-300">
|
||||
{snmpForm.priv_protocol !== 'none' ? 'authPriv (auth + encryption)' : 'authNoPriv (auth only)'}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</fieldset>
|
||||
|
||||
{/* Per-switch SNMP status table */}
|
||||
{snmpLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<RefreshCw className="w-4 h-4 animate-spin" /> Checking SNMP status on all switches…
|
||||
</div>
|
||||
) : snmpStatuses.length === 0 ? (
|
||||
<p className="text-sm text-gray-400 dark:text-slate-500 py-1">No managed switches found.</p>
|
||||
) : (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50 dark:bg-slate-700/50 border-b border-gray-200 dark:border-slate-700">
|
||||
<th className="table-header px-4 py-2.5 text-left">Switch</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">IP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">SNMP</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Version</th>
|
||||
<th className="table-header px-4 py-2.5 text-left">Community / User</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{snmpStatuses.map(sw => (
|
||||
<tr key={sw.id} className="hover:bg-gray-50 dark:hover:bg-slate-700/30">
|
||||
<td className="px-4 py-2.5 font-medium text-gray-900 dark:text-white">{sw.name}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">{sw.ip_address}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{sw.error ? (
|
||||
<span className="text-xs text-red-500 flex items-center gap-1"><AlertCircle className="w-3.5 h-3.5" /> Error</span>
|
||||
) : sw.enabled === null ? (
|
||||
<span className="text-xs text-gray-400">Unknown</span>
|
||||
) : sw.enabled ? (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400"><CheckCircle className="w-3.5 h-3.5" /> Enabled</span>
|
||||
) : (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500 dark:text-red-400"><XCircle className="w-3.5 h-3.5" /> Disabled</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-2.5">
|
||||
{sw.version ? (
|
||||
<span className={clsx(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-xs font-medium',
|
||||
sw.version === 'v3' ? 'bg-purple-100 dark:bg-purple-900/30 text-purple-700 dark:text-purple-300'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-slate-300'
|
||||
)}>
|
||||
{sw.version.toUpperCase()}
|
||||
</span>
|
||||
) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
|
||||
{sw.community_name ?? '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{snmpApplyResult && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
||||
<Check className="w-4 h-4" /> Applied to {snmpApplyResult.applied} of {snmpApplyResult.total} switches
|
||||
</div>
|
||||
)}
|
||||
{snmpApplyError && (
|
||||
<div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400">{snmpApplyError}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{canWrite && (
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
onClick={() => { setSnmpApplyResult(null); refetchSnmp(); }}
|
||||
disabled={snmpFetching}
|
||||
className="btn-secondary flex items-center gap-1.5 text-sm"
|
||||
>
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', snmpFetching && 'animate-spin')} /> Refresh Status
|
||||
</button>
|
||||
<button
|
||||
disabled={setSnmpMutation.isPending || snmpStatuses.length === 0}
|
||||
className="btn-primary flex items-center gap-1.5 text-sm"
|
||||
onClick={() => { setSnmpApplyResult(null); setSnmpApplyError(''); setSnmpMutation.mutate(); }}
|
||||
>
|
||||
{setSnmpMutation.isPending
|
||||
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Applying…</>
|
||||
: <><Check className="w-3.5 h-3.5" /> Apply to All Switches</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { RefreshCw, Wifi } from 'lucide-react';
|
||||
import { clientsApi } from '../services/api';
|
||||
import { useSocket } from '../hooks/useSocket';
|
||||
import type { Client } from '../types';
|
||||
import clsx from 'clsx';
|
||||
|
||||
function signalLabel(dbm: number | null): string {
|
||||
if (dbm === null) return '—';
|
||||
if (dbm >= -55) return 'Excellent';
|
||||
if (dbm >= -65) return 'Good';
|
||||
if (dbm >= -75) return 'Fair';
|
||||
return 'Poor';
|
||||
}
|
||||
|
||||
function signalColor(dbm: number | null): string {
|
||||
if (dbm === null) return 'text-gray-400 dark:text-slate-500';
|
||||
if (dbm >= -55) return 'text-green-600 dark:text-green-400';
|
||||
if (dbm >= -65) return 'text-lime-600 dark:text-lime-400';
|
||||
if (dbm >= -75) return 'text-yellow-600 dark:text-yellow-400';
|
||||
return 'text-red-500 dark:text-red-400';
|
||||
}
|
||||
|
||||
export default function WirelessClientsPage() {
|
||||
const qc = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['wireless-clients-page'],
|
||||
queryFn: () =>
|
||||
clientsApi.list({ active: true, client_type: 'wireless', limit: 200 }).then((r) => r.data),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
|
||||
useSocket({
|
||||
'clients:updated': () => qc.invalidateQueries({ queryKey: ['wireless-clients-page'] }),
|
||||
});
|
||||
|
||||
const clients: Client[] = data?.clients ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Wireless Clients</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||
Active wireless clients across all access points
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="p-1.5 rounded-lg text-gray-400 hover:text-gray-600 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors"
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className={clsx('w-4 h-4', isLoading && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="card overflow-hidden">
|
||||
{isLoading ? (
|
||||
<div className="p-8 text-center text-gray-400">
|
||||
<RefreshCw className="w-5 h-5 animate-spin inline mr-2" />Loading…
|
||||
</div>
|
||||
) : clients.length === 0 ? (
|
||||
<div className="p-8 text-center text-gray-400 dark:text-slate-500 text-sm">
|
||||
<Wifi className="w-6 h-6 mx-auto mb-2 opacity-40" />
|
||||
No active wireless clients
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/50">
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Name</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">IP Address</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">MAC Address</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">SSID</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Signal</th>
|
||||
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-slate-400">Access Point</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||
{clients.map((client) => {
|
||||
const c = client as Client & { device_name?: string; ssid?: string };
|
||||
const name = c.custom_name || c.hostname || c.mac_address;
|
||||
const dbm = c.signal_strength ?? null;
|
||||
return (
|
||||
<tr
|
||||
key={c.mac_address}
|
||||
className="hover:bg-gray-50 dark:hover:bg-slate-700/30 transition-colors"
|
||||
>
|
||||
<td className="px-4 py-3 font-medium">
|
||||
<button
|
||||
onClick={() => navigate(`/clients/${encodeURIComponent(c.mac_address)}`)}
|
||||
className="text-blue-600 dark:text-blue-400 hover:underline text-left"
|
||||
>
|
||||
{name}
|
||||
</button>
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-slate-400">
|
||||
{c.ip_address || <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-slate-400">
|
||||
{c.mac_address}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-400">
|
||||
{c.ssid || <span className="text-gray-400">—</span>}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
{dbm !== null ? (
|
||||
<span className={clsx('font-medium', signalColor(dbm))}>
|
||||
{dbm} dBm
|
||||
<span className="ml-1.5 font-normal text-xs opacity-75">
|
||||
({signalLabel(dbm)})
|
||||
</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-gray-400 dark:text-slate-500">—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-600 dark:text-slate-400">
|
||||
{c.device_name || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500 text-right">
|
||||
{clients.length} client{clients.length !== 1 ? 's' : ''} · refreshes every 30 s
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.16.3 Beta';
|
||||
export const APP_VERSION = 'v0.16.4 Beta';
|
||||
|
||||
Reference in New Issue
Block a user