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
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface.
|
A self-hosted, full-stack network management platform for MikroTik devices. Monitor, configure, and manage your entire MikroTik infrastructure — routers, switches, and wireless access points — from a single web interface.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
@@ -178,8 +178,9 @@ Each service supports multi-device management with conflict detection:
|
|||||||
| **DNS** | Upstream servers, static records (A/AAAA/CNAME/MX/NS/PTR/TXT/SRV), cache flush, DoH |
|
| **DNS** | Upstream servers, static records (A/AAAA/CNAME/MX/NS/PTR/TXT/SRV), cache flush, DoH |
|
||||||
| **NTP** | Server (broadcast/manycast), client (unicast/multicast), sync status |
|
| **NTP** | Server (broadcast/manycast), client (unicast/multicast), sync status |
|
||||||
| **WireGuard** | Interface management, peer configuration, public key display, RX/TX stats |
|
| **WireGuard** | Interface management, peer configuration, public key display, RX/TX stats |
|
||||||
| **Syslog** | Logging actions (remote/memory/disk/echo) and routing rules; single-device or push-to-all with per-entry coverage; enable/disable rules |
|
| **Logging** | Syslog actions (remote/memory/disk/echo) and routing rules; single-device or push-to-all with per-entry coverage; enable/disable rules |
|
||||||
| **NetFlow** | One-toggle Traffic Flow (NetFlow v9/IPFIX) export per device, pointed at the built-in collector; live export status per device |
|
| **NetFlow** | One-toggle Traffic Flow (NetFlow v9/IPFIX) export per device, pointed at the built-in collector; live export status per device |
|
||||||
|
| **Discovery & SNMP** | Fleet-wide LLDP enable/disable and SNMP v1/v2c/v3 configuration with per-device status tables, scoped to all devices, routers only, or switches only |
|
||||||
|
|
||||||
### Traffic Analytics (NetFlow)
|
### Traffic Analytics (NetFlow)
|
||||||
- **Built-in NetFlow/IPFIX collector** — receives Traffic Flow exports from your routers on UDP 2055 (no external tools needed); decoder supports both NetFlow v9 and IPFIX
|
- **Built-in NetFlow/IPFIX collector** — receives Traffic Flow exports from your routers on UDP 2055 (no external tools needed); decoder supports both NetFlow v9 and IPFIX
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-backend",
|
"name": "mikrotik-manager-backend",
|
||||||
"version": "0.16.3-beta",
|
"version": "0.16.4-beta",
|
||||||
"description": "Mikrotik Network Management Platform - Backend API",
|
"description": "Mikrotik Network Management Platform - Backend API",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mikrotik-manager-frontend",
|
"name": "mikrotik-manager-frontend",
|
||||||
"version": "0.16.3-beta",
|
"version": "0.16.4-beta",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+8
-10
@@ -11,13 +11,8 @@ import TopologyPage from './pages/TopologyPage';
|
|||||||
import BackupsPage from './pages/BackupsPage';
|
import BackupsPage from './pages/BackupsPage';
|
||||||
import FirmwarePage from './pages/FirmwarePage';
|
import FirmwarePage from './pages/FirmwarePage';
|
||||||
import SettingsPage from './pages/SettingsPage';
|
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 WirelessPage from './pages/WirelessPage';
|
||||||
import WirelessSettingsPage from './pages/WirelessSettingsPage';
|
import WirelessSettingsPage from './pages/WirelessSettingsPage';
|
||||||
import WirelessClientsPage from './pages/WirelessClientsPage';
|
|
||||||
import GuestWifiPage from './pages/GuestWifiPage';
|
import GuestWifiPage from './pages/GuestWifiPage';
|
||||||
import ClientDetailPage from './pages/ClientDetailPage';
|
import ClientDetailPage from './pages/ClientDetailPage';
|
||||||
import NetworkServicesOverviewPage from './pages/NetworkServicesOverviewPage';
|
import NetworkServicesOverviewPage from './pages/NetworkServicesOverviewPage';
|
||||||
@@ -27,6 +22,7 @@ import NetworkServicesNTPPage from './pages/NetworkServicesNTPPage';
|
|||||||
import NetworkServicesWireGuardPage from './pages/NetworkServicesWireGuardPage';
|
import NetworkServicesWireGuardPage from './pages/NetworkServicesWireGuardPage';
|
||||||
import NetworkServicesSyslogPage from './pages/NetworkServicesSyslogPage';
|
import NetworkServicesSyslogPage from './pages/NetworkServicesSyslogPage';
|
||||||
import NetworkServicesNetflowPage from './pages/NetworkServicesNetflowPage';
|
import NetworkServicesNetflowPage from './pages/NetworkServicesNetflowPage';
|
||||||
|
import NetworkServicesDiscoveryPage from './pages/NetworkServicesDiscoveryPage';
|
||||||
import TrafficAnalyticsPage from './pages/TrafficAnalyticsPage';
|
import TrafficAnalyticsPage from './pages/TrafficAnalyticsPage';
|
||||||
import SecurityPage from './pages/SecurityPage';
|
import SecurityPage from './pages/SecurityPage';
|
||||||
|
|
||||||
@@ -60,13 +56,14 @@ export default function App() {
|
|||||||
<Route path="backups" element={<BackupsPage />} />
|
<Route path="backups" element={<BackupsPage />} />
|
||||||
<Route path="firmware" element={<FirmwarePage />} />
|
<Route path="firmware" element={<FirmwarePage />} />
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
<Route path="switches" element={<SwitchesOverviewPage />} />
|
{/* Legacy routes — consolidated in the v0.16.4 UI reorganization */}
|
||||||
<Route path="switches/settings" element={<SwitchesSettingsPage />} />
|
<Route path="switches" element={<Navigate to="/devices?type=SW" replace />} />
|
||||||
<Route path="routers" element={<RoutersPage />} />
|
<Route path="switches/settings" element={<Navigate to="/network-services/discovery" replace />} />
|
||||||
<Route path="routers/settings" element={<RouterSettingsPage />} />
|
<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" element={<WirelessPage />} />
|
||||||
<Route path="wireless/settings" element={<WirelessSettingsPage />} />
|
<Route path="wireless/settings" element={<WirelessSettingsPage />} />
|
||||||
<Route path="wireless/clients" element={<WirelessClientsPage />} />
|
|
||||||
<Route path="wireless/guest" element={<GuestWifiPage />} />
|
<Route path="wireless/guest" element={<GuestWifiPage />} />
|
||||||
<Route path="network-services" element={<NetworkServicesOverviewPage />} />
|
<Route path="network-services" element={<NetworkServicesOverviewPage />} />
|
||||||
<Route path="network-services/dhcp" element={<NetworkServicesDHCPPage />} />
|
<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/wireguard" element={<NetworkServicesWireGuardPage />} />
|
||||||
<Route path="network-services/syslog" element={<NetworkServicesSyslogPage />} />
|
<Route path="network-services/syslog" element={<NetworkServicesSyslogPage />} />
|
||||||
<Route path="network-services/netflow" element={<NetworkServicesNetflowPage />} />
|
<Route path="network-services/netflow" element={<NetworkServicesNetflowPage />} />
|
||||||
|
<Route path="network-services/discovery" element={<NetworkServicesDiscoveryPage />} />
|
||||||
<Route path="traffic" element={<TrafficAnalyticsPage />} />
|
<Route path="traffic" element={<TrafficAnalyticsPage />} />
|
||||||
<Route path="security" element={<SecurityPage />} />
|
<Route path="security" element={<SecurityPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -4,49 +4,41 @@ import { useQuery } from '@tanstack/react-query';
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, Router, Users, Bell, GitBranch, HardDrive,
|
LayoutDashboard, Router, Users, Bell, GitBranch, HardDrive,
|
||||||
Settings, Network, ChevronLeft, ChevronRight, Layers, ChevronDown, SlidersHorizontal, X, Wifi,
|
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';
|
} from 'lucide-react';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { APP_VERSION } from '../../version';
|
import { APP_VERSION } from '../../version';
|
||||||
import { devicesApi } from '../../services/api';
|
import { devicesApi } from '../../services/api';
|
||||||
|
|
||||||
const navItems = [
|
const monitorItems = [
|
||||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
{ 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: '/topology', icon: GitBranch, label: 'Topology' },
|
||||||
{ to: '/backups', icon: HardDrive, label: 'Backups' },
|
{ to: '/traffic', icon: Activity, label: 'Traffic' },
|
||||||
{ to: '/firmware', icon: ArrowUpCircle, label: 'Firmware' },
|
{ to: '/events', icon: Bell, label: 'Events' },
|
||||||
];
|
{ to: '/security', icon: Shield, label: 'Security' },
|
||||||
|
|
||||||
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' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const wirelessSubItems = [
|
const wirelessSubItems = [
|
||||||
{ to: '/wireless', icon: Wifi, label: 'Overview' },
|
{ 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/guest', icon: Ticket, label: 'Guest WiFi' },
|
||||||
{ to: '/wireless/settings', icon: SlidersHorizontal, label: 'Settings' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const networkServicesSubItems = [
|
const networkServicesSubItems = [
|
||||||
{ to: '/network-services', icon: Network, label: 'Overview' },
|
{ to: '/network-services', icon: Network, label: 'Overview' },
|
||||||
{ to: '/network-services/dhcp', icon: Server, label: 'DHCP' },
|
{ to: '/network-services/dhcp', icon: Server, label: 'DHCP' },
|
||||||
{ to: '/network-services/dns', icon: Globe, label: 'DNS' },
|
{ to: '/network-services/dns', icon: Globe, label: 'DNS' },
|
||||||
{ to: '/network-services/ntp', icon: Clock, label: 'NTP' },
|
{ to: '/network-services/ntp', icon: Clock, label: 'NTP' },
|
||||||
{ to: '/network-services/wireguard', icon: Shield, label: 'WireGuard' },
|
{ to: '/network-services/wireguard', icon: Shield, label: 'WireGuard' },
|
||||||
{ to: '/network-services/syslog', icon: FileText, label: 'Syslog' },
|
{ to: '/network-services/syslog', icon: FileText, label: 'Logging' },
|
||||||
{ to: '/network-services/netflow', icon: BarChart3, label: 'NetFlow' },
|
{ 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 {
|
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({
|
function GroupHeader({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
label,
|
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) {
|
export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -135,21 +174,20 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
|
|
||||||
const onlineCount = devices.filter(d => d.status === 'online').length;
|
const onlineCount = devices.filter(d => d.status === 'online').length;
|
||||||
|
|
||||||
const switchesActive = location.pathname.startsWith('/switches');
|
// Collapsible groups: until the user touches the chevron (pref === null) a
|
||||||
const [switchesOpen, setSwitchesOpen] = useState(switchesActive);
|
// group is open exactly when its section is active; after that the manual
|
||||||
const effectiveSwitchesOpen = switchesOpen || switchesActive;
|
// choice wins, so collapse always works — even inside the active section.
|
||||||
|
const devicesActive = location.pathname.startsWith('/devices');
|
||||||
const routersActive = location.pathname.startsWith('/routers');
|
const [devicesOpenPref, setDevicesOpenPref] = useState<boolean | null>(null);
|
||||||
const [routersOpen, setRoutersOpen] = useState(routersActive);
|
const devicesOpen = devicesOpenPref ?? devicesActive;
|
||||||
const effectiveRoutersOpen = routersOpen || routersActive;
|
|
||||||
|
|
||||||
const wirelessActive = location.pathname.startsWith('/wireless');
|
const wirelessActive = location.pathname.startsWith('/wireless');
|
||||||
const [wirelessOpen, setWirelessOpen] = useState(wirelessActive);
|
const [wirelessOpenPref, setWirelessOpenPref] = useState<boolean | null>(null);
|
||||||
const effectiveWirelessOpen = wirelessOpen || wirelessActive;
|
const wirelessOpen = wirelessOpenPref ?? wirelessActive;
|
||||||
|
|
||||||
const networkServicesActive = location.pathname.startsWith('/network-services');
|
const networkServicesActive = location.pathname.startsWith('/network-services');
|
||||||
const [networkServicesOpen, setNetworkServicesOpen] = useState(networkServicesActive);
|
const [networkServicesOpenPref, setNetworkServicesOpenPref] = useState<boolean | null>(null);
|
||||||
const effectiveNetworkServicesOpen = networkServicesOpen || networkServicesActive;
|
const networkServicesOpen = networkServicesOpenPref ?? networkServicesActive;
|
||||||
|
|
||||||
const isCollapsed = collapsed;
|
const isCollapsed = collapsed;
|
||||||
|
|
||||||
@@ -157,6 +195,16 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
onMobileClose();
|
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 (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -196,119 +244,73 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
|||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 px-[14px] py-3 space-y-[1px] overflow-y-auto">
|
<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} />
|
<NavItem key={to} to={to} icon={icon} label={label} isCollapsed={isCollapsed} onClick={handleNavClick} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Divider before device-type groups */}
|
{/* ── Network ── */}
|
||||||
{(hasSwitches || hasRouters || hasWireless) && !isCollapsed && (
|
<SectionLabel label="Network" isCollapsed={isCollapsed} />
|
||||||
<div className="my-[6px] mx-2" style={{ height: 1, background: 'var(--line)' }} />
|
|
||||||
|
{/* 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 */}
|
<NavItem to="/clients" icon={Users} label="Clients" isCollapsed={isCollapsed} onClick={handleNavClick} />
|
||||||
{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>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Routers group */}
|
{/* Wireless group (only when wireless APs are managed) */}
|
||||||
{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 */}
|
|
||||||
{hasWireless && (
|
{hasWireless && (
|
||||||
isCollapsed ? (
|
isCollapsed ? (
|
||||||
<NavLink
|
<CollapsedGroupLink to="/wireless" icon={Wifi} label="Wireless" groupActive={wirelessActive} onClick={handleNavClick} />
|
||||||
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>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<GroupHeader
|
<GroupHeader
|
||||||
icon={Wifi}
|
icon={Wifi}
|
||||||
label="Wireless"
|
label="Wireless"
|
||||||
isActive={wirelessActive}
|
isActive={wirelessActive}
|
||||||
isOpen={effectiveWirelessOpen}
|
isOpen={wirelessOpen}
|
||||||
onToggle={() => setWirelessOpen(o => !o)}
|
onToggle={() => setWirelessOpenPref(!wirelessOpen)}
|
||||||
/>
|
/>
|
||||||
{effectiveWirelessOpen && (
|
{wirelessOpen && (
|
||||||
<div className="ml-4 pl-3 space-y-[1px]" style={{ borderLeft: '1px solid var(--line)' }}>
|
<div className="ml-4 pl-3 space-y-[1px]" style={{ borderLeft: '1px solid var(--line)' }}>
|
||||||
{wirelessSubItems.map(({ to, icon, label }) => (
|
{wirelessSubItems.map(({ to, icon, label }) => (
|
||||||
<NavItem key={to} to={to} end icon={icon} label={label} isCollapsed={false} onClick={handleNavClick} />
|
<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 */}
|
{/* ── Services ── */}
|
||||||
<div className="mt-[6px]" style={{ height: 1, background: 'var(--line)', marginInline: '8px' }} />
|
<SectionLabel label="Services" isCollapsed={isCollapsed} />
|
||||||
<div className="mt-[6px]">
|
{isCollapsed ? (
|
||||||
{isCollapsed ? (
|
<CollapsedGroupLink to="/network-services" icon={Network} label="Network Services" groupActive={networkServicesActive} onClick={handleNavClick} />
|
||||||
<NavLink
|
) : (
|
||||||
to="/network-services"
|
<>
|
||||||
title="Network Services"
|
<GroupHeader
|
||||||
onClick={handleNavClick}
|
icon={Network}
|
||||||
className={({ isActive }) =>
|
label="Network Services"
|
||||||
clsx(
|
isActive={networkServicesActive}
|
||||||
'hidden md:flex items-center justify-center rounded-[6px] text-[13px] font-medium transition-colors duration-150 px-2 py-[7px] border-l-2',
|
isOpen={networkServicesOpen}
|
||||||
isActive || networkServicesActive
|
onToggle={() => setNetworkServicesOpenPref(!networkServicesOpen)}
|
||||||
? 'bg-surface-3 text-ink border-accent'
|
/>
|
||||||
: 'text-ink-2 hover:bg-surface-3 hover:text-ink border-transparent'
|
{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} />
|
||||||
<Network className="w-[17px] h-[17px] flex-shrink-0" />
|
))}
|
||||||
</NavLink>
|
</div>
|
||||||
) : (
|
)}
|
||||||
<>
|
</>
|
||||||
<GroupHeader
|
)}
|
||||||
icon={Network}
|
|
||||||
label="Network Services"
|
{/* ── Operations ── */}
|
||||||
isActive={networkServicesActive}
|
<SectionLabel label="Operations" isCollapsed={isCollapsed} />
|
||||||
isOpen={effectiveNetworkServicesOpen}
|
{operationsItems.map(({ to, icon, label }) => (
|
||||||
onToggle={() => setNetworkServicesOpen(o => !o)}
|
<NavItem key={to} to={to} icon={icon} label={label} isCollapsed={isCollapsed} onClick={handleNavClick} />
|
||||||
/>
|
))}
|
||||||
{effectiveNetworkServicesOpen && (
|
|
||||||
<div className="ml-4 pl-3 space-y-[1px]" style={{ borderLeft: '1px solid var(--line)' }}>
|
|
||||||
{networkServicesSubItems.map(({ to, icon, label }) => (
|
|
||||||
<NavItem key={to} to={to} end icon={icon} label={label} isCollapsed={false} onClick={handleNavClick} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer: poll status card + settings + collapse */}
|
{/* Footer: poll status card + settings + collapse */}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState, useRef, useEffect } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown,
|
Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown,
|
||||||
ChevronLeft, ChevronRight, RefreshCw,
|
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 {
|
function formatDataBytes(bytes: number): string {
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
@@ -174,6 +188,21 @@ export default function ClientsPage() {
|
|||||||
const canWrite = useCanWrite();
|
const canWrite = useCanWrite();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [showAll, setShowAll] = useState(false);
|
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 [page, setPage] = useState(0);
|
||||||
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
const [editingClient, setEditingClient] = useState<Client | null>(null);
|
||||||
const [sortCol, setSortCol] = useState<string>('last_seen');
|
const [sortCol, setSortCol] = useState<string>('last_seen');
|
||||||
@@ -204,10 +233,10 @@ export default function ClientsPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const { data, isLoading } = useQuery({
|
const { data, isLoading } = useQuery({
|
||||||
queryKey: ['clients', { search, showAll, page, pageSize, sortCol, sortDir }],
|
queryKey: ['clients', { search, showAll, typeFilter, page, pageSize, sortCol, sortDir }],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
clientsApi
|
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),
|
.then((r) => r.data),
|
||||||
refetchInterval: refreshInterval ?? false,
|
refetchInterval: refreshInterval ?? false,
|
||||||
// Keep the current rows on screen while a sort/page/search refetch is in
|
// 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); }}
|
onChange={(e) => { setSearch(e.target.value); setPage(0); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<button
|
||||||
onClick={() => { setShowAll((s) => !s); setPage(0); }}
|
onClick={() => { setShowAll((s) => !s); setPage(0); }}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
@@ -329,14 +379,10 @@ export default function ClientsPage() {
|
|||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm table-fixed">
|
<table className="w-full text-sm table-fixed">
|
||||||
<colgroup>
|
<colgroup>
|
||||||
<col style={{ width: '22%' }} />
|
{(isWireless
|
||||||
<col style={{ width: '12%' }} />
|
? ['20%', '11%', '11%', '11%', '9%', '6%', '12%', '9%', '11%']
|
||||||
<col style={{ width: '13%' }} />
|
: ['22%', '12%', '13%', '11%', '7%', '13%', '10%', '12%']
|
||||||
<col style={{ width: '11%' }} />
|
).map((w, i) => <col key={i} style={{ width: w }} />)}
|
||||||
<col style={{ width: '7%' }} />
|
|
||||||
<col style={{ width: '13%' }} />
|
|
||||||
<col style={{ width: '10%' }} />
|
|
||||||
<col style={{ width: '12%' }} />
|
|
||||||
{showAll && <col style={{ width: '6%' }} />}
|
{showAll && <col style={{ width: '6%' }} />}
|
||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
@@ -344,27 +390,34 @@ export default function ClientsPage() {
|
|||||||
{([
|
{([
|
||||||
{ col: 'hostname', label: 'Host / Vendor / MAC', align: 'left' },
|
{ col: 'hostname', label: 'Host / Vendor / MAC', align: 'left' },
|
||||||
{ col: 'ip_address', label: 'IP Address', 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: 'interface_name', label: 'Port', align: 'left' },
|
||||||
{ col: 'vlan_id', label: 'VLAN', align: 'left' },
|
{ col: 'vlan_id', label: 'VLAN', align: 'left' },
|
||||||
{ col: 'device_name', label: 'Device', align: 'left' },
|
{ col: 'device_name', label: 'Device', align: 'left' },
|
||||||
{ col: 'traffic_today_bytes', label: 'Data (today)', align: 'left' },
|
{ col: 'traffic_today_bytes', label: 'Data (today)', align: 'left' },
|
||||||
{ col: 'last_seen', label: 'Last Seen', 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
|
<th
|
||||||
key={col}
|
key={col}
|
||||||
className={clsx('table-header px-4 py-2.5 cursor-pointer select-none whitespace-nowrap', `text-${align}`)}
|
className={clsx('table-header px-4 py-2.5 select-none whitespace-nowrap', !noSort && 'cursor-pointer', `text-${align}`)}
|
||||||
onClick={() => toggleSort(col)}
|
onClick={noSort ? undefined : () => toggleSort(col)}
|
||||||
>
|
>
|
||||||
<span className="inline-flex items-center gap-1">
|
<span className="inline-flex items-center gap-1">
|
||||||
{label}
|
{label}
|
||||||
{sortCol === col ? (
|
{!noSort && (sortCol === col ? (
|
||||||
sortDir === 'asc'
|
sortDir === 'asc'
|
||||||
? <ChevronUp className="w-3 h-3 text-blue-500" />
|
? <ChevronUp className="w-3 h-3 text-blue-500" />
|
||||||
: <ChevronDown 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" />
|
<ChevronsUpDown className="w-3 h-3 text-gray-300 dark:text-slate-600" />
|
||||||
)}
|
))}
|
||||||
</span>
|
</span>
|
||||||
</th>
|
</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">
|
<td className="px-4 py-2.5 font-mono text-gray-600 dark:text-slate-400 truncate">
|
||||||
{client.ip_address || '—'}
|
{client.ip_address || '—'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2.5">
|
{isWireless ? (
|
||||||
<span
|
<>
|
||||||
className={clsx(
|
<td className="px-4 py-2.5 text-xs text-gray-600 dark:text-slate-400 truncate">
|
||||||
'inline-flex items-center gap-1 text-xs font-medium',
|
{(client as Client & { ssid?: string }).ssid || '—'}
|
||||||
client.client_type === 'wireless'
|
</td>
|
||||||
? 'text-purple-600 dark:text-purple-400'
|
<td className="px-4 py-2.5 text-xs whitespace-nowrap">
|
||||||
: 'text-blue-600 dark:text-blue-400'
|
{client.signal_strength != null ? (
|
||||||
)}
|
<span className={clsx('font-medium', signalColor(client.signal_strength))}>
|
||||||
>
|
{client.signal_strength} dBm
|
||||||
{client.client_type === 'wireless' ? (
|
<span className="ml-1 font-normal opacity-75">({signalLabel(client.signal_strength)})</span>
|
||||||
<Wifi className="w-3 h-3" />
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<Network className="w-3 h-3" />
|
<span className="text-gray-400 dark:text-slate-500">—</span>
|
||||||
)}
|
)}
|
||||||
{client.client_type}
|
</td>
|
||||||
{client.signal_strength != null && (
|
</>
|
||||||
<span className="text-gray-400 ml-1">({client.signal_strength} dBm)</span>
|
) : (
|
||||||
)}
|
<td className="px-4 py-2.5">
|
||||||
</span>
|
<span
|
||||||
</td>
|
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">
|
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400 truncate">
|
||||||
{client.interface_name || '—'}
|
{client.interface_name || '—'}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMemo, useState } from 'react';
|
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 { useQuery, useQueries, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Plus, RefreshCw, Router, Wifi, Trash2, ChevronRight, Search,
|
Plus, RefreshCw, Router, Wifi, Trash2, ChevronRight, Search,
|
||||||
@@ -125,7 +125,18 @@ export default function DevicesPage() {
|
|||||||
dir: 'desc',
|
dir: 'desc',
|
||||||
});
|
});
|
||||||
const [statusFilter, setStatusFilter] = useState<'all' | 'online' | 'offline' | 'updates'>('all');
|
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 [tagFilter, setTagFilter] = useState<number | null>(null);
|
||||||
|
|
||||||
const { data: devices = [], isLoading } = useQuery({
|
const { data: devices = [], isLoading } = useQuery({
|
||||||
@@ -333,7 +344,7 @@ export default function DevicesPage() {
|
|||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={t}
|
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"
|
className="mono text-[11px] px-[8px] py-[4px] rounded-[5px] transition-colors"
|
||||||
style={{
|
style={{
|
||||||
background: typeFilter === t ? 'var(--surface-3)' : 'transparent',
|
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 { useQuery, useMutation } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
Radio, RefreshCw, CheckCircle, XCircle, AlertCircle, Check,
|
Radio, RefreshCw, CheckCircle, XCircle, AlertCircle, Check,
|
||||||
HelpCircle, Shield, Eye, EyeOff,
|
HelpCircle, Shield, Eye, EyeOff, Router as RouterIcon, Layers,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { routersApi } from '../services/api';
|
import { routersApi, switchesApi } from '../services/api';
|
||||||
import { useCanWrite } from '../hooks/useCanWrite';
|
import { useCanWrite } from '../hooks/useCanWrite';
|
||||||
import clsx from 'clsx';
|
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 {
|
interface SnmpForm {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
community_name: string;
|
community_name: string;
|
||||||
@@ -34,6 +51,30 @@ const DEFAULT_SNMP: SnmpForm = {
|
|||||||
priv_password: '',
|
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 }) {
|
function PasswordInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
|
||||||
const [show, setShow] = useState(false);
|
const [show, setShow] = useState(false);
|
||||||
return (
|
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 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 ──────────────────────────────────────────────────────────────
|
// ── LLDP state ──────────────────────────────────────────────────────────────
|
||||||
const [lldpApplyResult, setLldpApplyResult] = useState<{ applied: number; total: number } | null>(null);
|
const [lldpApplyResult, setLldpApplyResult] = useState<{ applied: number; total: number } | null>(null);
|
||||||
const [lldpApplyError, setLldpApplyError] = useState('');
|
const [lldpApplyError, setLldpApplyError] = useState('');
|
||||||
|
|
||||||
const { data: lldpStatuses = [], isLoading: lldpLoading, refetch: refetchLldp, isFetching: lldpFetching } =
|
const routerLldp = useQuery({
|
||||||
useQuery({ queryKey: ['routers-lldp'], queryFn: () => routersApi.getLldpStatus().then(r => r.data) });
|
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({
|
const setLldpMutation = useMutation({
|
||||||
mutationFn: (enabled: boolean) => routersApi.setLldp(enabled),
|
mutationFn: async (enabled: boolean) => {
|
||||||
onSuccess: (res) => { setLldpApplyResult({ applied: res.data.applied, total: res.data.total }); setLldpApplyError(''); refetchLldp(); },
|
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) => {
|
onError: (err: unknown) => {
|
||||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||||
setLldpApplyError(msg || 'Failed to apply LLDP settings');
|
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 sf = (patch: Partial<SnmpForm>) => { setSnmpForm(f => ({ ...f, ...patch })); setSnmpFormEdited(true); };
|
||||||
|
|
||||||
const { data: snmpStatuses = [], isLoading: snmpLoading, refetch: refetchSnmp, isFetching: snmpFetching } =
|
const routerSnmp = useQuery({
|
||||||
useQuery({
|
queryKey: ['routers-snmp'],
|
||||||
queryKey: ['routers-snmp'],
|
queryFn: () => routersApi.getSnmpStatus().then(r => r.data),
|
||||||
queryFn: () => routersApi.getSnmpStatus().then(r => {
|
});
|
||||||
const first = r.data.find(s => s.enabled != null && !s.error);
|
const switchSnmp = useQuery({
|
||||||
if (first && !snmpFormEdited) {
|
queryKey: ['switches-snmp'],
|
||||||
setSnmpForm({
|
queryFn: () => switchesApi.getSnmpStatus().then(r => r.data),
|
||||||
enabled: first.enabled ?? true,
|
});
|
||||||
community_name: first.community_name ?? 'public',
|
|
||||||
version: (first.version as 'v1' | 'v2c' | 'v3') ?? 'v2c',
|
const snmpLoading = routerSnmp.isLoading || switchSnmp.isLoading;
|
||||||
contact: first.contact ?? '',
|
const snmpFetching = routerSnmp.isFetching || switchSnmp.isFetching;
|
||||||
location: first.location ?? '',
|
const refetchSnmp = () => { routerSnmp.refetch(); switchSnmp.refetch(); };
|
||||||
trap_target: first.trap_target ?? '',
|
|
||||||
auth_protocol: first.auth_protocol ?? 'MD5',
|
const snmpAll: SnmpRow[] = [
|
||||||
auth_password: '',
|
...(routerSnmp.data ?? []).map(r => ({ ...r, kind: 'router' as const })),
|
||||||
priv_protocol: first.priv_protocol ?? 'none',
|
...(switchSnmp.data ?? []).map(r => ({ ...r, kind: 'switch' as const })),
|
||||||
priv_password: '',
|
];
|
||||||
});
|
const snmpStatuses = inScope(snmpAll);
|
||||||
}
|
|
||||||
return r.data;
|
// 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({
|
const setSnmpMutation = useMutation({
|
||||||
mutationFn: () => routersApi.setSnmp(snmpForm),
|
mutationFn: async () => {
|
||||||
onSuccess: (res) => {
|
const calls = [];
|
||||||
setSnmpApplyResult({ applied: res.data.applied, total: res.data.total });
|
if (scope !== 'switches') calls.push(routersApi.setSnmp(snmpForm));
|
||||||
setSnmpApplyError('');
|
if (scope !== 'routers') calls.push(switchesApi.setSnmp(snmpForm));
|
||||||
refetchSnmp();
|
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) => {
|
onError: (err: unknown) => {
|
||||||
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
const msg = (err as { response?: { data?: { error?: string } } })?.response?.data?.error;
|
||||||
setSnmpApplyError(msg || 'Failed to apply SNMP settings');
|
setSnmpApplyError(msg || 'Failed to apply SNMP settings');
|
||||||
@@ -129,11 +222,31 @@ export default function RouterSettingsPage() {
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-3xl">
|
<div className="space-y-6 max-w-3xl">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div>
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Router Settings</h1>
|
<div>
|
||||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Discovery & SNMP</h1>
|
||||||
Network-wide configuration applied to all managed routers
|
<p className="text-sm text-gray-500 dark:text-slate-400 mt-0.5">
|
||||||
</p>
|
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>
|
</div>
|
||||||
|
|
||||||
{/* ── LLDP Card ──────────────────────────────────────────────────────────── */}
|
{/* ── LLDP Card ──────────────────────────────────────────────────────────── */}
|
||||||
@@ -147,7 +260,7 @@ export default function RouterSettingsPage() {
|
|||||||
Link Layer Discovery Protocol (LLDP)
|
Link Layer Discovery Protocol (LLDP)
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
<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.
|
neighbor information. Enabling LLDP improves the accuracy of the network topology map.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -161,26 +274,27 @@ export default function RouterSettingsPage() {
|
|||||||
'bg-yellow-50 dark:bg-yellow-900/20 text-yellow-700 dark:text-yellow-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 ? <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' :
|
{allEnabled ? `LLDP is enabled on all online ${scopeNoun(scope)}` :
|
||||||
allDisabled ? 'LLDP is disabled on all online routers' :
|
allDisabled ? `LLDP is disabled on all online ${scopeNoun(scope)}` :
|
||||||
'LLDP state is mixed across routers'}
|
`LLDP state is mixed across ${scopeNoun(scope)}`}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{lldpLoading ? (
|
{lldpLoading ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
<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>
|
</div>
|
||||||
) : lldpStatuses.length === 0 ? (
|
) : lldpStatuses.length === 0 ? (
|
||||||
<p className="text-sm text-gray-400 dark:text-slate-500">
|
<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>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-50 dark:bg-slate-700/50 border-b border-gray-200 dark:border-slate-700">
|
<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">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">LLDP</th>
|
||||||
<th className="table-header px-4 py-2.5 text-left">Protocols</th>
|
<th className="table-header px-4 py-2.5 text-left">Protocols</th>
|
||||||
@@ -188,8 +302,9 @@ export default function RouterSettingsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||||
{lldpStatuses.map(r => (
|
{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 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 font-mono text-xs text-gray-500 dark:text-slate-400">{r.ip_address}</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
{r.error ? (
|
{r.error ? (
|
||||||
@@ -214,7 +329,7 @@ export default function RouterSettingsPage() {
|
|||||||
|
|
||||||
{lldpApplyResult && (
|
{lldpApplyResult && (
|
||||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{lldpApplyError && (
|
{lldpApplyError && (
|
||||||
@@ -260,7 +375,7 @@ export default function RouterSettingsPage() {
|
|||||||
Simple Network Management Protocol (SNMP)
|
Simple Network Management Protocol (SNMP)
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-gray-500 dark:text-slate-400 mt-1">
|
<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.
|
SNMPv3 provides authentication and optional encryption for secure monitoring.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -272,7 +387,7 @@ export default function RouterSettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<p className="text-sm font-medium text-gray-900 dark:text-white">Enable SNMP</p>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -390,17 +505,18 @@ export default function RouterSettingsPage() {
|
|||||||
)}
|
)}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
{/* Per-router SNMP status table */}
|
{/* Per-device SNMP status table */}
|
||||||
{snmpLoading ? (
|
{snmpLoading ? (
|
||||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
<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>
|
</div>
|
||||||
) : snmpStatuses.length > 0 && (
|
) : snmpStatuses.length > 0 && (
|
||||||
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
<div className="rounded-lg border border-gray-200 dark:border-slate-700 overflow-hidden">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-gray-50 dark:bg-slate-700/50 border-b border-gray-200 dark:border-slate-700">
|
<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">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">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">Version</th>
|
||||||
@@ -409,8 +525,9 @@ export default function RouterSettingsPage() {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
<tbody className="divide-y divide-gray-100 dark:divide-slate-700 table-zebra">
|
||||||
{snmpStatuses.map(r => (
|
{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 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 font-mono text-xs text-gray-500 dark:text-slate-400">{r.ip_address}</td>
|
||||||
<td className="px-4 py-2.5">
|
<td className="px-4 py-2.5">
|
||||||
{r.error ? (
|
{r.error ? (
|
||||||
@@ -446,7 +563,7 @@ export default function RouterSettingsPage() {
|
|||||||
|
|
||||||
{snmpApplyResult && (
|
{snmpApplyResult && (
|
||||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-400">
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
{snmpApplyError && (
|
{snmpApplyError && (
|
||||||
@@ -468,7 +585,7 @@ export default function RouterSettingsPage() {
|
|||||||
>
|
>
|
||||||
{setSnmpMutation.isPending
|
{setSnmpMutation.isPending
|
||||||
? <><RefreshCw className="w-3.5 h-3.5 animate-spin" /> Applying…</>
|
? <><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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -60,7 +60,7 @@ export default function NetworkServicesOverviewPage() {
|
|||||||
{ key: 'dns', label: 'DNS', icon: Globe, href: '/network-services/dns' },
|
{ key: 'dns', label: 'DNS', icon: Globe, href: '/network-services/dns' },
|
||||||
{ key: 'ntp', label: 'NTP', icon: Clock, href: '/network-services/ntp' },
|
{ key: 'ntp', label: 'NTP', icon: Clock, href: '/network-services/ntp' },
|
||||||
{ key: 'wireguard', label: 'WireGuard', icon: Shield, href: '/network-services/wireguard' },
|
{ 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;
|
] as const;
|
||||||
|
|
||||||
// KPI: for each service, how many devices have it active?
|
// KPI: for each service, how many devices have it active?
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
FileText, RefreshCw, AlertTriangle, Plus, Pencil, Trash2, X, Save,
|
FileText, RefreshCw, AlertTriangle, Plus, Pencil, Trash2, X, Save,
|
||||||
@@ -517,9 +518,12 @@ export default function NetworkServicesSyslogPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{onlineDevices.length > 0 && (
|
{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) =>
|
const setCfg = (key: string, val: unknown) =>
|
||||||
setChForm((f) => ({ ...f, config: { ...f.config, [key]: val } }));
|
setChForm((f) => ({ ...f, config: { ...f.config, [key]: val } }));
|
||||||
|
|
||||||
const tabs = [
|
// Tabs are grouped by audience: platform administration, the signed-in
|
||||||
{ key: 'general' as const, label: 'General', icon: Settings },
|
// user's own account, and fleet-facing operational configuration.
|
||||||
{ key: 'users' as const, label: 'Users & Roles', icon: Users },
|
const tabGroups = [
|
||||||
{ key: 'credentials' as const, label: 'Device Credentials', icon: KeyRound },
|
{
|
||||||
{ key: 'security' as const, label: 'My Password', icon: Key },
|
label: 'Platform',
|
||||||
{ key: 'certificate' as const, label: 'Certificate', icon: Lock },
|
tabs: [
|
||||||
{ key: 'alerting' as const, label: 'Alerting', icon: Bell },
|
{ key: 'general' as const, label: 'General', icon: Settings },
|
||||||
{ key: 'automation' as const, label: 'Automation', icon: Zap },
|
{ key: 'users' as const, label: 'Users & Roles', icon: Users },
|
||||||
...(isAdmin ? [
|
{ key: 'certificate' as const, label: 'Certificate', icon: Lock },
|
||||||
{ key: 'templates' as const, label: 'Config Templates', icon: FileText },
|
...(isAdmin ? [{ key: 'audit' as const, label: 'Audit Log', icon: ClipboardList }] : []),
|
||||||
{ 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 },
|
{
|
||||||
] : []),
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Settings</h1>
|
<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">
|
<div className="flex flex-wrap gap-x-6 gap-y-2 border-b border-gray-200 dark:border-slate-700">
|
||||||
{tabs.map((tab) => (
|
{tabGroups.map((group, gi) => (
|
||||||
<button
|
<div key={group.label} className={clsx(gi > 0 && 'sm:border-l sm:border-gray-200 sm:dark:border-slate-700 sm:pl-6')}>
|
||||||
key={tab.key}
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-gray-400 dark:text-slate-500 select-none pt-1">
|
||||||
onClick={() => setActiveTab(tab.key)}
|
{group.label}
|
||||||
className={clsx(
|
</div>
|
||||||
'px-4 py-2.5 text-sm font-medium border-b-2 transition-colors -mb-px flex items-center gap-2',
|
<div className="flex gap-1">
|
||||||
activeTab === tab.key
|
{group.tabs.map((tab) => (
|
||||||
? 'border-blue-600 text-blue-600 dark:text-blue-400'
|
<button
|
||||||
: 'border-transparent text-gray-500 dark:text-slate-400 hover:text-gray-700 dark:hover:text-slate-300'
|
key={tab.key}
|
||||||
)}
|
onClick={() => setActiveTab(tab.key)}
|
||||||
>
|
className={clsx(
|
||||||
<tab.icon className="w-4 h-4" />
|
'px-3 py-2 text-sm font-medium border-b-2 transition-colors -mb-px flex items-center gap-2 whitespace-nowrap',
|
||||||
{tab.label}
|
activeTab === tab.key
|
||||||
</button>
|
? '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>
|
</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