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:
2GT-Rich
2026-07-03 08:07:08 -07:00
co-authored by Claude Fable 5
parent 662d7852fa
commit dd1b6b7d13
16 changed files with 538 additions and 1375 deletions
+8 -10
View File
@@ -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>
+166 -174
View File
@@ -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 */}
+108 -37
View File
@@ -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>
+14 -3
View File
@@ -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',
@@ -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 &amp; 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 && (
-197
View File
@@ -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>
);
}
+55 -29
View File
@@ -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>
-194
View File
@@ -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>
);
}
-526
View File
@@ -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>
);
}
-140
View File
@@ -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
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.16.3 Beta';
export const APP_VERSION = 'v0.16.4 Beta';