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 <noreply@anthropic.com>
This commit is contained in:
2GT-Rich
2026-06-18 13:37:11 -07:00
co-authored by Claude Opus 4.8
parent ee9c9ff9f7
commit d79eb13119
18 changed files with 402 additions and 93 deletions
@@ -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 (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
@@ -39,6 +53,12 @@ export default function ConnectionsTab({ deviceId }: { deviceId: number }) {
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
Active Connections <span className="text-gray-400 font-normal">({total}{total > all.length ? `, showing ${all.length}` : ''})</span>
</h3>
{trackLabel && (
<span className={clsx('inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium', trackStyle)}
title={tracking === 'auto' ? 'Auto: tracking activates only when the device has firewall/NAT rules' : tracking === 'no' ? 'Connection tracking is disabled' : 'Connection tracking is active'}>
Tracking: {trackLabel}
</span>
)}
</div>
<div className="flex items-center gap-2">
<div className="relative">
@@ -54,7 +74,7 @@ export default function ConnectionsTab({ deviceId }: { deviceId: number }) {
{isLoading ? (
<div className="text-center py-8 text-gray-400"><RefreshCw className="w-4 h-4 animate-spin inline mr-2" />Loading connections…</div>
) : rows.length === 0 ? (
<div className="card p-8 text-center text-gray-400">{all.length === 0 ? 'No active connections (connection tracking may be disabled).' : 'No connections match your filter.'}</div>
<div className="card p-8 text-center text-gray-400 max-w-2xl mx-auto text-sm">{all.length === 0 ? emptyMessage : 'No connections match your filter.'}</div>
) : (
<div className="card overflow-hidden overflow-x-auto">
<table className="w-full text-sm">
+12 -15
View File
@@ -83,7 +83,7 @@ function NavItem({
)
}
>
<Icon className="w-[15px] h-[15px] flex-shrink-0" />
<Icon className="w-[17px] h-[17px] flex-shrink-0" />
<span className={clsx(isCollapsed && 'md:hidden')}>{label}</span>
</NavLink>
);
@@ -110,7 +110,7 @@ function GroupHeader({
isActive ? 'text-accent' : 'text-ink-2 hover:bg-surface-3 hover:text-ink'
)}
>
<Icon className="w-[15px] h-[15px] flex-shrink-0" />
<Icon className="w-[17px] h-[17px] flex-shrink-0" />
<span className="flex-1 text-left">{label}</span>
<ChevronDown className={clsx('w-3.5 h-3.5 transition-transform', isOpen ? 'rotate-0' : '-rotate-90')} />
</button>
@@ -176,14 +176,11 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
)}
style={{ borderColor: 'var(--line)' }}
>
<div
className="w-[30px] h-[30px] rounded-[7px] flex items-center justify-center flex-shrink-0"
style={{ background: 'linear-gradient(135deg, var(--accent), var(--accent-2))' }}
>
<Network className="w-4 h-4" style={{ color: 'var(--accent-fg)' }} />
</div>
{/* Theme-aware logo: light-tile icon in light mode, navy-tile icon in dark mode */}
<img src="/logo-light.svg" alt="MikroTik Manager" className="w-[45px] h-[45px] flex-shrink-0 block dark:hidden" />
<img src="/logo-dark.svg" alt="MikroTik Manager" className="w-[45px] h-[45px] flex-shrink-0 hidden dark:block" />
<div className={clsx(isCollapsed && 'md:hidden')}>
<div className="text-[14px] font-semibold leading-none" style={{ color: 'var(--ink)' }}>Mikrotik Manager</div>
<div className="text-[14px] font-semibold leading-none" style={{ color: 'var(--ink)' }}>MikroTik Manager</div>
<div className="text-[11px] leading-none mt-[3px]" style={{ color: 'var(--ink-3)' }}>{APP_VERSION}</div>
</div>
<button
@@ -222,7 +219,7 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
)
}
>
<Layers className="w-[15px] h-[15px] flex-shrink-0" />
<Layers className="w-[17px] h-[17px] flex-shrink-0" />
</NavLink>
) : (
<>
@@ -260,7 +257,7 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
)
}
>
<Router className="w-[15px] h-[15px] flex-shrink-0" />
<Router className="w-[17px] h-[17px] flex-shrink-0" />
</NavLink>
) : (
<>
@@ -298,7 +295,7 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
)
}
>
<Wifi className="w-[15px] h-[15px] flex-shrink-0" />
<Wifi className="w-[17px] h-[17px] flex-shrink-0" />
</NavLink>
) : (
<>
@@ -337,7 +334,7 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
)
}
>
<Network className="w-[15px] h-[15px] flex-shrink-0" />
<Network className="w-[17px] h-[17px] flex-shrink-0" />
</NavLink>
) : (
<>
@@ -405,9 +402,9 @@ export default function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
style={{ color: 'var(--ink-4)' }}
>
{isCollapsed ? (
<ChevronRight className="w-[15px] h-[15px] flex-shrink-0" />
<ChevronRight className="w-[17px] h-[17px] flex-shrink-0" />
) : (
<><ChevronLeft className="w-[15px] h-[15px] flex-shrink-0" />Collapse</>
<><ChevronLeft className="w-[17px] h-[17px] flex-shrink-0" />Collapse</>
)}
</button>
</div>
+33 -39
View File
@@ -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 (
<div className="space-y-4">
{editingClient && (
@@ -296,7 +279,18 @@ export default function ClientsPage() {
<>
<div className="card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-sm table-fixed">
<colgroup>
<col style={{ width: '22%' }} />
<col style={{ width: '12%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '11%' }} />
<col style={{ width: '7%' }} />
<col style={{ width: '13%' }} />
<col style={{ width: '10%' }} />
<col style={{ width: '12%' }} />
{showAll && <col style={{ width: '6%' }} />}
</colgroup>
<thead>
<tr className="border-b border-gray-200 dark:border-slate-700">
{([
@@ -338,22 +332,22 @@ export default function ClientsPage() {
className="hover:bg-gray-50 dark:hover:bg-slate-700/30"
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 min-w-0">
<button
onClick={() => navigate(`/clients/${encodeURIComponent(client.mac_address)}`)}
className="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left"
className="font-medium text-blue-600 dark:text-blue-400 hover:underline text-left truncate"
>
{client.custom_name || client.hostname || (
<span className="text-gray-400 dark:text-slate-500 italic">No hostname</span>
)}
</button>
{client.custom_name && (
<span className="text-xs text-blue-500 dark:text-blue-400 ml-1">custom</span>
<span className="text-xs text-blue-500 dark:text-blue-400 ml-1 flex-shrink-0">custom</span>
)}
{canWrite && (
<button
onClick={(e) => { e.stopPropagation(); setEditingClient(client); }}
className="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700"
className="p-0.5 rounded hover:bg-gray-100 dark:hover:bg-slate-700 flex-shrink-0"
title="Edit name"
>
<Pencil className="w-3 h-3 text-gray-300 dark:text-slate-600 hover:text-gray-500 dark:hover:text-slate-400" />
@@ -361,13 +355,13 @@ export default function ClientsPage() {
)}
</div>
{client.vendor && (
<div className="text-xs text-gray-400 dark:text-slate-500">{client.vendor}</div>
<div className="text-xs text-gray-400 dark:text-slate-500 truncate">{client.vendor}</div>
)}
<div className="text-xs font-mono text-gray-400 dark:text-slate-500">
<div className="text-xs font-mono text-gray-400 dark:text-slate-500 truncate">
{client.mac_address}
</div>
</td>
<td className="px-4 py-2.5 font-mono text-gray-600 dark:text-slate-400">
<td className="px-4 py-2.5 font-mono text-gray-600 dark:text-slate-400 truncate">
{client.ip_address || '—'}
</td>
<td className="px-4 py-2.5">
@@ -390,7 +384,7 @@ export default function ClientsPage() {
)}
</span>
</td>
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400">
<td className="px-4 py-2.5 font-mono text-xs text-gray-500 dark:text-slate-400 truncate">
{client.interface_name || '—'}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
@@ -398,7 +392,7 @@ export default function ClientsPage() {
? <span className="px-1.5 py-0.5 bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded font-mono">{client.vlan_id}</span>
: '—'}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400 truncate">
{client.device_name || '—'}
</td>
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400 whitespace-nowrap">
+28 -19
View File
@@ -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<T>(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<string, any>) {
const { summary, devices, wirelessCount, clientSparkline, clientsOverTime,
@@ -282,13 +295,11 @@ function SummaryView(props: Record<string, any>) {
router: { label: 'Routers', color: 'var(--violet)' },
};
const chartDomain: [number, number] = (() => {
const ranges: Record<string, number> = { '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 (
<div className="space-y-4">
@@ -335,16 +346,18 @@ function SummaryView(props: Record<string, any>) {
</div>
{clientsOverTime.length > 0 ? (
<ResponsiveContainer width="100%" height={180}>
<LineChart data={clientsOverTime} margin={{ top: 4, right: 4, left: -24, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="var(--line-soft)" />
<BarChart data={chartData} margin={{ top: 4, right: 4, left: -24, bottom: 0 }} barCategoryGap="12%">
<CartesianGrid strokeDasharray="3 3" stroke="var(--line-soft)" vertical={false} />
<XAxis
dataKey="ts" type="number" scale="time" domain={chartDomain}
dataKey="ts"
tick={{ fontSize: 10, fill: 'var(--ink-4)', fontFamily: 'Geist Mono' }}
tickCount={7}
tickFormatter={(t) => format(new Date(t), 'HH:mm')}
interval={tickEvery}
minTickGap={20}
tickFormatter={(t) => format(new Date(t as number), chartRange === '7d' ? 'MMM d' : 'HH:mm')}
/>
<YAxis tick={{ fontSize: 10, fill: 'var(--ink-4)' }} allowDecimals={false} />
<Tooltip
cursor={{ fill: 'var(--surface-3)', opacity: 0.4 }}
formatter={(v: number) => [v, 'Clients']}
labelFormatter={(t) => format(new Date(t as number), 'MMM d, HH:mm')}
contentStyle={{
@@ -355,12 +368,8 @@ function SummaryView(props: Record<string, any>) {
color: 'var(--ink)',
}}
/>
<Line
type="monotone" dataKey="value"
stroke="var(--accent)" strokeWidth={1.8}
dot={false} activeDot={{ r: 4, fill: 'var(--accent)' }}
/>
</LineChart>
<Bar dataKey="value" fill="var(--accent)" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center text-[13px]" style={{ height: 180, color: 'var(--ink-4)' }}>
+3 -1
View File
@@ -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<string, string>[] }>(`/devices/${id}/connections`, { params: { limit } }),
api.get<{ total: number; connections: Record<string, string>[]; tracking: Record<string, string> }>(`/devices/${id}/connections`, { params: { limit } }),
// Simple queues (bandwidth control)
getQueues: (id: number) => api.get<Record<string, string>[]>(`/devices/${id}/queues`),
addQueue: (id: number, data: Record<string, unknown>) =>
@@ -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<ClientDetail>(`/clients/${mac}`),
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = 'v0.14.0 Beta';
export const APP_VERSION = 'v0.14.1 Beta';