From d79eb1311948788097ee7a53f0b98fa62fa8a92b Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Thu, 18 Jun 2026 13:37:11 -0700 Subject: [PATCH] feat(ui): theme-aware logo, dashboard bar graph, server-side client sorting (v0.14.1-beta) UI/UX refinements on top of v0.14.0-beta, all validated on the test server: - Logo: new theme-aware compass iconmark (light/dark SVGs swapped via the dark class), sized to 45px; favicon updated to match. Wordmark casing corrected to "MikroTik Manager" (font size unchanged). - Navigation icons enlarged 15 -> 17px. - Dashboard: "Connected clients" line chart converted to a bar chart with a category axis + light downsampling so bars stay readably thick across ranges. - Clients: sorting is now server-side across the whole dataset (was sorting only the current page, so Type/others appeared to do nothing on a uniform page); table uses a fixed column layout + keepPreviousData so columns no longer jump or blank out when sorting/paging. Backend clients route gains whitelisted sort/dir params (safe numeric IP sort, deterministic tiebreaker). - Connections tab: surfaces the live /ip/firewall/connection/tracking state (on/auto/off pill) and clearer empty-state copy explaining auto-mode. Lint/typecheck/build/jest green in both workspaces; npm audit --audit-level=high clean. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + README.md | 2 +- backend/package-lock.json | 4 +- backend/package.json | 2 +- backend/src/routes/clients.ts | 24 ++- backend/src/routes/devices.ts | 4 +- .../src/services/mikrotik/DeviceCollector.ts | 7 + frontend/package-lock.json | 4 +- frontend/package.json | 2 +- frontend/public/favicon.svg | 164 +++++++++++++++++- frontend/public/logo-dark.svg | 56 ++++++ frontend/public/logo-light.svg | 49 ++++++ .../device-detail/ConnectionsTab.tsx | 22 ++- frontend/src/components/layout/Sidebar.tsx | 27 ++- frontend/src/pages/ClientsPage.tsx | 72 ++++---- frontend/src/pages/DashboardPage.tsx | 47 +++-- frontend/src/services/api.ts | 4 +- frontend/src/version.ts | 2 +- 18 files changed, 402 insertions(+), 93 deletions(-) create mode 100644 frontend/public/logo-dark.svg create mode 100644 frontend/public/logo-light.svg diff --git a/.gitignore b/.gitignore index 2b8a479..0276c0b 100644 --- a/.gitignore +++ b/.gitignore @@ -140,3 +140,6 @@ vite.config.ts.timestamp-* # Claude Code session files .claude/ + +# macOS +.DS_Store diff --git a/README.md b/README.md index 958064c..4a70cec 100644 --- a/README.md +++ b/README.md @@ -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. -![Version](https://img.shields.io/badge/version-0.14.0_Beta-blue) +![Version](https://img.shields.io/badge/version-0.14.1_Beta-blue) ![License](https://img.shields.io/badge/license-AGPLv3-blue) ![Docker](https://img.shields.io/badge/docker-compose-2496ED?logo=docker&logoColor=white) ![TypeScript](https://img.shields.io/badge/TypeScript-5.3-3178C6?logo=typescript&logoColor=white) diff --git a/backend/package-lock.json b/backend/package-lock.json index eefe9f1..770f330 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mikrotik-manager-backend", - "version": "0.14.0-beta", + "version": "0.14.1-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mikrotik-manager-backend", - "version": "0.14.0-beta", + "version": "0.14.1-beta", "dependencies": { "@influxdata/influxdb-client": "^1.33.2", "@influxdata/influxdb-client-apis": "^1.35.0", diff --git a/backend/package.json b/backend/package.json index 3c2c9ea..e78970e 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.14.0-beta", + "version": "0.14.1-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/routes/clients.ts b/backend/src/routes/clients.ts index 4911135..a473d3f 100644 --- a/backend/src/routes/clients.ts +++ b/backend/src/routes/clients.ts @@ -13,7 +13,27 @@ export function setPollerService(p: PollerService): void { pollerService = p; } // GET /api/clients router.get('/', async (req: Request, res: Response) => { - const { deviceId, active, search, client_type, limit = '100', offset = '0' } = req.query; + const { deviceId, active, search, client_type, limit = '100', offset = '0', sort, dir } = req.query; + + // Whitelist sortable columns → safe SQL expressions on the outer (deduped) + // result. Sorting must run server-side across the whole dataset, not just the + // current page, or column sorts appear to do nothing when a page is uniform. + const SORT_EXPR: Record = { + hostname: `LOWER(COALESCE(NULLIF(deduped.custom_name, ''), deduped.hostname, ''))`, + vendor: `LOWER(COALESCE(deduped.vendor, ''))`, + // Regex-guard the inet cast so non-IP/empty values can't error; sorts numerically. + ip_address: `CASE WHEN deduped.ip_address ~ '^[0-9]+\\.[0-9]+\\.[0-9]+\\.[0-9]+$' THEN deduped.ip_address::inet END`, + client_type: `deduped.client_type`, + interface_name: `LOWER(COALESCE(deduped.interface_name, ''))`, + vlan_id: `deduped.vlan_id`, + device_name: `LOWER(COALESCE(deduped.device_name, ''))`, + traffic_today_bytes: `traffic_today_bytes`, + last_seen: `deduped.last_seen`, + }; + const sortExpr = SORT_EXPR[String(sort)] ?? SORT_EXPR.last_seen; + const sortDir = String(dir).toLowerCase() === 'asc' ? 'ASC' : 'DESC'; + // Stable, predictable: chosen column, then mac as a deterministic tiebreaker. + const orderBy = `ORDER BY ${sortExpr} ${sortDir} NULLS LAST, deduped.mac_address ASC`; // Build WHERE clause shared by both queries const filters: string[] = []; @@ -58,7 +78,7 @@ router.get('/', async (req: Request, res: Response) => { ) deduped LEFT JOIN client_traffic_daily ctd ON ctd.mac_address = LOWER(deduped.mac_address) AND ctd.day = CURRENT_DATE - ORDER BY last_seen DESC NULLS LAST + ${orderBy} LIMIT $${idx++} OFFSET $${idx++} `; const clients = await query(sql, [...filterParams, parseInt(String(limit), 10), parseInt(String(offset), 10)]); diff --git a/backend/src/routes/devices.ts b/backend/src/routes/devices.ts index 9aa84e2..1302b64 100644 --- a/backend/src/routes/devices.ts +++ b/backend/src/routes/devices.ts @@ -897,9 +897,9 @@ router.delete('/:id/address-lists/:entryId', requireWrite, async (req, res) => { router.get('/:id/connections', async (req, res) => { const limit = Math.min(parseInt(String(req.query.limit || '500'), 10) || 500, 2000); await withCollector(req.params.id, res, async (c) => { - const rows = await c.getConnections(); + const [rows, tracking] = await Promise.all([c.getConnections(), c.getConnectionTracking()]); // Connection tables can be huge; cap and surface the total so the UI can note truncation. - return { total: rows.length, connections: rows.slice(0, limit) }; + return { total: rows.length, connections: rows.slice(0, limit), tracking }; }); }); diff --git a/backend/src/services/mikrotik/DeviceCollector.ts b/backend/src/services/mikrotik/DeviceCollector.ts index 29b8d9c..6e3a530 100644 --- a/backend/src/services/mikrotik/DeviceCollector.ts +++ b/backend/src/services/mikrotik/DeviceCollector.ts @@ -1824,6 +1824,13 @@ export class DeviceCollector { return this.client.execute('/ip/firewall/connection/print', { detail: '' }).catch(() => [] as Record[]); } + // /ip/firewall/connection/tracking holds the global conntrack state. `enabled` + // is yes / no / auto (auto = on only once firewall/NAT/mangle rules exist). + async getConnectionTracking(): Promise> { + const rows = await this.client.execute('/ip/firewall/connection/tracking/print', {}).catch(() => [] as Record[]); + return rows[0] || {}; + } + // ─── Simple Queues (per-client / per-subnet bandwidth control) ────────────── async getSimpleQueues(): Promise[]> { diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 100d08b..8fd3ebf 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1,12 +1,12 @@ { "name": "mikrotik-manager-frontend", - "version": "0.14.0-beta", + "version": "0.14.1-beta", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "mikrotik-manager-frontend", - "version": "0.14.0-beta", + "version": "0.14.1-beta", "dependencies": { "@tanstack/react-query": "^5.17.1", "@types/leaflet": "^1.9.21", diff --git a/frontend/package.json b/frontend/package.json index fae039d..3303eb3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.14.0-beta", + "version": "0.14.1-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg index 12a8c1e..390eac4 100644 --- a/frontend/public/favicon.svg +++ b/frontend/public/favicon.svg @@ -1,7 +1,159 @@ - - - - - - + + + + MikroTik Manager Favicon + A simplified compass favicon with central M mark and four connected network nodes. + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/logo-dark.svg b/frontend/public/logo-dark.svg new file mode 100644 index 0000000..38bd7c6 --- /dev/null +++ b/frontend/public/logo-dark.svg @@ -0,0 +1,56 @@ + + + MikroTik Manager Config Compass Icon - Dark Mode + A dark-mode SVG app icon showing a network compass with a central manager node and four directional service nodes. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/public/logo-light.svg b/frontend/public/logo-light.svg new file mode 100644 index 0000000..94cc6b9 --- /dev/null +++ b/frontend/public/logo-light.svg @@ -0,0 +1,49 @@ + + + MikroTik Manager Config Compass Icon - Light Mode + A light-mode SVG app icon showing a network compass with a central manager node and four directional service nodes. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/components/device-detail/ConnectionsTab.tsx b/frontend/src/components/device-detail/ConnectionsTab.tsx index b52d7fc..ec1ee46 100644 --- a/frontend/src/components/device-detail/ConnectionsTab.tsx +++ b/frontend/src/components/device-detail/ConnectionsTab.tsx @@ -31,6 +31,20 @@ export default function ConnectionsTab({ deviceId }: { deviceId: number }) { const q = search.trim().toLowerCase(); const rows = q ? all.filter(c => JSON.stringify(c).toLowerCase().includes(q)) : all; + // Conntrack state: yes / no / auto (auto = active only once firewall/NAT rules exist). + const tracking = (data?.tracking?.enabled ?? '').toLowerCase(); + const trackLabel = tracking === 'yes' ? 'on' : tracking === 'auto' ? 'auto' : tracking === 'no' ? 'off' : ''; + const trackStyle = tracking === 'yes' + ? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400' + : tracking === 'no' + ? 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400' + : 'bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400'; + const emptyMessage = tracking === 'no' + ? 'Connection tracking is disabled on this device, so no connections are recorded. Enable it (or add a firewall/NAT rule) to populate this table.' + : tracking === 'auto' + ? 'Connection tracking is in auto mode — RouterOS only tracks connections once the device has firewall/NAT/mangle rules. This device has none yet, so the table is empty. It also won’t show hardware-switched (L2) traffic, only what the CPU routes/firewalls.' + : 'No active connections. Only CPU-processed (routed/firewalled) traffic is tracked — hardware-switched L2 traffic bypasses connection tracking.'; + return (
@@ -39,6 +53,12 @@ export default function ConnectionsTab({ deviceId }: { deviceId: number }) {

Active Connections ({total}{total > all.length ? `, showing ${all.length}` : ''})

+ {trackLabel && ( + + Tracking: {trackLabel} + + )}
@@ -54,7 +74,7 @@ export default function ConnectionsTab({ deviceId }: { deviceId: number }) { {isLoading ? (
Loading connections…
) : rows.length === 0 ? ( -
{all.length === 0 ? 'No active connections (connection tracking may be disabled).' : 'No connections match your filter.'}
+
{all.length === 0 ? emptyMessage : 'No connections match your filter.'}
) : (
diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 9cadba0..1e44cf1 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -83,7 +83,7 @@ function NavItem({ ) } > - + {label} ); @@ -110,7 +110,7 @@ function GroupHeader({ isActive ? 'text-accent' : 'text-ink-2 hover:bg-surface-3 hover:text-ink' )} > - + {label} @@ -176,14 +176,11 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) { )} style={{ borderColor: 'var(--line)' }} > -
- -
+ {/* Theme-aware logo: light-tile icon in light mode, navy-tile icon in dark mode */} + MikroTik Manager + MikroTik Manager
-
Mikrotik Manager
+
MikroTik Manager
{APP_VERSION}
diff --git a/frontend/src/pages/ClientsPage.tsx b/frontend/src/pages/ClientsPage.tsx index 3495d52..1e111cd 100644 --- a/frontend/src/pages/ClientsPage.tsx +++ b/frontend/src/pages/ClientsPage.tsx @@ -1,5 +1,5 @@ -import { useState, useMemo, useRef, useEffect } from 'react'; -import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { useState, useRef, useEffect } from 'react'; +import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { Search, Wifi, Network, Users, X, Pencil, Trash2, ChevronUp, ChevronDown, ChevronsUpDown, RefreshCw } from 'lucide-react'; import { clientsApi } from '../services/api'; @@ -147,18 +147,24 @@ export default function ClientsPage() { localStorage.setItem(STORAGE_KEY, String(val)); }; + // Sorting is server-side (across the whole dataset, not just the current + // page), so changing the sort returns to page 1 and refetches. const toggleSort = (col: string) => { if (sortCol === col) setSortDir((d) => d === 'asc' ? 'desc' : 'asc'); else { setSortCol(col); setSortDir('asc'); } + setPage(0); }; const { data, isLoading } = useQuery({ - queryKey: ['clients', { search, showAll, page }], + queryKey: ['clients', { search, showAll, page, sortCol, sortDir }], queryFn: () => clientsApi - .list({ search: search || undefined, active: showAll ? undefined : true, limit: PAGE_SIZE, offset: page * PAGE_SIZE }) + .list({ search: search || undefined, active: showAll ? undefined : true, limit: PAGE_SIZE, offset: page * PAGE_SIZE, sort: sortCol, dir: sortDir }) .then((r) => r.data), refetchInterval: refreshInterval ?? false, + // Keep the current rows on screen while a sort/page/search refetch is in + // flight so the table doesn't blank out and jump. + placeholderData: keepPreviousData, }); const [purgeResult, setPurgeResult] = useState(''); @@ -179,33 +185,10 @@ export default function ClientsPage() { }, }); - const rawClients = data?.clients ?? []; + // Server returns rows already sorted + paginated for the active sort column. + const clients = data?.clients ?? []; const total = data?.total ?? 0; - const clients = useMemo(() => { - const toIpNum = (ip: string) => - (ip || '').split('.').reduce((n, o) => n * 256 + parseInt(o || '0', 10), 0); - - return [...rawClients].sort((a, b) => { - let av: string | number, bv: string | number; - switch (sortCol) { - case 'hostname': av = a.custom_name || a.hostname || ''; bv = b.custom_name || b.hostname || ''; break; - case 'vendor': av = a.vendor || ''; bv = b.vendor || ''; break; - case 'ip_address': av = toIpNum(a.ip_address || ''); bv = toIpNum(b.ip_address || ''); break; - case 'client_type': av = a.client_type || ''; bv = b.client_type || ''; break; - case 'interface_name': av = a.interface_name || ''; bv = b.interface_name || ''; break; - case 'vlan_id': av = a.vlan_id ?? -1; bv = b.vlan_id ?? -1; break; - case 'device_name': av = a.device_name || ''; bv = b.device_name || ''; break; - case 'traffic_today_bytes': av = a.traffic_today_bytes ?? 0; bv = b.traffic_today_bytes ?? 0; break; - case 'last_seen': av = a.last_seen || ''; bv = b.last_seen || ''; break; - default: av = ''; bv = ''; - } - if (av === bv) return 0; - const cmp = av < bv ? -1 : 1; - return sortDir === 'asc' ? cmp : -cmp; - }); - }, [rawClients, sortCol, sortDir]); - return (
{editingClient && ( @@ -296,7 +279,18 @@ export default function ClientsPage() { <>
-
+
+ + + + + + + + + + {showAll && } + {([ @@ -338,22 +332,22 @@ export default function ClientsPage() { className="hover:bg-gray-50 dark:hover:bg-slate-700/30" > - - -
-
+
{client.custom_name && ( - custom + custom )} {canWrite && (
{client.vendor && ( -
{client.vendor}
+
{client.vendor}
)} -
+
{client.mac_address}
+ {client.ip_address || '—'} @@ -390,7 +384,7 @@ export default function ClientsPage() { )} + {client.interface_name || '—'} @@ -398,7 +392,7 @@ export default function ClientsPage() { ? {client.vlan_id} : '—'} + {client.device_name || '—'} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index abbbea8..2b64a38 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -2,7 +2,7 @@ import { useState, useEffect, useRef, useMemo } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; import { - LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, } from 'recharts'; import { Users, AlertTriangle, Wifi, MapPin, ArrowUpCircle, Cpu, X, @@ -256,6 +256,19 @@ function HealthBar({ // ─── Summary View ───────────────────────────────────────────────────────────── +// Cap the number of bars so they render as readable columns instead of hairline +// slivers on dense ranges. Strides through the series (a point-in-time client +// gauge, so sampling preserves the trend) and always keeps the latest point. +function downsample(arr: T[], max: number): T[] { + if (arr.length <= max) return arr; + const step = Math.ceil(arr.length / max); + const out: T[] = []; + for (let i = 0; i < arr.length; i += step) out.push(arr[i]); + const last = arr[arr.length - 1]; + if (out[out.length - 1] !== last) out.push(last); + return out; +} + // eslint-disable-next-line @typescript-eslint/no-explicit-any function SummaryView(props: Record) { const { summary, devices, wirelessCount, clientSparkline, clientsOverTime, @@ -282,13 +295,11 @@ function SummaryView(props: Record) { router: { label: 'Routers', color: 'var(--violet)' }, }; - const chartDomain: [number, number] = (() => { - const ranges: Record = { '1h': 1, '6h': 6, '24h': 24, '7d': 168 }; - const hrs = ranges[chartRange] ?? 24; - // eslint-disable-next-line react-hooks/purity - const now = Date.now(); - return [now - hrs * 60 * 60 * 1000, now]; - })(); + // Bars over a category axis fill their slots (thick, minimal gaps) rather than + // sitting as thin fixed-width marks on a continuous time axis. Downsample to + // keep them chunky even on long ranges. + const chartData = downsample(clientsOverTime as { ts: number; value: number }[], 60); + const tickEvery = Math.max(0, Math.ceil(chartData.length / 6) - 1); return (
@@ -335,16 +346,18 @@ function SummaryView(props: Record) {
{clientsOverTime.length > 0 ? ( - - + + format(new Date(t), 'HH:mm')} + interval={tickEvery} + minTickGap={20} + tickFormatter={(t) => format(new Date(t as number), chartRange === '7d' ? 'MMM d' : 'HH:mm')} /> [v, 'Clients']} labelFormatter={(t) => format(new Date(t as number), 'MMM d, HH:mm')} contentStyle={{ @@ -355,12 +368,8 @@ function SummaryView(props: Record) { color: 'var(--ink)', }} /> - - + + ) : (
diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 4a871be..9d14567 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -261,7 +261,7 @@ export const devicesApi = { api.delete(`/devices/${id}/address-lists/${encodeURIComponent(entryId)}`), // Active connections (read-only) getConnections: (id: number, limit = 500) => - api.get<{ total: number; connections: Record[] }>(`/devices/${id}/connections`, { params: { limit } }), + api.get<{ total: number; connections: Record[]; tracking: Record }>(`/devices/${id}/connections`, { params: { limit } }), // Simple queues (bandwidth control) getQueues: (id: number) => api.get[]>(`/devices/${id}/queues`), addQueue: (id: number, data: Record) => @@ -350,6 +350,8 @@ export const clientsApi = { client_type?: string; limit?: number; offset?: number; + sort?: string; + dir?: 'asc' | 'desc'; }) => api.get<{ clients: Client[]; total: number }>('/clients', { params }), get: (mac: string) => api.get(`/clients/${mac}`), diff --git a/frontend/src/version.ts b/frontend/src/version.ts index 25aa171..124c25d 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.14.0 Beta'; +export const APP_VERSION = 'v0.14.1 Beta';