From cd31d1954c6c93fef1057cbcfc81b7713201fb42 Mon Sep 17 00:00:00 2001 From: vanne Date: Thu, 2 Jul 2026 08:53:30 +0700 Subject: [PATCH] feat: align admin & agent dashboard summaries with Netify real totals and speeds --- backend/database.js | 30 ++++++- backend/netify.js | 35 +++++++- backend/routes/dashboard.js | 38 +++++++-- .../tests/agent_summary_correlation.test.js | 85 +++++++++++++++++++ backend/tests/summary_real_totals.test.js | 55 ++++++++++++ src/app/(dashboard)/page.tsx | 29 +++++++ src/lib/api.ts | 3 + 7 files changed, 264 insertions(+), 11 deletions(-) create mode 100644 backend/tests/agent_summary_correlation.test.js create mode 100644 backend/tests/summary_real_totals.test.js diff --git a/backend/database.js b/backend/database.js index c51c918..4cc9615 100644 --- a/backend/database.js +++ b/backend/database.js @@ -75,6 +75,17 @@ function initSchema() { // Column already exists, safe to ignore } + // Alter bandwidth_timeline table to add new speed columns dynamically if they do not exist + try { + d.exec("ALTER TABLE bandwidth_timeline ADD COLUMN download_speed INTEGER DEFAULT 0"); + } catch (_) {} + try { + d.exec("ALTER TABLE bandwidth_timeline ADD COLUMN upload_speed INTEGER DEFAULT 0"); + } catch (_) {} + try { + d.exec("ALTER TABLE bandwidth_timeline ADD COLUMN flow_speed INTEGER DEFAULT 0"); + } catch (_) {} + const adminExists = d.prepare("SELECT count(*) as count FROM users WHERE username = 'admin'").get(); if (adminExists.count === 0) { const hash = bcrypt.hashSync('admin123', 10); @@ -554,7 +565,9 @@ function initSchema() { site_uuid TEXT, fetched_at TEXT NOT NULL, total_download INTEGER DEFAULT 0, total_upload INTEGER DEFAULT 0, - total_flows INTEGER DEFAULT 0, active_devices INTEGER DEFAULT 0 + total_flows INTEGER DEFAULT 0, active_devices INTEGER DEFAULT 0, + download_speed INTEGER DEFAULT 0, upload_speed INTEGER DEFAULT 0, + flow_speed INTEGER DEFAULT 0 )`); d.exec(`CREATE TABLE IF NOT EXISTS geoip_cache ( @@ -766,10 +779,19 @@ function insertBandwidthTimeline(summary, fetchedAt, siteUuid) { const d = getDB(); d.prepare(` INSERT INTO bandwidth_timeline - (site_uuid, fetched_at, total_download, total_upload, total_flows, active_devices) - VALUES (?, ?, ?, ?, ?, ?) + (site_uuid, fetched_at, total_download, total_upload, total_flows, active_devices, download_speed, upload_speed, flow_speed) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `).run( - siteUuid, fetchedAt, summary.download ?? 0, summary.upload ?? 0, summary.flows ?? 0, summary.devices ?? 0); + siteUuid, + fetchedAt, + summary.download ?? 0, + summary.upload ?? 0, + summary.flows ?? 0, + summary.devices ?? 0, + summary.download_speed ?? 0, + summary.upload_speed ?? 0, + summary.flow_speed ?? 0 + ); } // ─── QUERY FUNCTIONS ────────────────────────────────────────────────────────── diff --git a/backend/netify.js b/backend/netify.js index 3b29aae..6b575ac 100644 --- a/backend/netify.js +++ b/backend/netify.js @@ -17,6 +17,16 @@ function headersSite(useApiKey = false) { } function getMockDataForPath(path, params = {}) { + if (path.includes('/data/stats/summary')) { + return { + download: 218648938113, + upload: 33551656812, + flow_hourly_count: 2970165, + download_speed: 2530000, + upload_speed: 390000, + flow_speed: 123756 + }; + } if (path.includes('/data/stats/top/application/download')) { return [ { application: { id: 1, label: 'YouTube', tag: 'video', category: { label: 'Streaming' } }, download: Math.floor(Math.random() * 50000000) + 10000000, flows: Math.floor(Math.random() * 200) + 50 }, @@ -575,7 +585,20 @@ async function fetchTopDomains(interval = 1440, limit = 50) { // ─── BANDWIDTH SUMMARY untuk timeline ───────────────────────────────────────── async function fetchBandwidthSummary(interval = 30) { - // Pakai interval PENDEK (30 menit) untuk timeline supaya ada variasi naik turun + const rawSummary = await netifyFetch('/data/stats/summary', { filter_interval: interval }); + if (rawSummary) { + return { + download: rawSummary.download ?? 0, + upload: rawSummary.upload ?? 0, + flows: rawSummary.flow_hourly_count ?? 0, + download_speed: rawSummary.download_speed ?? 0, + upload_speed: rawSummary.upload_speed ?? 0, + flow_speed: rawSummary.flow_speed ?? 0, + devices: 0, + }; + } + + // Fallback to old way (aggregate top 100 application stats) const [dlData, ulData] = await Promise.all([ netifyFetch('/data/stats/top/application/download', { filter_interval: interval, settings_limit: 100 }), netifyFetch('/data/stats/top/application/upload', { filter_interval: interval, settings_limit: 100 }), @@ -585,7 +608,15 @@ async function fetchBandwidthSummary(interval = 30) { const upload = (ulData ?? []).reduce((s, r) => s + (r.upload ?? 0), 0); const flows = (dlData ?? []).reduce((s, r) => s + (r.flows ?? 0), 0); - return { download, upload, flows, devices: dlData?.length ?? 0 }; + return { + download, + upload, + flows, + download_speed: 0, + upload_speed: 0, + flow_speed: 0, + devices: dlData?.length ?? 0 + }; } // ─── THREATS — gunakan flows dengan filter anomali sebagai fallback ──────────── diff --git a/backend/routes/dashboard.js b/backend/routes/dashboard.js index a94de18..864cc9c 100644 --- a/backend/routes/dashboard.js +++ b/backend/routes/dashboard.js @@ -6,10 +6,35 @@ const { runPoll } = require('../scheduler'); // GET /api/dashboard/summary — kartu ringkasan (top of page) router.get('/summary', (req, res) => { - const stats = db.getStats(req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null); - const timeline = db.getBandwidthTimeline(1, req.user?.site_uuid, req.user?.role === 'AGENT_VIEWER' ? req.user?.agent_uuid : null); + const isAgent = req.user?.role === 'AGENT_VIEWER'; + const stats = db.getStats(req.user?.site_uuid, isAgent ? req.user?.agent_uuid : null); + const timeline = db.getBandwidthTimeline(1, req.user?.site_uuid, isAgent ? req.user?.agent_uuid : null); const latest = timeline[0] ?? {}; + let bandwidth_down = latest.total_download ?? 0; + let bandwidth_up = latest.total_upload ?? 0; + let active_flows = stats.activeFlows; + let download_speed = latest.download_speed ?? 0; + let upload_speed = latest.upload_speed ?? 0; + let flow_speed = latest.flow_speed ?? 0; + + if (isAgent) { + const siteTimeline = db.getBandwidthTimeline(1, req.user?.site_uuid, null); + const siteLatest = siteTimeline[0] ?? {}; + const siteStats = db.getStats(req.user?.site_uuid, null); + + const download_ratio = siteLatest.total_download > 0 ? (bandwidth_down / siteLatest.total_download) : 0; + const upload_ratio = siteLatest.total_upload > 0 ? (bandwidth_up / siteLatest.total_upload) : 0; + const flow_ratio = siteStats.activeFlows > 0 ? (stats.activeFlows / siteStats.activeFlows) : download_ratio; + + active_flows = Math.round((siteLatest.total_flows ?? 0) * flow_ratio); + download_speed = Math.round((siteLatest.download_speed ?? 0) * download_ratio); + upload_speed = Math.round((siteLatest.upload_speed ?? 0) * upload_ratio); + flow_speed = Math.round((siteLatest.flow_speed ?? 0) * flow_ratio); + } else { + active_flows = latest.total_flows ?? stats.activeFlows; + } + res.json({ ok: true, data: { @@ -17,9 +42,12 @@ router.get('/summary', (req, res) => { total_threats: stats.totalThreats, total_events: stats.totalEvents, last_fetch: stats.lastFetch, - bandwidth_down: latest.total_download ?? 0, - bandwidth_up: latest.total_upload ?? 0, - active_flows: stats.activeFlows, + bandwidth_down, + bandwidth_up, + active_flows, + download_speed, + upload_speed, + flow_speed, } }); }); diff --git a/backend/tests/agent_summary_correlation.test.js b/backend/tests/agent_summary_correlation.test.js new file mode 100644 index 0000000..d37ad30 --- /dev/null +++ b/backend/tests/agent_summary_correlation.test.js @@ -0,0 +1,85 @@ +const test = require('node:test'); +const assert = require('node:assert'); + +// Require the database module to mock it before loading the router +const db = require('../database'); + +const originalGetStats = db.getStats; +const originalGetTimeline = db.getBandwidthTimeline; + +test.afterEach(() => { + db.getStats = originalGetStats; + db.getBandwidthTimeline = originalGetTimeline; +}); + +test('GET /summary scales metrics proportionally for AGENT_VIEWER based on database traffic share', async () => { + // Mock db.getStats + db.getStats = (siteUuid, agentUuid) => { + if (agentUuid === '8A-V3-PB-85') { + return { totalDevices: 5, totalThreats: 1, totalEvents: 10, lastFetch: '2026-07-02T01:00:00Z', activeFlows: 50 }; + } + // Site wide + return { totalDevices: 20, totalThreats: 2, totalEvents: 50, lastFetch: '2026-07-02T01:00:00Z', activeFlows: 200 }; + }; + + // Mock db.getBandwidthTimeline + db.getBandwidthTimeline = (points, siteUuid, agentUuid) => { + if (agentUuid === '8A-V3-PB-85') { + return [{ fetched_at: '2026-07-02T01:00:00Z', total_download: 50000000, total_upload: 10000000 }]; + } + // Site wide + return [{ + fetched_at: '2026-07-02T01:00:00Z', + total_download: 200000000, + total_upload: 40000000, + total_flows: 100000, + download_speed: 8000000, + upload_speed: 2000000, + flow_speed: 120000 + }]; + }; + + // Load the dashboard router + const router = require('../routes/dashboard'); + const layer = router.stack.find(l => l.route && l.route.path === '/summary'); + assert.ok(layer, 'Dashboard router should have a /summary route'); + const handler = layer.route.stack[0].handle; + + // Mock req and res for AGENT_VIEWER + const req = { + user: { + role: 'AGENT_VIEWER', + agent_uuid: '8A-V3-PB-85', + site_uuid: '6681452d_9cae_4ff4_8ae8_0d504774265e' + } + }; + + let responseData = null; + const res = { + json(payload) { + responseData = payload; + } + }; + + // Execute the route handler + await handler(req, res); + + // Assert proportional scaling is correct + assert.ok(responseData); + assert.strictEqual(responseData.ok, true); + + const data = responseData.data; + assert.ok(data); + // Unaltered metrics + assert.strictEqual(data.total_devices, 5); + assert.strictEqual(data.total_threats, 1); + assert.strictEqual(data.total_events, 10); + assert.strictEqual(data.bandwidth_down, 50000000); + assert.strictEqual(data.bandwidth_up, 10000000); + + // Scaled metrics (agent has 25% share of downloads, uploads, and flows) + assert.strictEqual(data.active_flows, 25000); + assert.strictEqual(data.download_speed, 2000000); + assert.strictEqual(data.upload_speed, 500000); + assert.strictEqual(data.flow_speed, 30000); +}); diff --git a/backend/tests/summary_real_totals.test.js b/backend/tests/summary_real_totals.test.js new file mode 100644 index 0000000..107ab20 --- /dev/null +++ b/backend/tests/summary_real_totals.test.js @@ -0,0 +1,55 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const axios = require('axios'); + +// Save original axios get method +const originalGet = axios.get; + +function mockAxiosGet(mockFn) { + axios.get = mockFn; +} + +function restoreAxiosGet() { + axios.get = originalGet; +} + +test.afterEach(() => { + restoreAxiosGet(); +}); + +test('fetchBandwidthSummary retrieves /data/stats/summary with correct filter_interval and outputs all metrics', async () => { + const netify = require('../netify'); + + mockAxiosGet(async (url, config) => { + if (url.endsWith('/data/stats/summary')) { + // Verify query params + assert.strictEqual(config.params?.filter_interval, 1440); + + return { + data: { + status_code: 0, + status_message: 'Success.', + data: { + download: 218648938113, + upload: 33551656812, + flow_hourly_count: 2970165, + download_speed: 2530000, + upload_speed: 390000, + flow_speed: 123756 + } + } + }; + } + throw new Error(`Unexpected mock URL: ${url}`); + }); + + const summary = await netify.fetchBandwidthSummary(1440); + + assert.ok(summary); + assert.strictEqual(summary.download, 218648938113); + assert.strictEqual(summary.upload, 33551656812); + assert.strictEqual(summary.flows, 2970165); + assert.strictEqual(summary.download_speed, 2530000); + assert.strictEqual(summary.upload_speed, 390000); + assert.strictEqual(summary.flow_speed, 123756); +}); diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index f42d28c..733b2fe 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -42,6 +42,9 @@ export default function SummaryPage() { const isLoading = summary.isLoading || timeline.isLoading || topApps.isLoading || topProtocols.isLoading; + const downloadSpeedBits = summary.data?.download_speed ? summary.data.download_speed * 8 : 0; + const uploadSpeedBits = summary.data?.upload_speed ? summary.data.upload_speed * 8 : 0; + return (
@@ -69,6 +72,16 @@ export default function SummaryPage() {
{summary.data ? fmtBytes(summary.data.bandwidth_down) : "0 B"}
+ {summary.data?.download_speed !== undefined && summary.data.download_speed > 0 && ( +
+ + Speed: {downloadSpeedBits >= 1e6 + ? `${(downloadSpeedBits / 1e6).toFixed(1)} Mbps` + : downloadSpeedBits >= 1e3 + ? `${(downloadSpeedBits / 1e3).toFixed(1)} Kbps` + : `${downloadSpeedBits} bps`} +
+ )} @@ -86,6 +99,16 @@ export default function SummaryPage() {
{summary.data ? fmtBytes(summary.data.bandwidth_up) : "0 B"}
+ {summary.data?.upload_speed !== undefined && summary.data.upload_speed > 0 && ( +
+ + Speed: {uploadSpeedBits >= 1e6 + ? `${(uploadSpeedBits / 1e6).toFixed(1)} Mbps` + : uploadSpeedBits >= 1e3 + ? `${(uploadSpeedBits / 1e3).toFixed(1)} Kbps` + : `${uploadSpeedBits} bps`} +
+ )} @@ -120,6 +143,12 @@ export default function SummaryPage() {
{summary.data?.active_flows?.toLocaleString() || "0"}
+ {summary.data?.flow_speed !== undefined && summary.data.flow_speed > 0 && ( +
+ + Speed: {summary.data.flow_speed.toLocaleString()} /hr +
+ )} diff --git a/src/lib/api.ts b/src/lib/api.ts index 2329cdc..0c8fdbc 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -96,6 +96,9 @@ export interface DashboardSummary { bandwidth_down: number; bandwidth_up: number; active_flows: number; + download_speed?: number; + upload_speed?: number; + flow_speed?: number; } export interface TimelinePoint {