From e777276e4117c30837609a6c2b3abe8126108c08 Mon Sep 17 00:00:00 2001 From: 2GT-Rich <107219997+2GT-Rich@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:38:16 -0700 Subject: [PATCH] fix(clients): unify client traffic chart timeframes to 2h/24h/7d and stop App Traffic card vanishing (v0.13.2-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two per-client usage charts exposed different time windows: "Wireless Usage" offered 2h/24h/7d while "App Traffic (NetFlow)" offered 1h/24h/7d/30d. Worse, selecting 1h (or any range with no flows in window) made the whole App Traffic card unmount — including its range buttons, so it couldn't be recovered without reloading. - AppTrafficCard now uses 2h/24h/7d (default 24h), matching Wireless Usage - Card visibility latches on first non-empty result, so switching to an empty range shows an inline "No traffic data yet for this range" state instead of unmounting the card (latch derived via guarded setState in render — no effect) - Backend /api/traffic/* now accepts the '2h' range (rangeToFlux + windowForRange: 2h -> 2m buckets); previously '2h' silently fell back to 24h Verified in the live stack with Playwright: ranges render as 2h/24h/7d (no 1h/30d), and clicking 2h on a client with data only outside the 2h window keeps the card mounted with an empty-state and recovers on switching back. Co-Authored-By: Claude Opus 4.8 --- README.md | 2 +- backend/package.json | 2 +- backend/src/routes/trafficAnalytics.ts | 4 ++-- frontend/package.json | 2 +- frontend/src/pages/ClientDetailPage.tsx | 24 +++++++++++++++++------- frontend/src/version.ts | 2 +- 6 files changed, 23 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 308b3f2..3810117 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.13.1_Beta-blue) +![Version](https://img.shields.io/badge/version-0.13.2_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.json b/backend/package.json index 2c0e839..a3d8f58 100644 --- a/backend/package.json +++ b/backend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-backend", - "version": "0.13.1-beta", + "version": "0.13.2-beta", "description": "Mikrotik Network Management Platform - Backend API", "main": "dist/index.js", "scripts": { diff --git a/backend/src/routes/trafficAnalytics.ts b/backend/src/routes/trafficAnalytics.ts index 82c6cdf..12d893e 100644 --- a/backend/src/routes/trafficAnalytics.ts +++ b/backend/src/routes/trafficAnalytics.ts @@ -8,7 +8,7 @@ const router = Router(); router.use(requireAuth); function rangeToFlux(range: string): string { - const allowed = ['1h', '3h', '6h', '12h', '24h', '7d', '30d']; + const allowed = ['1h', '2h', '3h', '6h', '12h', '24h', '7d', '30d']; return allowed.includes(range) ? range : '24h'; } @@ -16,7 +16,7 @@ function rangeToFlux(range: string): string { // written every 60s by the collector flush). function windowForRange(range: string): string { const map: Record = { - '1h': '1m', '3h': '5m', '6h': '5m', '12h': '10m', + '1h': '1m', '2h': '2m', '3h': '5m', '6h': '5m', '12h': '10m', '24h': '15m', '7d': '1h', '30d': '6h', }; return map[range] || '15m'; diff --git a/frontend/package.json b/frontend/package.json index f5747c8..d3b3762 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mikrotik-manager-frontend", - "version": "0.13.1-beta", + "version": "0.13.2-beta", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/pages/ClientDetailPage.tsx b/frontend/src/pages/ClientDetailPage.tsx index 3be74f2..3f65867 100644 --- a/frontend/src/pages/ClientDetailPage.tsx +++ b/frontend/src/pages/ClientDetailPage.tsx @@ -589,7 +589,7 @@ const APP_COLORS = [ ]; function AppTrafficCard({ mac }: { mac: string }) { - const [range, setRange] = useState<'1h' | '24h' | '7d' | '30d'>('24h'); + const [range, setRange] = useState<'2h' | '24h' | '7d'>('24h'); const { data, isLoading } = useQuery({ queryKey: ['client-app-traffic', mac, range], @@ -600,22 +600,27 @@ function AppTrafficCard({ mac }: { mac: string }) { const series = data?.series ?? []; const apps = data?.apps ?? []; - // NetFlow data only exists for clients on networks with export enabled — - // hide the card entirely rather than showing a permanently empty chart. - if (!isLoading && series.length === 0 && apps.length === 0) return null; + // NetFlow data only exists for clients on networks with export enabled. Latch + // on the first non-empty result so the card stays mounted once it has shown + // data — otherwise switching to a range with no traffic (e.g. an idle client + // over the last 2h) would unmount the whole card, range buttons and all. + // Setting state during render (guarded) is React's recommended way to derive + // sticky state without an effect. + const [hadData, setHadData] = useState(false); + if (!hadData && (series.length > 0 || apps.length > 0)) setHadData(true); + if (!isLoading && series.length === 0 && apps.length === 0 && !hadData) return null; const ranges = [ - { value: '1h', label: '1h' }, + { value: '2h', label: '2h' }, { value: '24h', label: '24h' }, { value: '7d', label: '7d' }, - { value: '30d', label: '30d' }, ] as const; const chartData = series.map(p => ({ time: p.time, Upload: p.upload, Download: p.download, - label: format(parseISO(p.time), range === '7d' || range === '30d' ? 'MMM d HH:mm' : 'HH:mm'), + label: format(parseISO(p.time), range === '7d' ? 'MMM d HH:mm' : 'HH:mm'), })); const totalAppBytes = apps.reduce((s, a) => s + a.bytes, 0); @@ -648,6 +653,11 @@ function AppTrafficCard({ mac }: { mac: string }) { {isLoading ? (
+ ) : chartData.length === 0 && apps.length === 0 ? ( +
+ + No traffic data yet for this range +
) : ( <> {chartData.length > 0 && ( diff --git a/frontend/src/version.ts b/frontend/src/version.ts index c16763c..2ea1723 100644 --- a/frontend/src/version.ts +++ b/frontend/src/version.ts @@ -1 +1 @@ -export const APP_VERSION = 'v0.13.1 Beta'; +export const APP_VERSION = 'v0.13.2 Beta';