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:
co-authored by
Claude Opus 4.8
parent
ee9c9ff9f7
commit
d79eb13119
@@ -140,3 +140,6 @@ vite.config.ts.timestamp-*
|
||||
|
||||
# Claude Code session files
|
||||
.claude/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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)]);
|
||||
|
||||
@@ -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 };
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -1824,6 +1824,13 @@ export class DeviceCollector {
|
||||
return this.client.execute('/ip/firewall/connection/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
|
||||
}
|
||||
|
||||
// /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<Record<string, string>> {
|
||||
const rows = await this.client.execute('/ip/firewall/connection/tracking/print', {}).catch(() => [] as Record<string, string>[]);
|
||||
return rows[0] || {};
|
||||
}
|
||||
|
||||
// ─── Simple Queues (per-client / per-subnet bandwidth control) ──────────────
|
||||
|
||||
async getSimpleQueues(): Promise<Record<string, string>[]> {
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.14.0-beta",
|
||||
"version": "0.14.1-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
+158
-6
@@ -1,7 +1,159 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||
<rect width="32" height="32" rx="8" fill="#2563eb"/>
|
||||
<path d="M6 16h4M22 16h4M10 16a6 6 0 0 1 12 0" stroke="white" stroke-width="2.5" stroke-linecap="round"/>
|
||||
<circle cx="16" cy="16" r="3" fill="white"/>
|
||||
<circle cx="8" cy="16" r="2" fill="#93c5fd"/>
|
||||
<circle cx="24" cy="16" r="2" fill="#93c5fd"/>
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
width="64"
|
||||
height="64"
|
||||
viewBox="0 0 64 64"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-labelledby="title desc"
|
||||
version="1.1"
|
||||
id="svg10"
|
||||
sodipodi:docname="mikrotik-manager-config-compass-favicon.svg"
|
||||
inkscape:version="1.3.2 (091e20e, 2023-11-25)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<sodipodi:namedview
|
||||
id="namedview10"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#666666"
|
||||
borderopacity="1.0"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="10.429825"
|
||||
inkscape:cx="19.415474"
|
||||
inkscape:cy="41.419679"
|
||||
inkscape:window-width="1240"
|
||||
inkscape:window-height="809"
|
||||
inkscape:window-x="546"
|
||||
inkscape:window-y="1729"
|
||||
inkscape:window-maximized="0"
|
||||
inkscape:current-layer="svg10" />
|
||||
<title
|
||||
id="title">MikroTik Manager Favicon</title>
|
||||
<desc
|
||||
id="desc">A simplified compass favicon with central M mark and four connected network nodes.</desc>
|
||||
<defs
|
||||
id="defs3">
|
||||
<linearGradient
|
||||
id="favRing"
|
||||
x1="12"
|
||||
y1="12"
|
||||
x2="52"
|
||||
y2="52"
|
||||
gradientUnits="userSpaceOnUse">
|
||||
<stop
|
||||
stop-color="#7C6DFF"
|
||||
id="stop1" />
|
||||
<stop
|
||||
offset="0.48"
|
||||
stop-color="#2EA8FF"
|
||||
id="stop2" />
|
||||
<stop
|
||||
offset="1"
|
||||
stop-color="#23E0C1"
|
||||
id="stop3" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect
|
||||
x="2"
|
||||
y="2"
|
||||
width="60"
|
||||
height="60"
|
||||
rx="14"
|
||||
fill="#07111F"
|
||||
id="rect3" />
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="19"
|
||||
stroke="url(#favRing)"
|
||||
stroke-width="4.5"
|
||||
stroke-linecap="round"
|
||||
id="circle3" />
|
||||
<circle
|
||||
cx="32"
|
||||
cy="32"
|
||||
r="9"
|
||||
stroke="#F4F7FB"
|
||||
stroke-width="3.5"
|
||||
id="circle4" />
|
||||
<line
|
||||
x1="32"
|
||||
y1="11"
|
||||
x2="32"
|
||||
y2="22"
|
||||
stroke="#F4F7FB"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
id="line4" />
|
||||
<line
|
||||
x1="42"
|
||||
y1="32"
|
||||
x2="53"
|
||||
y2="32"
|
||||
stroke="#F4F7FB"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
id="line5" />
|
||||
<line
|
||||
x1="32"
|
||||
y1="42"
|
||||
x2="32"
|
||||
y2="53"
|
||||
stroke="#F4F7FB"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
id="line6" />
|
||||
<line
|
||||
x1="11"
|
||||
y1="32"
|
||||
x2="22"
|
||||
y2="32"
|
||||
stroke="#F4F7FB"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
id="line7" />
|
||||
<circle
|
||||
cx="32"
|
||||
cy="10"
|
||||
r="4.2"
|
||||
fill="#07111F"
|
||||
stroke="#23E0C1"
|
||||
stroke-width="3"
|
||||
id="circle7" />
|
||||
<circle
|
||||
cx="54"
|
||||
cy="32"
|
||||
r="4.2"
|
||||
fill="#07111F"
|
||||
stroke="#2EA8FF"
|
||||
stroke-width="3"
|
||||
id="circle8" />
|
||||
<circle
|
||||
cx="32"
|
||||
cy="54"
|
||||
r="4.2"
|
||||
fill="#07111F"
|
||||
stroke="#79E044"
|
||||
stroke-width="3"
|
||||
id="circle9" />
|
||||
<circle
|
||||
cx="10"
|
||||
cy="32"
|
||||
r="4.2"
|
||||
fill="#07111F"
|
||||
stroke="#7C6DFF"
|
||||
stroke-width="3"
|
||||
id="circle10" />
|
||||
<path
|
||||
d="M 28.513435,36.116485 V 27.924724 L 32,33.044574 l 3.486564,-5.11985 v 8.191761"
|
||||
stroke="#f4f7fb"
|
||||
stroke-width="2.98101"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
id="path10" />
|
||||
</svg>
|
||||
|
||||
|
Before Width: | Height: | Size: 387 B After Width: | Height: | Size: 3.4 KiB |
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" role="img" aria-labelledby="title desc" version="1.1" id="svg24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title id="title">MikroTik Manager Config Compass Icon - Dark Mode</title>
|
||||
<desc id="desc">A dark-mode SVG app icon showing a network compass with a central manager node and four directional service nodes.</desc>
|
||||
<defs id="defs13">
|
||||
<linearGradient id="bgDark" x1="96" y1="80" x2="928" y2="944" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#111D2E" id="stop1" />
|
||||
<stop offset="1" stop-color="#06111F" id="stop2" />
|
||||
</linearGradient>
|
||||
<linearGradient id="ringDark" x1="236" y1="244" x2="788" y2="780" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7C6DFF" id="stop3" />
|
||||
<stop offset="0.48" stop-color="#2EA8FF" id="stop4" />
|
||||
<stop offset="1" stop-color="#23E0C1" id="stop5" />
|
||||
</linearGradient>
|
||||
<linearGradient id="northDark" x1="512" y1="136" x2="512" y2="318" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#35F4E4" id="stop6" />
|
||||
<stop offset="1" stop-color="#1CA7FF" id="stop7" />
|
||||
</linearGradient>
|
||||
<linearGradient id="eastDark" x1="702" y1="512" x2="900" y2="512" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1CA7FF" id="stop8" />
|
||||
<stop offset="1" stop-color="#35F4E4" id="stop9" />
|
||||
</linearGradient>
|
||||
<linearGradient id="southDark" x1="512" y1="708" x2="512" y2="900" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#A7F542" id="stop10" />
|
||||
<stop offset="1" stop-color="#43D868" id="stop11" />
|
||||
</linearGradient>
|
||||
<linearGradient id="westDark" x1="320" y1="512" x2="126" y2="512" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#7C6DFF" id="stop12" />
|
||||
<stop offset="1" stop-color="#A36BFF" id="stop13" />
|
||||
</linearGradient>
|
||||
<filter id="softGlowDark" x="-0.052830189" y="-0.052830189" width="1.1056604" height="1.1056604" color-interpolation-filters="sRGB">
|
||||
<feGaussianBlur stdDeviation="14" result="blur" id="feGaussianBlur13" />
|
||||
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.10 0 0 0 0 0.60 0 0 0 0 1.00 0 0 0 0.35 0" id="feColorMatrix13" />
|
||||
<feBlend in="SourceGraphic" id="feBlend13" />
|
||||
</filter>
|
||||
</defs>
|
||||
<rect x="64" y="64" width="896" height="896" rx="208" fill="url(#bgDark)" id="rect13" />
|
||||
<rect x="76" y="76" width="872" height="872" rx="196" stroke="#223855" stroke-width="20" opacity="0.85" id="rect14" />
|
||||
<g filter="url(#softGlowDark)" id="g24">
|
||||
<circle cx="512" cy="512" r="262" stroke="url(#ringDark)" stroke-width="32" stroke-linecap="round" stroke-dasharray="360 70 360 70" id="circle14" />
|
||||
<circle cx="512" cy="512" r="116" stroke="#F4F7FB" stroke-width="28" id="circle15" />
|
||||
<path d="M512 194 L552 306 H472 L512 194Z" fill="url(#northDark)" id="path15" />
|
||||
<path d="M830 512 L718 552 V472 L830 512Z" fill="url(#eastDark)" id="path16" />
|
||||
<path d="M512 830 L472 718 H552 L512 830Z" fill="url(#southDark)" id="path17" />
|
||||
<path d="M194 512 L306 472 V552 L194 512Z" fill="url(#westDark)" id="path18" />
|
||||
<line x1="512" y1="300" x2="512" y2="396" stroke="#F4F7FB" stroke-width="28" stroke-linecap="round" id="line18" />
|
||||
<line x1="628" y1="512" x2="724" y2="512" stroke="#F4F7FB" stroke-width="28" stroke-linecap="round" id="line19" />
|
||||
<line x1="512" y1="628" x2="512" y2="724" stroke="#F4F7FB" stroke-width="28" stroke-linecap="round" id="line20" />
|
||||
<line x1="300" y1="512" x2="396" y2="512" stroke="#F4F7FB" stroke-width="28" stroke-linecap="round" id="line21" />
|
||||
<circle cx="512" cy="294" r="46" fill="#07111F" stroke="url(#northDark)" stroke-width="28" id="circle21" />
|
||||
<circle cx="730" cy="512" r="46" fill="#07111F" stroke="url(#eastDark)" stroke-width="28" id="circle22" />
|
||||
<circle cx="512" cy="730" r="46" fill="#07111F" stroke="url(#southDark)" stroke-width="28" id="circle23" />
|
||||
<circle cx="294" cy="512" r="46" fill="#07111F" stroke="url(#westDark)" stroke-width="28" id="circle24" />
|
||||
<path d="m 454.87455,565.67016 v -108.409 l 57.12546,77.09084 57.12545,-77.09084 v 108.409" stroke="#f4f7fb" stroke-width="34.5308" stroke-linecap="round" stroke-linejoin="round" id="path24" />
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,49 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" role="img" aria-labelledby="title desc" version="1.1" id="svg24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title id="title">MikroTik Manager Config Compass Icon - Light Mode</title>
|
||||
<desc id="desc">A light-mode SVG app icon showing a network compass with a central manager node and four directional service nodes.</desc>
|
||||
<defs id="defs13">
|
||||
<linearGradient id="bgLight" x1="96" y1="80" x2="928" y2="944" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#FFFFFF" id="stop1" />
|
||||
<stop offset="1" stop-color="#EAF2FF" id="stop2" />
|
||||
</linearGradient>
|
||||
<linearGradient id="ringLight" x1="236" y1="244" x2="788" y2="780" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6757F5" id="stop3" />
|
||||
<stop offset="0.48" stop-color="#0777D8" id="stop4" />
|
||||
<stop offset="1" stop-color="#00A98F" id="stop5" />
|
||||
</linearGradient>
|
||||
<linearGradient id="northLight" x1="512" y1="136" x2="512" y2="318" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#00BFB3" id="stop6" />
|
||||
<stop offset="1" stop-color="#0877D8" id="stop7" />
|
||||
</linearGradient>
|
||||
<linearGradient id="eastLight" x1="702" y1="512" x2="900" y2="512" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#0877D8" id="stop8" />
|
||||
<stop offset="1" stop-color="#00BFB3" id="stop9" />
|
||||
</linearGradient>
|
||||
<linearGradient id="southLight" x1="512" y1="708" x2="512" y2="900" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#79C900" id="stop10" />
|
||||
<stop offset="1" stop-color="#1CB85E" id="stop11" />
|
||||
</linearGradient>
|
||||
<linearGradient id="westLight" x1="320" y1="512" x2="126" y2="512" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#6757F5" id="stop12" />
|
||||
<stop offset="1" stop-color="#914AF8" id="stop13" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="64" y="64" width="896" height="896" rx="208" fill="url(#bgLight)" id="rect13" />
|
||||
<rect x="76" y="76" width="872" height="872" rx="196" stroke="#D5E2F2" stroke-width="20" id="rect14" />
|
||||
<circle cx="512" cy="512" r="262" stroke="url(#ringLight)" stroke-width="32" stroke-linecap="round" stroke-dasharray="360 70 360 70" id="circle14" />
|
||||
<circle cx="512" cy="512" r="116" stroke="#152238" stroke-width="28" id="circle15" />
|
||||
<path d="M512 194 L552 306 H472 L512 194Z" fill="url(#northLight)" id="path15" />
|
||||
<path d="M830 512 L718 552 V472 L830 512Z" fill="url(#eastLight)" id="path16" />
|
||||
<path d="M512 830 L472 718 H552 L512 830Z" fill="url(#southLight)" id="path17" />
|
||||
<path d="M194 512 L306 472 V552 L194 512Z" fill="url(#westLight)" id="path18" />
|
||||
<line x1="512" y1="300" x2="512" y2="396" stroke="#152238" stroke-width="28" stroke-linecap="round" id="line18" />
|
||||
<line x1="628" y1="512" x2="724" y2="512" stroke="#152238" stroke-width="28" stroke-linecap="round" id="line19" />
|
||||
<line x1="512" y1="628" x2="512" y2="724" stroke="#152238" stroke-width="28" stroke-linecap="round" id="line20" />
|
||||
<line x1="300" y1="512" x2="396" y2="512" stroke="#152238" stroke-width="28" stroke-linecap="round" id="line21" />
|
||||
<circle cx="512" cy="294" r="46" fill="#FFFFFF" stroke="url(#northLight)" stroke-width="28" id="circle21" />
|
||||
<circle cx="730" cy="512" r="46" fill="#FFFFFF" stroke="url(#eastLight)" stroke-width="28" id="circle22" />
|
||||
<circle cx="512" cy="730" r="46" fill="#FFFFFF" stroke="url(#southLight)" stroke-width="28" id="circle23" />
|
||||
<circle cx="294" cy="512" r="46" fill="#FFFFFF" stroke="url(#westLight)" stroke-width="28" id="circle24" />
|
||||
<path d="M 454.87456,567.20426 V 458.79529 L 512,535.88611 569.12545,458.79529 v 108.40897" stroke="#152238" stroke-width="38" stroke-linecap="round" stroke-linejoin="round" id="path24" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.7 KiB |
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)' }}>
|
||||
|
||||
@@ -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 @@
|
||||
export const APP_VERSION = 'v0.14.0 Beta';
|
||||
export const APP_VERSION = 'v0.14.1 Beta';
|
||||
|
||||
Reference in New Issue
Block a user