feat: NetFlow/IPFIX traffic analytics — per-client usage, app breakdown, built-in collector (v0.13.0-beta)
Adds Meraki-style traffic analytics powered by MikroTik Traffic Flow: Backend - Built-in NetFlow v9 + IPFIX collector (UDP 2055) with hand-rolled decoder, template caching, enterprise-field and IPv6 support - Flow attribution: client IP → MAC via the clients table, upload/download direction, NAT-duplicate records dropped - App classification by protocol/port (~25 categories: HTTPS, QUIC, DNS, SSH, Email, WireGuard, ...) - Deduplication: per-client best-exporter-per-window so flows crossing two managed routers are never double-counted - NAT-tolerant ingest: flows from sources matching no managed device (NAT / Tailscale subnet routers in path) are accepted as per-source unidentified exporters instead of silently dropped (netflow_accept_unknown setting) - Storage: InfluxDB client_traffic time-series (bounded cardinality via top-N fold) + Postgres client_traffic_daily rollups; daily retention purge - One-toggle device config push via RouterOS API (/ip traffic-flow + target) - New /api/traffic/* analytics endpoints + /api/network-services/netflow - docker-compose: NETFLOW_PORT (default 2055/udp) published on the backend Frontend - New Traffic page: fleet up/down chart, top talkers with client click-through, app-category donut, 1h–30d ranges - New NetFlow config page: collector settings, live ingest stats, per-device export toggles with health badges, NAT-source banner - Client detail: App Traffic card (auto-hides without data) - Clients page: sortable "Data (today)" column - Settings: NetFlow retention controls Includes 17 unit tests (decoder fixtures, dedup, attribution, classifier); verified end-to-end against live RouterOS devices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
367034c2d6
commit
b841eb6e7b
@@ -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.
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
@@ -144,6 +144,17 @@ Each service supports multi-device management with conflict detection:
|
||||
| **NTP** | Server (broadcast/manycast), client (unicast/multicast), sync status |
|
||||
| **WireGuard** | Interface management, peer configuration, public key display, RX/TX stats |
|
||||
| **Syslog** | Logging actions (remote/memory/disk/echo) and routing rules; single-device or push-to-all with per-entry coverage; enable/disable rules |
|
||||
| **NetFlow** | One-toggle Traffic Flow (NetFlow v9/IPFIX) export per device, pointed at the built-in collector; live export status per device |
|
||||
|
||||
### Traffic Analytics (NetFlow)
|
||||
- **Built-in NetFlow/IPFIX collector** — receives Traffic Flow exports from your routers on UDP 2055 (no external tools needed); decoder supports both NetFlow v9 and IPFIX
|
||||
- **Per-client usage accounting** — flows are attributed to known clients (IP → MAC) so every client shows real upload/download totals; "Data (today)" column on the Clients page and an App Traffic card on each client's detail page
|
||||
- **Application breakdown** — flows are classified by protocol and port into readable categories (HTTPS, QUIC, DNS, SSH, Email, WireGuard, …) with fleet-wide and per-client views
|
||||
- **Top talkers** — ranked client bandwidth usage over 1h/24h/7d/30d ranges on the dedicated Traffic page
|
||||
- **Automatic deduplication** — a flow crossing two managed routers is exported twice; the collector keeps only the best exporter per client each window so totals are never double-counted
|
||||
- **NAT-tolerant ingest** — when routers export from behind NAT or a VPN/Tailscale subnet router, packets arrive from an address that matches no managed device; the collector accepts these as per-source "unidentified" exporters (configurable) so flows are still attributed to clients instead of silently dropped, and the NetFlow page shows a banner naming the NAT'd source. Per-device export status/flow counters require un-NAT'd sources — exempt `udp/2055` from masquerade on the gateway to restore device identity (and exact cross-device dedup)
|
||||
- **Fully UI-configurable** — enable the collector, set its address/port/version in the NetFlow page, then toggle export per device; the platform pushes `/ip traffic-flow` configuration to each router via the RouterOS API
|
||||
- Configurable retention for detailed time-series (default 30 days) and daily rollups (default 365 days)
|
||||
|
||||
### Network Topology
|
||||
- **LLDP-authoritative** — LLDP links are treated as ground truth; CDP/MNDP links to the same neighbor are automatically suppressed, eliminating spurious "Shared Segment" nodes
|
||||
|
||||
Generated
+12
-2
@@ -1,14 +1,15 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.11.7-beta",
|
||||
"version": "0.12.3-beta",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.11.7-beta",
|
||||
"version": "0.12.3-beta",
|
||||
"dependencies": {
|
||||
"@influxdata/influxdb-client": "^1.33.2",
|
||||
"@influxdata/influxdb-client-apis": "^1.35.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.1.1",
|
||||
"compression": "^1.7.4",
|
||||
@@ -992,6 +993,15 @@
|
||||
"integrity": "sha512-woWMi8PDpPQpvTsRaUw4Ig+nOGS/CWwAwS66Fa1Vr/EkW+NEwxI8YfPBsdBMn33jK2Y86/qMiiuX/ROHIkJLTw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@influxdata/influxdb-client-apis": {
|
||||
"version": "1.35.0",
|
||||
"resolved": "https://registry.npmjs.org/@influxdata/influxdb-client-apis/-/influxdb-client-apis-1.35.0.tgz",
|
||||
"integrity": "sha512-+7h6smVPHYBge2rNKgYh/5k+SriYvPMsoJDfbUiQt1vJtpWnElwgBDLDl7Cr6d9XPC+FCI9GP4GQEMv7y8WxdA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"@influxdata/influxdb-client": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@ioredis/commands": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.5.1.tgz",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-backend",
|
||||
"version": "0.12.3-beta",
|
||||
"version": "0.13.0-beta",
|
||||
"description": "Mikrotik Network Management Platform - Backend API",
|
||||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@influxdata/influxdb-client": "^1.33.2",
|
||||
"@influxdata/influxdb-client-apis": "^1.35.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"bullmq": "^5.1.1",
|
||||
"compression": "^1.7.4",
|
||||
|
||||
@@ -388,6 +388,19 @@ CREATE TABLE IF NOT EXISTS config_templates (
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_secret VARCHAR(64);
|
||||
ALTER TABLE users ADD COLUMN IF NOT EXISTS totp_enabled BOOLEAN NOT NULL DEFAULT false;
|
||||
|
||||
-- Per-client daily traffic rollups from the NetFlow collector. mac_address
|
||||
-- also holds the pseudo-clients 'unknown' (unmapped local IPs) and 'other'
|
||||
-- (clients folded by the top-N cardinality cap).
|
||||
CREATE TABLE IF NOT EXISTS client_traffic_daily (
|
||||
mac_address VARCHAR(17) NOT NULL,
|
||||
day DATE NOT NULL,
|
||||
upload_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
download_bytes BIGINT NOT NULL DEFAULT 0,
|
||||
app_breakdown JSONB NOT NULL DEFAULT '{}',
|
||||
PRIMARY KEY (mac_address, day)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_client_traffic_daily_day ON client_traffic_daily(day DESC);
|
||||
|
||||
-- Wireless security profiles (WPA/WPA2/WPA3 config)
|
||||
CREATE TABLE IF NOT EXISTS wireless_security_profiles (
|
||||
id SERIAL PRIMARY KEY,
|
||||
@@ -424,6 +437,16 @@ const DEFAULT_SETTINGS = [
|
||||
{ key: 'config_snapshot_enabled', value: true },
|
||||
{ key: 'config_snapshot_interval_min', value: 60 },
|
||||
{ key: 'config_snapshot_retention', value: 30 },
|
||||
{ key: 'netflow_enabled', value: false },
|
||||
{ key: 'netflow_collector_address', value: '' },
|
||||
{ key: 'netflow_collector_port', value: 2055 },
|
||||
{ key: 'netflow_version', value: '9' },
|
||||
{ key: 'netflow_active_timeout', value: '1m' },
|
||||
{ key: 'netflow_inactive_timeout', value: '15s' },
|
||||
{ key: 'netflow_topn_clients', value: 50 },
|
||||
{ key: 'netflow_accept_unknown', value: true },
|
||||
{ key: 'netflow_retention_days', value: 30 },
|
||||
{ key: 'netflow_daily_retention_days', value: 365 },
|
||||
];
|
||||
|
||||
export async function runMigrations(): Promise<void> {
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
startBulkAddWorker,
|
||||
stopBulkAddWorker,
|
||||
} from './services/DeviceBulkAddWorker';
|
||||
import { netflowCollector } from './services/netflow/NetflowCollector';
|
||||
import { verifyToken } from './middleware/auth';
|
||||
import { decrypt } from './utils/crypto';
|
||||
import { corsMiddlewareOptions, socketIoCorsOptions } from './utils/corsOrigins';
|
||||
@@ -47,6 +48,7 @@ import configTemplatesRoutes from './routes/configTemplates';
|
||||
import configHistoryRoutes from './routes/configHistory';
|
||||
import wirelessRoutes from './routes/wireless';
|
||||
import networkServicesRoutes from './routes/networkServices';
|
||||
import trafficAnalyticsRoutes from './routes/trafficAnalytics';
|
||||
import credentialPresetsRoutes from './routes/credentialPresets';
|
||||
import systemRoutes from './routes/system';
|
||||
import { auditMiddleware } from './middleware/auditMiddleware';
|
||||
@@ -214,6 +216,7 @@ app.use('/api/routers', routersRoutes);
|
||||
app.use('/api/alerts', alertsRoutes);
|
||||
app.use('/api/wireless', wirelessRoutes);
|
||||
app.use('/api/network-services', networkServicesRoutes);
|
||||
app.use('/api/traffic', trafficAnalyticsRoutes);
|
||||
app.use('/api/credential-presets', credentialPresetsRoutes);
|
||||
app.use('/api/audit-log', auditLogRoutes);
|
||||
app.use('/api/tags', tagsRoutes);
|
||||
@@ -262,6 +265,9 @@ async function start(): Promise<void> {
|
||||
await pollerService.start();
|
||||
await startBulkAddWorker();
|
||||
|
||||
// NetFlow/IPFIX collector (binds its UDP socket only when netflow_enabled)
|
||||
await netflowCollector.start();
|
||||
|
||||
// Start HTTP server
|
||||
httpServer.listen(PORT, '0.0.0.0', () => {
|
||||
console.log(`✓ Mikrotik Manager backend running on port ${PORT}`);
|
||||
@@ -271,6 +277,7 @@ async function start(): Promise<void> {
|
||||
const shutdown = async () => {
|
||||
console.log('Shutting down...');
|
||||
await stopBulkAddWorker();
|
||||
await netflowCollector.stop();
|
||||
await pollerService.stop();
|
||||
await redis.quit().catch(() => {});
|
||||
await pool.end();
|
||||
|
||||
@@ -42,7 +42,10 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
// Deduplicate by MAC address across devices: prefer active rows, then most recently seen.
|
||||
// The inner DISTINCT ON picks the best row per MAC; the outer query applies final sort + pagination.
|
||||
const sql = `
|
||||
SELECT * FROM (
|
||||
SELECT deduped.*,
|
||||
-- float8 so node-pg returns a number (bigint would arrive as a string)
|
||||
(COALESCE(ctd.upload_bytes, 0) + COALESCE(ctd.download_bytes, 0))::float8 AS traffic_today_bytes
|
||||
FROM (
|
||||
SELECT DISTINCT ON (c.mac_address)
|
||||
c.*, d.name as device_name,
|
||||
wi.ssid as ssid
|
||||
@@ -53,6 +56,8 @@ router.get('/', async (req: Request, res: Response) => {
|
||||
${where}
|
||||
ORDER BY c.mac_address, (c.client_type = 'wireless') DESC, c.active DESC, c.last_seen DESC NULLS LAST
|
||||
) 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
|
||||
LIMIT $${idx++} OFFSET $${idx++}
|
||||
`;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth, requireWrite } from '../middleware/auth';
|
||||
import { DeviceCollector, DeviceRow } from '../services/mikrotik/DeviceCollector';
|
||||
import { netflowCollector } from '../services/netflow/NetflowCollector';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
@@ -488,4 +489,166 @@ router.delete('/syslog/rule/:id', requireWrite, async (req: Request, res: Respon
|
||||
});
|
||||
});
|
||||
|
||||
// ─── NetFlow / Traffic Flow ───────────────────────────────────────────────────
|
||||
|
||||
async function getNetflowAppSettings(): Promise<{
|
||||
address: string; port: number; version: string; activeTimeout: string; inactiveTimeout: string;
|
||||
}> {
|
||||
const rows = await query<{ key: string; value: unknown }>(
|
||||
`SELECT key, value FROM app_settings
|
||||
WHERE key IN ('netflow_collector_address', 'netflow_collector_port', 'netflow_version',
|
||||
'netflow_active_timeout', 'netflow_inactive_timeout')`
|
||||
);
|
||||
const map: Record<string, unknown> = {};
|
||||
for (const row of rows) map[row.key] = row.value;
|
||||
return {
|
||||
address: String(map['netflow_collector_address'] || ''),
|
||||
port: Number(map['netflow_collector_port']) || 2055,
|
||||
version: String(map['netflow_version'] || '9'),
|
||||
activeTimeout: String(map['netflow_active_timeout'] || '1m'),
|
||||
inactiveTimeout: String(map['netflow_inactive_timeout'] || '15s'),
|
||||
};
|
||||
}
|
||||
|
||||
function targetMatchesCollector(
|
||||
targets: Record<string, string>[],
|
||||
collector: { address: string; port: number }
|
||||
): boolean {
|
||||
return targets.some(
|
||||
(t) => t['dst-address'] === collector.address && Number(t['port']) === collector.port && t['disabled'] !== 'true'
|
||||
);
|
||||
}
|
||||
|
||||
// GET /api/network-services/netflow/fleet — traffic-flow state for all online devices
|
||||
router.get('/netflow/fleet', async (_req: Request, res: Response) => {
|
||||
const settings = await getNetflowAppSettings();
|
||||
const stats = netflowCollector.getStats();
|
||||
const statsByDevice = new Map(stats.exporters.map((e) => [e.deviceId, e]));
|
||||
const devices = await query<DeviceRow>(`SELECT * FROM devices WHERE status = 'online' ORDER BY name`);
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
devices.map(async (device: DeviceRow) => {
|
||||
const collector = new DeviceCollector(device);
|
||||
try {
|
||||
await collector.connect();
|
||||
const [tfSettings, targets] = await Promise.all([
|
||||
collector.getTrafficFlowSettings(),
|
||||
collector.getTrafficFlowTargets(),
|
||||
]);
|
||||
const exporterStats = statsByDevice.get(device.id);
|
||||
return {
|
||||
id: device.id,
|
||||
name: device.name,
|
||||
ip_address: device.ip_address,
|
||||
enabled: tfSettings?.['enabled'] === 'true' || tfSettings?.['enabled'] === 'yes',
|
||||
interfaces: tfSettings?.['interfaces'] || '',
|
||||
targets: targets.map((t) => ({
|
||||
id: t['.id'],
|
||||
dst_address: t['dst-address'],
|
||||
port: Number(t['port']) || 0,
|
||||
version: t['version'] || '',
|
||||
disabled: t['disabled'] === 'true',
|
||||
})),
|
||||
target_matches_collector: targetMatchesCollector(targets, settings),
|
||||
flows_received: exporterStats?.flows || 0,
|
||||
last_flow_at: exporterStats?.lastSeen || null,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
id: device.id, name: device.name, ip_address: device.ip_address,
|
||||
enabled: null, interfaces: '', targets: [], target_matches_collector: false,
|
||||
flows_received: 0, last_flow_at: null, error: (err as Error).message,
|
||||
};
|
||||
} finally {
|
||||
collector.disconnect();
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
res.json({
|
||||
collector: { ...settings, listening: stats.listening },
|
||||
devices: results.map((r) => (r.status === 'fulfilled' ? r.value : { error: (r.reason as Error).message })),
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/network-services/netflow?deviceId=X — single device traffic-flow state
|
||||
router.get('/netflow', async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const settings = await getNetflowAppSettings();
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const [tfSettings, targets] = await Promise.all([
|
||||
collector.getTrafficFlowSettings(),
|
||||
collector.getTrafficFlowTargets(),
|
||||
]);
|
||||
res.json({
|
||||
settings: tfSettings,
|
||||
targets,
|
||||
target_matches_collector: targetMatchesCollector(targets, settings),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// PUT /api/network-services/netflow?deviceId=X — body { enabled: boolean }
|
||||
// Enable: upsert our export target (matched by dst-address + port) and turn
|
||||
// traffic-flow on. Disable: remove our target; only turn traffic-flow off if
|
||||
// no other (user-managed) targets remain.
|
||||
router.put('/netflow', requireWrite, async (req: Request, res: Response) => {
|
||||
const deviceId = deviceIdParam(req, res); if (!deviceId) return;
|
||||
const { enabled } = req.body;
|
||||
if (typeof enabled !== 'boolean') return res.status(400).json({ error: 'enabled (boolean) is required' });
|
||||
|
||||
const settings = await getNetflowAppSettings();
|
||||
if (enabled && !settings.address) {
|
||||
return res.status(400).json({ error: 'Set the collector address in NetFlow settings before enabling devices' });
|
||||
}
|
||||
|
||||
await withDevice(deviceId, res, async (collector) => {
|
||||
const targets = await collector.getTrafficFlowTargets();
|
||||
const ours = targets.find(
|
||||
(t) => t['dst-address'] === settings.address && Number(t['port']) === settings.port
|
||||
);
|
||||
|
||||
if (enabled) {
|
||||
const targetParams: Record<string, string> = {
|
||||
'dst-address': settings.address,
|
||||
port: String(settings.port),
|
||||
version: settings.version === 'ipfix' ? 'ipfix' : '9',
|
||||
};
|
||||
if (settings.version !== 'ipfix') {
|
||||
// Re-send templates frequently so the collector can decode data
|
||||
// promptly after a backend restart clears its template cache.
|
||||
targetParams['v9-template-refresh'] = '20';
|
||||
targetParams['v9-template-timeout'] = '1m';
|
||||
}
|
||||
if (ours) {
|
||||
await collector.updateTrafficFlowTarget(ours['.id'], targetParams);
|
||||
} else {
|
||||
await collector.addTrafficFlowTarget(targetParams);
|
||||
}
|
||||
await collector.setTrafficFlowSettings({
|
||||
enabled: 'yes',
|
||||
interfaces: 'all',
|
||||
'active-flow-timeout': settings.activeTimeout,
|
||||
'inactive-flow-timeout': settings.inactiveTimeout,
|
||||
});
|
||||
} else {
|
||||
if (ours) await collector.removeTrafficFlowTarget(ours['.id']);
|
||||
const remaining = (await collector.getTrafficFlowTargets()).filter((t) => t['.id'] !== ours?.['.id']);
|
||||
if (remaining.length === 0) {
|
||||
await collector.setTrafficFlowSettings({ enabled: 'no' });
|
||||
}
|
||||
}
|
||||
|
||||
const [tfSettings, updatedTargets] = await Promise.all([
|
||||
collector.getTrafficFlowSettings(),
|
||||
collector.getTrafficFlowTargets(),
|
||||
]);
|
||||
res.json({
|
||||
settings: tfSettings,
|
||||
targets: updatedTargets,
|
||||
target_matches_collector: targetMatchesCollector(updatedTargets, settings),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Router, Request, Response } from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { query, queryOne } from '../config/database';
|
||||
import { requireAuth, requireAdmin } from '../middleware/auth';
|
||||
import { netflowCollector } from '../services/netflow/NetflowCollector';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
@@ -26,6 +27,11 @@ router.put('/', requireAdmin, async (req: Request, res: Response) => {
|
||||
[key, JSON.stringify(value)]
|
||||
);
|
||||
}
|
||||
// Apply NetFlow listener changes immediately instead of waiting for the
|
||||
// collector's periodic settings reconcile.
|
||||
if (Object.keys(updates).some((k) => k.startsWith('netflow_'))) {
|
||||
netflowCollector.reconcile().catch(() => {});
|
||||
}
|
||||
res.json({ message: 'Settings updated' });
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { getQueryApi, bucket } from '../config/influxdb';
|
||||
import { query } from '../config/database';
|
||||
import { requireAuth } from '../middleware/auth';
|
||||
import { netflowCollector } from '../services/netflow/NetflowCollector';
|
||||
|
||||
const router = Router();
|
||||
router.use(requireAuth);
|
||||
|
||||
function rangeToFlux(range: string): string {
|
||||
const allowed = ['1h', '3h', '6h', '12h', '24h', '7d', '30d'];
|
||||
return allowed.includes(range) ? range : '24h';
|
||||
}
|
||||
|
||||
// Aggregation window sized to the range so charts stay readable (points are
|
||||
// written every 60s by the collector flush).
|
||||
function windowForRange(range: string): string {
|
||||
const map: Record<string, string> = {
|
||||
'1h': '1m', '3h': '5m', '6h': '5m', '12h': '10m',
|
||||
'24h': '15m', '7d': '1h', '30d': '6h',
|
||||
};
|
||||
return map[range] || '15m';
|
||||
}
|
||||
|
||||
// MACs are used inside Flux queries — restrict to safe charsets (real MACs
|
||||
// plus the 'unknown'/'other' pseudo-clients).
|
||||
function sanitizeMac(raw: string): string | null {
|
||||
const mac = raw.toLowerCase();
|
||||
if (mac === 'unknown' || mac === 'other') return mac;
|
||||
return /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/.test(mac) ? mac : null;
|
||||
}
|
||||
|
||||
// GET /api/traffic/status — collector state for the config page
|
||||
router.get('/status', async (_req: Request, res: Response) => {
|
||||
res.json(netflowCollector.getStats());
|
||||
});
|
||||
|
||||
// GET /api/traffic/timeseries?range=24h — fleet-wide upload/download over time
|
||||
router.get('/timeseries', async (req: Request, res: Response) => {
|
||||
const range = rangeToFlux(String(req.query.range || '24h'));
|
||||
const every = windowForRange(range);
|
||||
const queryApi = getQueryApi();
|
||||
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_traffic")
|
||||
|> filter(fn: (r) => r._field == "bytes")
|
||||
|> group(columns: ["direction"])
|
||||
|> aggregateWindow(every: ${every}, fn: sum, createEmpty: false)
|
||||
`;
|
||||
|
||||
const pivoted: Record<string, { time: string; upload: number; download: number }> = {};
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const direction = tableMeta.get(row, 'direction') as string;
|
||||
const value = Number(tableMeta.get(row, '_value')) || 0;
|
||||
if (!pivoted[time]) pivoted[time] = { time, upload: 0, download: 0 };
|
||||
if (direction === 'upload') pivoted[time].upload += value;
|
||||
else if (direction === 'download') pivoted[time].download += value;
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
}
|
||||
|
||||
res.json(Object.values(pivoted).sort((a, b) => a.time.localeCompare(b.time)));
|
||||
});
|
||||
|
||||
// GET /api/traffic/top-clients?range=24h&limit=10 — top talkers with client names
|
||||
router.get('/top-clients', async (req: Request, res: Response) => {
|
||||
const range = rangeToFlux(String(req.query.range || '24h'));
|
||||
const limit = Math.min(parseInt(String(req.query.limit || '10'), 10) || 10, 50);
|
||||
const queryApi = getQueryApi();
|
||||
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_traffic")
|
||||
|> filter(fn: (r) => r._field == "bytes")
|
||||
|> group(columns: ["mac", "direction"])
|
||||
|> sum()
|
||||
`;
|
||||
|
||||
const perMac = new Map<string, { upload: number; download: number }>();
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
const mac = tableMeta.get(row, 'mac') as string;
|
||||
const direction = tableMeta.get(row, 'direction') as string;
|
||||
const value = Number(tableMeta.get(row, '_value')) || 0;
|
||||
let entry = perMac.get(mac);
|
||||
if (!entry) {
|
||||
entry = { upload: 0, download: 0 };
|
||||
perMac.set(mac, entry);
|
||||
}
|
||||
if (direction === 'upload') entry.upload += value;
|
||||
else entry.download += value;
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
}
|
||||
|
||||
const ranked = Array.from(perMac.entries())
|
||||
.map(([mac, e]) => ({ mac, upload_bytes: e.upload, download_bytes: e.download, total_bytes: e.upload + e.download }))
|
||||
.sort((a, b) => b.total_bytes - a.total_bytes)
|
||||
.slice(0, limit);
|
||||
|
||||
// Enrich real MACs with client identity from Postgres
|
||||
const realMacs = ranked.map((r) => r.mac).filter((m) => m !== 'unknown' && m !== 'other');
|
||||
const names = new Map<string, { hostname: string | null; custom_name: string | null; vendor: string | null; ip_address: string | null }>();
|
||||
if (realMacs.length > 0) {
|
||||
const rows = await query<{ mac_address: string; hostname: string | null; custom_name: string | null; vendor: string | null; ip_address: string | null }>(
|
||||
`SELECT DISTINCT ON (mac_address) mac_address, hostname, custom_name, vendor, ip_address
|
||||
FROM clients WHERE LOWER(mac_address) = ANY($1)
|
||||
ORDER BY mac_address, last_seen DESC NULLS LAST`,
|
||||
[realMacs]
|
||||
);
|
||||
for (const row of rows) names.set(row.mac_address.toLowerCase(), row);
|
||||
}
|
||||
|
||||
res.json(
|
||||
ranked.map((r) => {
|
||||
const info = names.get(r.mac);
|
||||
return {
|
||||
...r,
|
||||
hostname: info?.hostname || null,
|
||||
custom_name: info?.custom_name || null,
|
||||
vendor: info?.vendor || null,
|
||||
ip_address: info?.ip_address || null,
|
||||
};
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// GET /api/traffic/apps?range=24h&mac=xx:xx:.. — app-category byte totals
|
||||
// (fleet-wide, or for a single client when mac is given)
|
||||
router.get('/apps', async (req: Request, res: Response) => {
|
||||
const range = rangeToFlux(String(req.query.range || '24h'));
|
||||
const queryApi = getQueryApi();
|
||||
|
||||
let macFilter = '';
|
||||
if (req.query.mac) {
|
||||
const mac = sanitizeMac(String(req.query.mac));
|
||||
if (!mac) return res.status(400).json({ error: 'Invalid mac' });
|
||||
macFilter = `|> filter(fn: (r) => r.mac == "${mac}")`;
|
||||
}
|
||||
|
||||
const fluxQuery = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_traffic")
|
||||
|> filter(fn: (r) => r._field == "bytes")
|
||||
${macFilter}
|
||||
|> group(columns: ["app"])
|
||||
|> sum()
|
||||
`;
|
||||
|
||||
const apps: { app: string; bytes: number }[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(fluxQuery, (row, tableMeta) => {
|
||||
apps.push({
|
||||
app: tableMeta.get(row, 'app') as string,
|
||||
bytes: Number(tableMeta.get(row, '_value')) || 0,
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
}
|
||||
|
||||
res.json(apps.sort((a, b) => b.bytes - a.bytes));
|
||||
});
|
||||
|
||||
// GET /api/traffic/client/:mac?range=24h — per-client time series + app breakdown
|
||||
router.get('/client/:mac', async (req: Request, res: Response) => {
|
||||
const mac = sanitizeMac(req.params.mac);
|
||||
if (!mac) return res.status(400).json({ error: 'Invalid mac' });
|
||||
const range = rangeToFlux(String(req.query.range || '24h'));
|
||||
const every = windowForRange(range);
|
||||
const queryApi = getQueryApi();
|
||||
|
||||
const seriesFlux = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_traffic")
|
||||
|> filter(fn: (r) => r._field == "bytes")
|
||||
|> filter(fn: (r) => r.mac == "${mac}")
|
||||
|> group(columns: ["direction"])
|
||||
|> aggregateWindow(every: ${every}, fn: sum, createEmpty: false)
|
||||
`;
|
||||
|
||||
const pivoted: Record<string, { time: string; upload: number; download: number }> = {};
|
||||
try {
|
||||
await queryApi.collectRows(seriesFlux, (row, tableMeta) => {
|
||||
const time = tableMeta.get(row, '_time') as string;
|
||||
const direction = tableMeta.get(row, 'direction') as string;
|
||||
const value = Number(tableMeta.get(row, '_value')) || 0;
|
||||
if (!pivoted[time]) pivoted[time] = { time, upload: 0, download: 0 };
|
||||
if (direction === 'upload') pivoted[time].upload += value;
|
||||
else if (direction === 'download') pivoted[time].download += value;
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
}
|
||||
|
||||
const appsFlux = `
|
||||
from(bucket: "${bucket}")
|
||||
|> range(start: -${range})
|
||||
|> filter(fn: (r) => r._measurement == "client_traffic")
|
||||
|> filter(fn: (r) => r._field == "bytes")
|
||||
|> filter(fn: (r) => r.mac == "${mac}")
|
||||
|> group(columns: ["app"])
|
||||
|> sum()
|
||||
`;
|
||||
|
||||
const apps: { app: string; bytes: number }[] = [];
|
||||
try {
|
||||
await queryApi.collectRows(appsFlux, (row, tableMeta) => {
|
||||
apps.push({
|
||||
app: tableMeta.get(row, 'app') as string,
|
||||
bytes: Number(tableMeta.get(row, '_value')) || 0,
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// No data yet
|
||||
}
|
||||
|
||||
res.json({
|
||||
series: Object.values(pivoted).sort((a, b) => a.time.localeCompare(b.time)),
|
||||
apps: apps.sort((a, b) => b.bytes - a.bytes),
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -207,6 +207,17 @@ export class PollerService {
|
||||
);
|
||||
}
|
||||
|
||||
// NetFlow data retention — runs once per day. Purges old client_traffic
|
||||
// points from InfluxDB and old daily rollups from Postgres.
|
||||
const netflowPruneKey = 'task:netflow_retention';
|
||||
const lastNetflowPrune = await this.getTimestamp(netflowPruneKey);
|
||||
if (now - lastNetflowPrune > 86_400_000) {
|
||||
await this.setTimestamp(netflowPruneKey, now, 86_400 + 3_600);
|
||||
this.purgeNetflowData().catch((e) =>
|
||||
console.error('[Poller] NetFlow retention error:', e)
|
||||
);
|
||||
}
|
||||
|
||||
// Stale client pruning — runs once per hour
|
||||
// Deletes inactive client records not seen for longer than retention_clients_days.
|
||||
const pruneKey = 'task:prune_clients';
|
||||
@@ -353,6 +364,48 @@ export class PollerService {
|
||||
}
|
||||
}
|
||||
|
||||
private async purgeNetflowData(): Promise<void> {
|
||||
const rows = await query<{ key: string; value: unknown }>(
|
||||
`SELECT key, value FROM app_settings
|
||||
WHERE key IN ('netflow_retention_days', 'netflow_daily_retention_days')`
|
||||
);
|
||||
const map: Record<string, unknown> = {};
|
||||
for (const row of rows) map[row.key] = row.value;
|
||||
const detailDays = Number(map['netflow_retention_days']) || 30;
|
||||
const dailyDays = Number(map['netflow_daily_retention_days']) || 365;
|
||||
|
||||
// InfluxDB: delete client_traffic points older than the detail retention
|
||||
const { DeleteAPI } = await import('@influxdata/influxdb-client-apis');
|
||||
const { getInfluxClient, org, bucket } = await import('../config/influxdb');
|
||||
const stop = new Date(Date.now() - detailDays * 86_400_000).toISOString();
|
||||
await new DeleteAPI(getInfluxClient())
|
||||
.postDelete({
|
||||
org,
|
||||
bucket,
|
||||
body: {
|
||||
start: '1970-01-01T00:00:00Z',
|
||||
stop,
|
||||
predicate: '_measurement="client_traffic"',
|
||||
},
|
||||
})
|
||||
.catch((e) => console.error('[Poller] NetFlow Influx purge error:', (e as Error).message));
|
||||
|
||||
// Postgres: delete daily rollups older than the rollup retention
|
||||
const result = await query<{ count: string }>(
|
||||
`WITH deleted AS (
|
||||
DELETE FROM client_traffic_daily
|
||||
WHERE day < CURRENT_DATE - ($1 || ' days')::interval
|
||||
RETURNING 1
|
||||
)
|
||||
SELECT COUNT(*) AS count FROM deleted`,
|
||||
[dailyDays]
|
||||
);
|
||||
const count = parseInt(result[0]?.count || '0', 10);
|
||||
if (count > 0) {
|
||||
console.log(`[Poller] NetFlow retention pruned ${count} daily rollup row(s) (> ${dailyDays} days)`);
|
||||
}
|
||||
}
|
||||
|
||||
private async pruneStaleClients(settings: Record<string, unknown>): Promise<void> {
|
||||
// Delete inactive clients not seen for longer than the configured retention period.
|
||||
// Default: 7 days. Preserves any client that was active within the window so
|
||||
|
||||
@@ -2706,4 +2706,31 @@ export class DeviceCollector {
|
||||
async removeWireGuardPeer(id: string): Promise<void> {
|
||||
await this.client.execute('/interface/wireguard/peers/remove', { '.id': id });
|
||||
}
|
||||
|
||||
// Traffic Flow (NetFlow/IPFIX export) ─────────────────────────────────────────
|
||||
|
||||
async getTrafficFlowSettings(): Promise<Record<string, string> | null> {
|
||||
const rows = await this.client.execute('/ip/traffic-flow/print', {}).catch(() => [] as Record<string, string>[]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async setTrafficFlowSettings(params: Record<string, string>): Promise<void> {
|
||||
await this.client.execute('/ip/traffic-flow/set', params);
|
||||
}
|
||||
|
||||
async getTrafficFlowTargets(): Promise<Record<string, string>[]> {
|
||||
return this.client.execute('/ip/traffic-flow/target/print', { detail: '' }).catch(() => [] as Record<string, string>[]);
|
||||
}
|
||||
|
||||
async addTrafficFlowTarget(params: Record<string, string>): Promise<void> {
|
||||
await this.client.execute('/ip/traffic-flow/target/add', params);
|
||||
}
|
||||
|
||||
async updateTrafficFlowTarget(id: string, params: Record<string, string>): Promise<void> {
|
||||
await this.client.execute('/ip/traffic-flow/target/set', { '.id': id, ...params });
|
||||
}
|
||||
|
||||
async removeTrafficFlowTarget(id: string): Promise<void> {
|
||||
await this.client.execute('/ip/traffic-flow/target/remove', { '.id': id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
// In-memory flow aggregation between flushes (60s windows).
|
||||
//
|
||||
// Counters are kept PER EXPORTER per (client, direction, app) so that a flow
|
||||
// crossing two managed routers — and therefore exported twice — can be
|
||||
// deduplicated at drain time: for each client we keep only the exporter that
|
||||
// saw the most bytes in the window (it converges on the gateway, which sees
|
||||
// all of a client's traffic) and discard the rest. Tie-break: lowest exporter
|
||||
// id, for determinism.
|
||||
|
||||
export type Direction = 'upload' | 'download';
|
||||
|
||||
// Pseudo-client keys (not real MACs, but stored in the same mac slot)
|
||||
export const CLIENT_UNKNOWN = 'unknown';
|
||||
export const CLIENT_OTHER = 'other';
|
||||
|
||||
export interface FlowSample {
|
||||
exporterId: number; // managed device id that exported the flow
|
||||
clientKey: string; // client MAC address, or CLIENT_UNKNOWN
|
||||
direction: Direction;
|
||||
app: string;
|
||||
bytes: number;
|
||||
packets: number;
|
||||
}
|
||||
|
||||
export interface AggregatedRow {
|
||||
clientKey: string;
|
||||
direction: Direction;
|
||||
app: string;
|
||||
bytes: number;
|
||||
packets: number;
|
||||
}
|
||||
|
||||
interface Bucket {
|
||||
exporterId: number;
|
||||
clientKey: string;
|
||||
direction: Direction;
|
||||
app: string;
|
||||
bytes: number;
|
||||
packets: number;
|
||||
}
|
||||
|
||||
export class FlowAggregator {
|
||||
private buckets = new Map<string, Bucket>();
|
||||
|
||||
add(sample: FlowSample): void {
|
||||
if (sample.bytes <= 0 && sample.packets <= 0) return;
|
||||
const key = `${sample.exporterId}|${sample.clientKey}|${sample.direction}|${sample.app}`;
|
||||
const existing = this.buckets.get(key);
|
||||
if (existing) {
|
||||
existing.bytes += sample.bytes;
|
||||
existing.packets += sample.packets;
|
||||
} else {
|
||||
this.buckets.set(key, { ...sample });
|
||||
}
|
||||
}
|
||||
|
||||
get size(): number {
|
||||
return this.buckets.size;
|
||||
}
|
||||
|
||||
// Drain the window: dedup across exporters, then fold clients beyond the
|
||||
// top-N (by total bytes) into CLIENT_OTHER to bound series cardinality.
|
||||
// Pseudo-clients (unknown) are never folded — they're already aggregates.
|
||||
drain(topN: number): AggregatedRow[] {
|
||||
const all = Array.from(this.buckets.values());
|
||||
this.buckets.clear();
|
||||
if (all.length === 0) return [];
|
||||
|
||||
// 1. Pick the winning exporter per client (max bytes, tie → lowest id)
|
||||
const totals = new Map<string, Map<number, number>>(); // clientKey → exporterId → bytes
|
||||
for (const b of all) {
|
||||
let perExporter = totals.get(b.clientKey);
|
||||
if (!perExporter) {
|
||||
perExporter = new Map();
|
||||
totals.set(b.clientKey, perExporter);
|
||||
}
|
||||
perExporter.set(b.exporterId, (perExporter.get(b.exporterId) || 0) + b.bytes);
|
||||
}
|
||||
const winner = new Map<string, number>(); // clientKey → exporterId
|
||||
for (const [clientKey, perExporter] of totals) {
|
||||
let bestId = -1;
|
||||
let bestBytes = -1;
|
||||
for (const [exporterId, bytes] of perExporter) {
|
||||
if (bytes > bestBytes || (bytes === bestBytes && exporterId < bestId)) {
|
||||
bestId = exporterId;
|
||||
bestBytes = bytes;
|
||||
}
|
||||
}
|
||||
winner.set(clientKey, bestId);
|
||||
}
|
||||
|
||||
// 2. Keep only the winning exporter's rows, merged by (client, dir, app)
|
||||
const merged = new Map<string, AggregatedRow>();
|
||||
const clientBytes = new Map<string, number>();
|
||||
for (const b of all) {
|
||||
if (winner.get(b.clientKey) !== b.exporterId) continue;
|
||||
const key = `${b.clientKey}|${b.direction}|${b.app}`;
|
||||
const row = merged.get(key);
|
||||
if (row) {
|
||||
row.bytes += b.bytes;
|
||||
row.packets += b.packets;
|
||||
} else {
|
||||
merged.set(key, {
|
||||
clientKey: b.clientKey,
|
||||
direction: b.direction,
|
||||
app: b.app,
|
||||
bytes: b.bytes,
|
||||
packets: b.packets,
|
||||
});
|
||||
}
|
||||
clientBytes.set(b.clientKey, (clientBytes.get(b.clientKey) || 0) + b.bytes);
|
||||
}
|
||||
|
||||
// 3. Top-N fold
|
||||
const realClients = Array.from(clientBytes.keys()).filter((c) => c !== CLIENT_UNKNOWN);
|
||||
const keep = new Set(
|
||||
realClients
|
||||
.sort((a, c) => (clientBytes.get(c) || 0) - (clientBytes.get(a) || 0))
|
||||
.slice(0, topN)
|
||||
);
|
||||
|
||||
const result = new Map<string, AggregatedRow>();
|
||||
for (const row of merged.values()) {
|
||||
const clientKey =
|
||||
row.clientKey === CLIENT_UNKNOWN || keep.has(row.clientKey)
|
||||
? row.clientKey
|
||||
: CLIENT_OTHER;
|
||||
const key = `${clientKey}|${row.direction}|${row.app}`;
|
||||
const out = result.get(key);
|
||||
if (out) {
|
||||
out.bytes += row.bytes;
|
||||
out.packets += row.packets;
|
||||
} else {
|
||||
result.set(key, { ...row, clientKey });
|
||||
}
|
||||
}
|
||||
return Array.from(result.values());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// NetFlow/IPFIX collector — receives Traffic Flow exports from managed
|
||||
// RouterOS devices on a UDP socket, attributes flows to known clients
|
||||
// (IP → MAC via the clients table), classifies them by protocol/port, and
|
||||
// aggregates into 60-second windows written to InfluxDB (time series) and
|
||||
// Postgres (daily per-client rollups).
|
||||
|
||||
import * as dgram from 'dgram';
|
||||
import { Point } from '@influxdata/influxdb-client';
|
||||
import { query } from '../../config/database';
|
||||
import { getWriteApi } from '../../config/influxdb';
|
||||
import { decodePacket, TemplateCache } from './decoder';
|
||||
import { classifyApp, APP_LAN } from './appCategories';
|
||||
import { FlowAggregator, CLIENT_UNKNOWN, Direction } from './FlowAggregator';
|
||||
|
||||
const FLUSH_INTERVAL_MS = 60_000;
|
||||
const MAP_REFRESH_MS = 60_000;
|
||||
const SETTINGS_RECONCILE_MS = 60_000;
|
||||
|
||||
export interface ExporterStats {
|
||||
deviceId: number;
|
||||
deviceName: string;
|
||||
packets: number;
|
||||
flows: number;
|
||||
lastSeen: string | null;
|
||||
}
|
||||
|
||||
export interface CollectorStats {
|
||||
listening: boolean;
|
||||
port: number;
|
||||
packetsReceived: number;
|
||||
flowsDecoded: number;
|
||||
flowsAttributed: number;
|
||||
packetsFromUnknownExporter: number;
|
||||
recordsWithoutTemplate: number;
|
||||
exporters: ExporterStats[];
|
||||
}
|
||||
|
||||
interface NetflowSettings {
|
||||
enabled: boolean;
|
||||
port: number;
|
||||
topN: number;
|
||||
acceptUnknown: boolean;
|
||||
}
|
||||
|
||||
function isPrivateIPv4(ip: string): boolean {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
const a = Number(parts[0]);
|
||||
const b = Number(parts[1]);
|
||||
if (a === 10) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 169 && b === 254) return true; // link-local
|
||||
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLocalAddress(ip: string): boolean {
|
||||
if (ip.includes(':')) {
|
||||
const lower = ip.toLowerCase();
|
||||
return lower.startsWith('fc') || lower.startsWith('fd') || lower.startsWith('fe8');
|
||||
}
|
||||
return isPrivateIPv4(ip);
|
||||
}
|
||||
|
||||
export class NetflowCollector {
|
||||
private socket: dgram.Socket | null = null;
|
||||
private templates: TemplateCache = new Map();
|
||||
private aggregator = new FlowAggregator();
|
||||
|
||||
private exporterByIp = new Map<string, { deviceId: number; deviceName: string }>();
|
||||
private clientMacByIp = new Map<string, string>();
|
||||
|
||||
private settings: NetflowSettings = { enabled: false, port: 2055, topN: 50, acceptUnknown: true };
|
||||
private listening = false;
|
||||
|
||||
// Pseudo-exporter ids for sources that don't match a managed device (e.g.
|
||||
// routers exporting from behind NAT). Negative so they can never collide
|
||||
// with real device ids.
|
||||
private unknownExporterIds = new Map<string, number>();
|
||||
|
||||
private flushTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private mapTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// Stats (since process start)
|
||||
private packetsReceived = 0;
|
||||
private flowsDecoded = 0;
|
||||
private flowsAttributed = 0;
|
||||
private packetsFromUnknownExporter = 0;
|
||||
private recordsWithoutTemplate = 0;
|
||||
private exporterStats = new Map<number, ExporterStats>();
|
||||
|
||||
async start(): Promise<void> {
|
||||
await this.refreshMaps().catch(() => {});
|
||||
await this.reconcile();
|
||||
this.flushTimer = setInterval(() => {
|
||||
this.flush().catch((e) => console.error('[NetFlow] Flush error:', e));
|
||||
}, FLUSH_INTERVAL_MS);
|
||||
this.mapTimer = setInterval(() => {
|
||||
this.refreshMaps().catch(() => {});
|
||||
}, MAP_REFRESH_MS);
|
||||
this.reconcileTimer = setInterval(() => {
|
||||
this.reconcile().catch(() => {});
|
||||
}, SETTINGS_RECONCILE_MS);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
if (this.flushTimer) clearInterval(this.flushTimer);
|
||||
if (this.mapTimer) clearInterval(this.mapTimer);
|
||||
if (this.reconcileTimer) clearInterval(this.reconcileTimer);
|
||||
this.closeSocket();
|
||||
await this.flush().catch(() => {});
|
||||
}
|
||||
|
||||
// Re-read settings and (re)bind or close the socket as needed. Called on an
|
||||
// interval and directly by the settings route when netflow_* keys change.
|
||||
async reconcile(): Promise<void> {
|
||||
const next = await this.readSettings();
|
||||
const needsRebind =
|
||||
next.enabled !== this.settings.enabled ||
|
||||
(next.enabled && next.port !== this.settings.port);
|
||||
this.settings = next;
|
||||
if (!needsRebind) return;
|
||||
|
||||
this.closeSocket();
|
||||
if (next.enabled) {
|
||||
this.bindSocket(next.port);
|
||||
}
|
||||
}
|
||||
|
||||
getStats(): CollectorStats {
|
||||
return {
|
||||
listening: this.listening,
|
||||
port: this.settings.port,
|
||||
packetsReceived: this.packetsReceived,
|
||||
flowsDecoded: this.flowsDecoded,
|
||||
flowsAttributed: this.flowsAttributed,
|
||||
packetsFromUnknownExporter: this.packetsFromUnknownExporter,
|
||||
recordsWithoutTemplate: this.recordsWithoutTemplate,
|
||||
exporters: Array.from(this.exporterStats.values()),
|
||||
};
|
||||
}
|
||||
|
||||
private async readSettings(): Promise<NetflowSettings> {
|
||||
try {
|
||||
const rows = await query<{ key: string; value: unknown }>(
|
||||
`SELECT key, value FROM app_settings
|
||||
WHERE key IN ('netflow_enabled', 'netflow_collector_port', 'netflow_topn_clients',
|
||||
'netflow_accept_unknown')`
|
||||
);
|
||||
const map: Record<string, unknown> = {};
|
||||
for (const row of rows) map[row.key] = row.value;
|
||||
return {
|
||||
enabled: map['netflow_enabled'] === true,
|
||||
// In Docker the container always binds 2055 (NETFLOW_BIND_PORT) and the
|
||||
// host port is remapped via compose; netflow_collector_port is the
|
||||
// externally reachable port pushed to devices as the export target.
|
||||
port: Number(process.env.NETFLOW_BIND_PORT) || Number(map['netflow_collector_port']) || 2055,
|
||||
topN: Number(map['netflow_topn_clients']) || 50,
|
||||
acceptUnknown: map['netflow_accept_unknown'] !== false,
|
||||
};
|
||||
} catch {
|
||||
return this.settings;
|
||||
}
|
||||
}
|
||||
|
||||
private bindSocket(port: number): void {
|
||||
const socket = dgram.createSocket('udp4');
|
||||
socket.on('message', (msg, rinfo) => this.onMessage(msg, rinfo));
|
||||
socket.on('error', (err) => {
|
||||
console.error(`[NetFlow] Socket error: ${err.message}`);
|
||||
this.closeSocket();
|
||||
});
|
||||
socket.bind(port, () => {
|
||||
this.listening = true;
|
||||
console.log(`[NetFlow] Collector listening on udp/${port}`);
|
||||
});
|
||||
this.socket = socket;
|
||||
}
|
||||
|
||||
private closeSocket(): void {
|
||||
if (this.socket) {
|
||||
try {
|
||||
this.socket.close();
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
this.socket = null;
|
||||
}
|
||||
if (this.listening) console.log('[NetFlow] Collector stopped');
|
||||
this.listening = false;
|
||||
}
|
||||
|
||||
private onMessage(msg: Buffer, rinfo: dgram.RemoteInfo): void {
|
||||
this.packetsReceived++;
|
||||
let exporter = this.exporterByIp.get(rinfo.address);
|
||||
if (!exporter) {
|
||||
// Source doesn't match any managed device — common when the exporting
|
||||
// routers sit behind NAT relative to the collector, so every packet
|
||||
// arrives from the NAT gateway's address. Client attribution doesn't
|
||||
// depend on exporter identity, so (unless disabled) process these as a
|
||||
// per-source pseudo-exporter instead of dropping real traffic data.
|
||||
this.packetsFromUnknownExporter++;
|
||||
if (!this.settings.acceptUnknown) return;
|
||||
let pseudoId = this.unknownExporterIds.get(rinfo.address);
|
||||
if (pseudoId === undefined) {
|
||||
pseudoId = -(this.unknownExporterIds.size + 1);
|
||||
this.unknownExporterIds.set(rinfo.address, pseudoId);
|
||||
console.log(`[NetFlow] Accepting flows from unidentified exporter ${rinfo.address} (NAT in path?)`);
|
||||
}
|
||||
exporter = { deviceId: pseudoId, deviceName: `Unidentified (${rinfo.address})` };
|
||||
}
|
||||
|
||||
const result = decodePacket(msg, rinfo.address, this.templates);
|
||||
this.flowsDecoded += result.flows.length;
|
||||
this.recordsWithoutTemplate += result.recordsWithoutTemplate;
|
||||
|
||||
let stats = this.exporterStats.get(exporter.deviceId);
|
||||
if (!stats) {
|
||||
stats = { deviceId: exporter.deviceId, deviceName: exporter.deviceName, packets: 0, flows: 0, lastSeen: null };
|
||||
this.exporterStats.set(exporter.deviceId, stats);
|
||||
}
|
||||
stats.packets++;
|
||||
stats.flows += result.flows.length;
|
||||
stats.deviceName = exporter.deviceName;
|
||||
if (result.flows.length > 0) stats.lastSeen = new Date().toISOString();
|
||||
|
||||
for (const flow of result.flows) {
|
||||
this.attribute(exporter.deviceId, flow.srcAddr, flow.dstAddr, flow.srcPort, flow.dstPort, flow.protocol, flow.bytes, flow.packets);
|
||||
}
|
||||
}
|
||||
|
||||
// Attribution rules:
|
||||
// - exactly one endpoint is a known client → that client's traffic
|
||||
// (client = source → upload, client = destination → download)
|
||||
// - both endpoints known clients → LAN traffic, attributed to both
|
||||
// - a local/private endpoint we can't map to a client → "unknown" bucket
|
||||
// - neither endpoint local (e.g. post-NAT duplicate record where the
|
||||
// source is the router's WAN address) → dropped
|
||||
private attribute(
|
||||
exporterId: number,
|
||||
srcAddr: string,
|
||||
dstAddr: string,
|
||||
srcPort: number,
|
||||
dstPort: number,
|
||||
protocol: number,
|
||||
bytes: number,
|
||||
packets: number
|
||||
): void {
|
||||
const srcMac = this.clientMacByIp.get(srcAddr);
|
||||
const dstMac = this.clientMacByIp.get(dstAddr);
|
||||
const srcLocal = srcMac !== undefined || isLocalAddress(srcAddr);
|
||||
const dstLocal = dstMac !== undefined || isLocalAddress(dstAddr);
|
||||
|
||||
const add = (clientKey: string, direction: Direction, app: string) => {
|
||||
this.aggregator.add({ exporterId, clientKey, direction, app, bytes, packets });
|
||||
this.flowsAttributed++;
|
||||
};
|
||||
|
||||
if (srcMac && dstMac) {
|
||||
// Client-to-client on the local network
|
||||
add(srcMac, 'upload', APP_LAN);
|
||||
add(dstMac, 'download', APP_LAN);
|
||||
return;
|
||||
}
|
||||
if (srcLocal && dstLocal) {
|
||||
// Local flow we can't fully attribute (e.g. printer ↔ unknown host)
|
||||
add(srcMac || dstMac || CLIENT_UNKNOWN, srcMac ? 'upload' : 'download', APP_LAN);
|
||||
return;
|
||||
}
|
||||
if (srcLocal && !dstLocal) {
|
||||
add(srcMac || CLIENT_UNKNOWN, 'upload', classifyApp(protocol, srcPort, dstPort));
|
||||
return;
|
||||
}
|
||||
if (!srcLocal && dstLocal) {
|
||||
add(dstMac || CLIENT_UNKNOWN, 'download', classifyApp(protocol, dstPort, srcPort));
|
||||
return;
|
||||
}
|
||||
// Neither endpoint local — transit or post-NAT duplicate; drop.
|
||||
}
|
||||
|
||||
private async refreshMaps(): Promise<void> {
|
||||
// Exporters: management IP + every cached /ip/address (CIDR stripped)
|
||||
const devices = await query<{ id: number; name: string; ip_address: string; ip_addresses_jsonb: { address: string }[] | null }>(
|
||||
`SELECT id, name, ip_address, ip_addresses_jsonb FROM devices`
|
||||
);
|
||||
const exporters = new Map<string, { deviceId: number; deviceName: string }>();
|
||||
for (const d of devices) {
|
||||
exporters.set(d.ip_address, { deviceId: d.id, deviceName: d.name });
|
||||
for (const entry of d.ip_addresses_jsonb || []) {
|
||||
const ip = (entry.address || '').split('/')[0];
|
||||
if (ip && !exporters.has(ip)) exporters.set(ip, { deviceId: d.id, deviceName: d.name });
|
||||
}
|
||||
}
|
||||
this.exporterByIp = exporters;
|
||||
|
||||
// Clients: newest MAC per IP wins (DHCP reuse)
|
||||
const clients = await query<{ mac_address: string; ip_address: string }>(
|
||||
`SELECT DISTINCT ON (ip_address) mac_address, ip_address
|
||||
FROM clients
|
||||
WHERE ip_address IS NOT NULL AND ip_address != ''
|
||||
ORDER BY ip_address, last_seen DESC NULLS LAST`
|
||||
);
|
||||
const clientMap = new Map<string, string>();
|
||||
for (const c of clients) {
|
||||
clientMap.set(c.ip_address, c.mac_address.toLowerCase());
|
||||
}
|
||||
// A device's own addresses are not client traffic endpoints we can bill
|
||||
// to a MAC — exporter match takes priority over a stale client row.
|
||||
for (const ip of exporters.keys()) clientMap.delete(ip);
|
||||
this.clientMacByIp = clientMap;
|
||||
}
|
||||
|
||||
private async flush(): Promise<void> {
|
||||
const rows = this.aggregator.drain(this.settings.topN);
|
||||
if (rows.length === 0) return;
|
||||
|
||||
const writeApi = getWriteApi();
|
||||
const now = new Date();
|
||||
for (const row of rows) {
|
||||
writeApi.writePoint(
|
||||
new Point('client_traffic')
|
||||
.tag('mac', row.clientKey)
|
||||
.tag('direction', row.direction)
|
||||
.tag('app', row.app)
|
||||
.intField('bytes', row.bytes)
|
||||
.intField('packets', row.packets)
|
||||
.timestamp(now)
|
||||
);
|
||||
}
|
||||
await writeApi.flush().catch((e) => console.error('[NetFlow] Influx write error:', e));
|
||||
|
||||
// Daily rollups: totals per mac + per-app byte breakdown
|
||||
const perClient = new Map<string, { upload: number; download: number; apps: Map<string, number> }>();
|
||||
for (const row of rows) {
|
||||
let entry = perClient.get(row.clientKey);
|
||||
if (!entry) {
|
||||
entry = { upload: 0, download: 0, apps: new Map() };
|
||||
perClient.set(row.clientKey, entry);
|
||||
}
|
||||
if (row.direction === 'upload') entry.upload += row.bytes;
|
||||
else entry.download += row.bytes;
|
||||
entry.apps.set(row.app, (entry.apps.get(row.app) || 0) + row.bytes);
|
||||
}
|
||||
|
||||
for (const [mac, entry] of perClient) {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO client_traffic_daily (mac_address, day, upload_bytes, download_bytes, app_breakdown)
|
||||
VALUES ($1, CURRENT_DATE, $2, $3, $4::jsonb)
|
||||
ON CONFLICT (mac_address, day) DO UPDATE SET
|
||||
upload_bytes = client_traffic_daily.upload_bytes + EXCLUDED.upload_bytes,
|
||||
download_bytes = client_traffic_daily.download_bytes + EXCLUDED.download_bytes,
|
||||
app_breakdown = (
|
||||
SELECT COALESCE(jsonb_object_agg(k, v), '{}'::jsonb)
|
||||
FROM (
|
||||
SELECT COALESCE(a.key, b.key) AS k,
|
||||
to_jsonb(COALESCE((a.value)::bigint, 0) + COALESCE((b.value)::bigint, 0)) AS v
|
||||
FROM jsonb_each_text(client_traffic_daily.app_breakdown) a
|
||||
FULL OUTER JOIN jsonb_each_text(EXCLUDED.app_breakdown) b ON a.key = b.key
|
||||
) merged
|
||||
)`,
|
||||
[mac, entry.upload, entry.download, JSON.stringify(Object.fromEntries(entry.apps))]
|
||||
);
|
||||
} catch (e) {
|
||||
console.error('[NetFlow] Daily rollup error:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const netflowCollector = new NetflowCollector();
|
||||
@@ -0,0 +1,107 @@
|
||||
import { FlowAggregator, CLIENT_UNKNOWN, CLIENT_OTHER } from '../FlowAggregator';
|
||||
import { classifyApp } from '../appCategories';
|
||||
|
||||
const MAC_A = 'aa:aa:aa:aa:aa:01';
|
||||
const MAC_B = 'aa:aa:aa:aa:aa:02';
|
||||
|
||||
describe('FlowAggregator dedup', () => {
|
||||
it('keeps only the exporter that saw the most bytes for each client', () => {
|
||||
const agg = new FlowAggregator();
|
||||
// Gateway (device 1) sees all of client A's traffic; an intermediate
|
||||
// switch/router (device 2) sees a subset of the same flows.
|
||||
agg.add({ exporterId: 1, clientKey: MAC_A, direction: 'download', app: 'HTTPS', bytes: 10_000, packets: 20 });
|
||||
agg.add({ exporterId: 2, clientKey: MAC_A, direction: 'download', app: 'HTTPS', bytes: 7_000, packets: 14 });
|
||||
|
||||
const rows = agg.drain(50);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].bytes).toBe(10_000);
|
||||
});
|
||||
|
||||
it('breaks byte ties deterministically by lowest exporter id', () => {
|
||||
const agg = new FlowAggregator();
|
||||
agg.add({ exporterId: 5, clientKey: MAC_A, direction: 'upload', app: 'DNS', bytes: 100, packets: 1 });
|
||||
agg.add({ exporterId: 3, clientKey: MAC_A, direction: 'upload', app: 'QUIC', bytes: 100, packets: 1 });
|
||||
|
||||
const rows = agg.drain(50);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].app).toBe('QUIC'); // exporter 3 wins the tie
|
||||
});
|
||||
|
||||
it('picks the winning exporter per client independently', () => {
|
||||
const agg = new FlowAggregator();
|
||||
agg.add({ exporterId: 1, clientKey: MAC_A, direction: 'download', app: 'HTTPS', bytes: 500, packets: 1 });
|
||||
agg.add({ exporterId: 2, clientKey: MAC_A, direction: 'download', app: 'HTTPS', bytes: 100, packets: 1 });
|
||||
agg.add({ exporterId: 1, clientKey: MAC_B, direction: 'download', app: 'HTTPS', bytes: 100, packets: 1 });
|
||||
agg.add({ exporterId: 2, clientKey: MAC_B, direction: 'download', app: 'HTTPS', bytes: 900, packets: 1 });
|
||||
|
||||
const rows = agg.drain(50);
|
||||
const a = rows.find((r) => r.clientKey === MAC_A);
|
||||
const b = rows.find((r) => r.clientKey === MAC_B);
|
||||
expect(a?.bytes).toBe(500); // device 1 wins for A
|
||||
expect(b?.bytes).toBe(900); // device 2 wins for B
|
||||
});
|
||||
|
||||
it('accumulates repeated samples within the window and clears on drain', () => {
|
||||
const agg = new FlowAggregator();
|
||||
agg.add({ exporterId: 1, clientKey: MAC_A, direction: 'upload', app: 'HTTPS', bytes: 100, packets: 1 });
|
||||
agg.add({ exporterId: 1, clientKey: MAC_A, direction: 'upload', app: 'HTTPS', bytes: 250, packets: 2 });
|
||||
|
||||
const rows = agg.drain(50);
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].bytes).toBe(350);
|
||||
expect(rows[0].packets).toBe(3);
|
||||
expect(agg.drain(50)).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('FlowAggregator top-N fold', () => {
|
||||
it('folds clients beyond the top-N into the "other" bucket', () => {
|
||||
const agg = new FlowAggregator();
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const mac = `aa:aa:aa:aa:bb:0${i}`;
|
||||
agg.add({ exporterId: 1, clientKey: mac, direction: 'download', app: 'HTTPS', bytes: (i + 1) * 1000, packets: 1 });
|
||||
}
|
||||
|
||||
const rows = agg.drain(2);
|
||||
const macs = new Set(rows.map((r) => r.clientKey));
|
||||
expect(macs.has('aa:aa:aa:aa:bb:04')).toBe(true); // biggest
|
||||
expect(macs.has('aa:aa:aa:aa:bb:03')).toBe(true); // second
|
||||
expect(macs.has(CLIENT_OTHER)).toBe(true);
|
||||
const other = rows.find((r) => r.clientKey === CLIENT_OTHER);
|
||||
expect(other?.bytes).toBe(1000 + 2000 + 3000); // the three folded clients
|
||||
});
|
||||
|
||||
it('never folds the unknown pseudo-client and does not let it consume a top-N slot', () => {
|
||||
const agg = new FlowAggregator();
|
||||
agg.add({ exporterId: 1, clientKey: CLIENT_UNKNOWN, direction: 'download', app: 'HTTPS', bytes: 999_999, packets: 1 });
|
||||
agg.add({ exporterId: 1, clientKey: MAC_A, direction: 'download', app: 'HTTPS', bytes: 10, packets: 1 });
|
||||
|
||||
const rows = agg.drain(1);
|
||||
const macs = new Set(rows.map((r) => r.clientKey));
|
||||
expect(macs.has(CLIENT_UNKNOWN)).toBe(true);
|
||||
expect(macs.has(MAC_A)).toBe(true); // still fits — unknown didn't take its slot
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyApp', () => {
|
||||
it('classifies by the remote port first', () => {
|
||||
expect(classifyApp(6, 54321, 443)).toBe('HTTPS');
|
||||
expect(classifyApp(17, 54321, 443)).toBe('QUIC');
|
||||
expect(classifyApp(17, 50000, 53)).toBe('DNS');
|
||||
expect(classifyApp(6, 50000, 22)).toBe('SSH');
|
||||
});
|
||||
|
||||
it('falls back to the local port for inbound services', () => {
|
||||
// Remote ephemeral port, local well-known port (client hosts SSH)
|
||||
expect(classifyApp(6, 22, 51515)).toBe('SSH');
|
||||
});
|
||||
|
||||
it('handles non-port protocols and fallbacks', () => {
|
||||
expect(classifyApp(1, 0, 0)).toBe('ICMP');
|
||||
expect(classifyApp(58, 0, 0)).toBe('ICMP');
|
||||
expect(classifyApp(47, 0, 0)).toBe('GRE');
|
||||
expect(classifyApp(6, 49152, 49153)).toBe('Other TCP');
|
||||
expect(classifyApp(17, 49152, 49153)).toBe('Other UDP');
|
||||
expect(classifyApp(132, 0, 0)).toBe('Other');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,271 @@
|
||||
import { decodePacket, TemplateCache } from '../decoder';
|
||||
|
||||
// ─── Fixture builders ─────────────────────────────────────────────────────────
|
||||
|
||||
interface FieldSpec { id: number; length: number }
|
||||
|
||||
function u16(n: number): Buffer {
|
||||
const b = Buffer.alloc(2);
|
||||
b.writeUInt16BE(n);
|
||||
return b;
|
||||
}
|
||||
|
||||
function u32(n: number): Buffer {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32BE(n);
|
||||
return b;
|
||||
}
|
||||
|
||||
function ipv4(addr: string): Buffer {
|
||||
return Buffer.from(addr.split('.').map(Number));
|
||||
}
|
||||
|
||||
function templateBody(templateId: number, fields: FieldSpec[]): Buffer {
|
||||
return Buffer.concat([
|
||||
u16(templateId),
|
||||
u16(fields.length),
|
||||
...fields.flatMap((f) => [u16(f.id), u16(f.length)]),
|
||||
]);
|
||||
}
|
||||
|
||||
function flowset(setId: number, body: Buffer, padTo4 = true): Buffer {
|
||||
let length = 4 + body.length;
|
||||
let padding = Buffer.alloc(0);
|
||||
if (padTo4 && length % 4 !== 0) {
|
||||
padding = Buffer.alloc(4 - (length % 4));
|
||||
length += padding.length;
|
||||
}
|
||||
return Buffer.concat([u16(setId), u16(length), body, padding]);
|
||||
}
|
||||
|
||||
function v9Packet(sourceId: number, flowsets: Buffer[]): Buffer {
|
||||
const body = Buffer.concat(flowsets);
|
||||
return Buffer.concat([
|
||||
u16(9), // version
|
||||
u16(flowsets.length), // record count (not used by decoder)
|
||||
u32(12345), // sysUptime
|
||||
u32(1700000000), // unix secs
|
||||
u32(1), // sequence
|
||||
u32(sourceId),
|
||||
body,
|
||||
]);
|
||||
}
|
||||
|
||||
function ipfixPacket(domainId: number, sets: Buffer[]): Buffer {
|
||||
const body = Buffer.concat(sets);
|
||||
return Buffer.concat([
|
||||
u16(10), // version
|
||||
u16(16 + body.length), // total length
|
||||
u32(1700000000), // export time
|
||||
u32(1), // sequence
|
||||
u32(domainId),
|
||||
body,
|
||||
]);
|
||||
}
|
||||
|
||||
// Standard 7-field record layout used in most tests:
|
||||
// srcIPv4(8), dstIPv4(12), srcPort(7), dstPort(11), protocol(4), bytes(1, 4B), packets(2, 4B)
|
||||
const V9_FIELDS: FieldSpec[] = [
|
||||
{ id: 8, length: 4 },
|
||||
{ id: 12, length: 4 },
|
||||
{ id: 7, length: 2 },
|
||||
{ id: 11, length: 2 },
|
||||
{ id: 4, length: 1 },
|
||||
{ id: 1, length: 4 },
|
||||
{ id: 2, length: 4 },
|
||||
];
|
||||
|
||||
function record(src: string, dst: string, srcPort: number, dstPort: number, proto: number, bytes: number, packets: number): Buffer {
|
||||
return Buffer.concat([
|
||||
ipv4(src),
|
||||
ipv4(dst),
|
||||
u16(srcPort),
|
||||
u16(dstPort),
|
||||
Buffer.from([proto]),
|
||||
u32(bytes),
|
||||
u32(packets),
|
||||
]);
|
||||
}
|
||||
|
||||
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('NetFlow v9 decoding', () => {
|
||||
it('decodes a template followed by data records in the same packet', () => {
|
||||
const cache: TemplateCache = new Map();
|
||||
const packet = v9Packet(0, [
|
||||
flowset(0, templateBody(256, V9_FIELDS)),
|
||||
flowset(256, Buffer.concat([
|
||||
record('192.168.1.10', '93.184.216.34', 54321, 443, 6, 1500, 10),
|
||||
record('93.184.216.34', '192.168.1.10', 443, 54321, 6, 90000, 60),
|
||||
])),
|
||||
]);
|
||||
|
||||
const result = decodePacket(packet, '10.0.0.1', cache);
|
||||
expect(result.templatesParsed).toBe(1);
|
||||
expect(result.flows).toHaveLength(2);
|
||||
expect(result.flows[0]).toEqual({
|
||||
srcAddr: '192.168.1.10',
|
||||
dstAddr: '93.184.216.34',
|
||||
srcPort: 54321,
|
||||
dstPort: 443,
|
||||
protocol: 6,
|
||||
bytes: 1500,
|
||||
packets: 10,
|
||||
});
|
||||
expect(result.flows[1].bytes).toBe(90000);
|
||||
});
|
||||
|
||||
it('drops data that arrives before its template, then decodes once the template is known', () => {
|
||||
const cache: TemplateCache = new Map();
|
||||
const dataOnly = v9Packet(0, [
|
||||
flowset(256, record('192.168.1.10', '1.1.1.1', 50000, 53, 17, 120, 2)),
|
||||
]);
|
||||
|
||||
const first = decodePacket(dataOnly, '10.0.0.1', cache);
|
||||
expect(first.flows).toHaveLength(0);
|
||||
expect(first.recordsWithoutTemplate).toBe(1);
|
||||
|
||||
const templateOnly = v9Packet(0, [flowset(0, templateBody(256, V9_FIELDS))]);
|
||||
decodePacket(templateOnly, '10.0.0.1', cache);
|
||||
|
||||
const second = decodePacket(dataOnly, '10.0.0.1', cache);
|
||||
expect(second.flows).toHaveLength(1);
|
||||
expect(second.flows[0].dstPort).toBe(53);
|
||||
});
|
||||
|
||||
it('keeps templates from different exporters separate', () => {
|
||||
const cache: TemplateCache = new Map();
|
||||
const templateOnly = v9Packet(0, [flowset(0, templateBody(256, V9_FIELDS))]);
|
||||
decodePacket(templateOnly, '10.0.0.1', cache);
|
||||
|
||||
const dataOnly = v9Packet(0, [
|
||||
flowset(256, record('192.168.1.10', '1.1.1.1', 50000, 53, 17, 120, 2)),
|
||||
]);
|
||||
// Different exporter never sent its template
|
||||
const other = decodePacket(dataOnly, '10.0.0.2', cache);
|
||||
expect(other.flows).toHaveLength(0);
|
||||
expect(other.recordsWithoutTemplate).toBe(1);
|
||||
});
|
||||
|
||||
it('ignores trailing padding in data flowsets', () => {
|
||||
const cache: TemplateCache = new Map();
|
||||
decodePacket(v9Packet(0, [flowset(0, templateBody(256, V9_FIELDS))]), '10.0.0.1', cache);
|
||||
|
||||
// One 21-byte record + flowset padding to a 4-byte boundary
|
||||
const packet = v9Packet(0, [
|
||||
flowset(256, record('192.168.1.20', '8.8.8.8', 40000, 443, 17, 555, 5)),
|
||||
]);
|
||||
const result = decodePacket(packet, '10.0.0.1', cache);
|
||||
expect(result.flows).toHaveLength(1);
|
||||
expect(result.flows[0].bytes).toBe(555);
|
||||
});
|
||||
|
||||
it('ignores options templates and unknown packet versions', () => {
|
||||
const cache: TemplateCache = new Map();
|
||||
const optionsSet = v9Packet(0, [flowset(1, Buffer.alloc(8))]);
|
||||
expect(decodePacket(optionsSet, '10.0.0.1', cache).flows).toHaveLength(0);
|
||||
|
||||
const v5ish = Buffer.alloc(24);
|
||||
v5ish.writeUInt16BE(5, 0);
|
||||
expect(decodePacket(v5ish, '10.0.0.1', cache).flows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('IPFIX decoding', () => {
|
||||
it('decodes IPFIX templates (set 2) and 64-bit counters', () => {
|
||||
const cache: TemplateCache = new Map();
|
||||
const fields: FieldSpec[] = [
|
||||
{ id: 8, length: 4 },
|
||||
{ id: 12, length: 4 },
|
||||
{ id: 7, length: 2 },
|
||||
{ id: 11, length: 2 },
|
||||
{ id: 4, length: 1 },
|
||||
{ id: 1, length: 8 }, // octetDeltaCount as 64-bit
|
||||
{ id: 2, length: 8 },
|
||||
];
|
||||
const bigBytes = Buffer.alloc(8);
|
||||
bigBytes.writeBigUInt64BE(5_000_000_000n);
|
||||
const bigPackets = Buffer.alloc(8);
|
||||
bigPackets.writeBigUInt64BE(4_000_000n);
|
||||
const dataRecord = Buffer.concat([
|
||||
ipv4('192.168.1.50'),
|
||||
ipv4('142.250.80.78'),
|
||||
u16(51000),
|
||||
u16(443),
|
||||
Buffer.from([17]),
|
||||
bigBytes,
|
||||
bigPackets,
|
||||
]);
|
||||
|
||||
const packet = ipfixPacket(7, [
|
||||
flowset(2, templateBody(300, fields)),
|
||||
flowset(300, dataRecord),
|
||||
]);
|
||||
|
||||
const result = decodePacket(packet, '10.0.0.1', cache);
|
||||
expect(result.templatesParsed).toBe(1);
|
||||
expect(result.flows).toHaveLength(1);
|
||||
expect(result.flows[0].bytes).toBe(5_000_000_000);
|
||||
expect(result.flows[0].protocol).toBe(17);
|
||||
});
|
||||
|
||||
it('skips enterprise-specific fields by length without corrupting the record walk', () => {
|
||||
const cache: TemplateCache = new Map();
|
||||
// Template: srcIPv4, dstIPv4, enterprise field (id 0x8000|99, 4 bytes + PEN), bytes
|
||||
const templateBuf = Buffer.concat([
|
||||
u16(301),
|
||||
u16(6),
|
||||
u16(8), u16(4),
|
||||
u16(12), u16(4),
|
||||
u16(0x8000 | 99), u16(4), u32(14988), // enterprise number (MikroTik PEN)
|
||||
u16(1), u16(4),
|
||||
u16(2), u16(4),
|
||||
u16(4), u16(1),
|
||||
]);
|
||||
const dataRecord = Buffer.concat([
|
||||
ipv4('192.168.1.60'),
|
||||
ipv4('9.9.9.9'),
|
||||
u32(0xdeadbeef), // enterprise value — must be skipped
|
||||
u32(777),
|
||||
u32(7),
|
||||
Buffer.from([6]),
|
||||
]);
|
||||
const packet = ipfixPacket(7, [
|
||||
flowset(2, templateBuf),
|
||||
flowset(301, dataRecord),
|
||||
]);
|
||||
|
||||
const result = decodePacket(packet, '10.0.0.1', cache);
|
||||
expect(result.flows).toHaveLength(1);
|
||||
expect(result.flows[0].bytes).toBe(777);
|
||||
expect(result.flows[0].protocol).toBe(6);
|
||||
});
|
||||
|
||||
it('decodes IPv6 flow records', () => {
|
||||
const cache: TemplateCache = new Map();
|
||||
const fields: FieldSpec[] = [
|
||||
{ id: 27, length: 16 },
|
||||
{ id: 28, length: 16 },
|
||||
{ id: 7, length: 2 },
|
||||
{ id: 11, length: 2 },
|
||||
{ id: 4, length: 1 },
|
||||
{ id: 1, length: 4 },
|
||||
{ id: 2, length: 4 },
|
||||
];
|
||||
const src = Buffer.alloc(16);
|
||||
src[0] = 0xfd;
|
||||
src[15] = 0x01;
|
||||
const dst = Buffer.alloc(16);
|
||||
dst[0] = 0x26;
|
||||
dst[1] = 0x07;
|
||||
const dataRecord = Buffer.concat([src, dst, u16(50000), u16(443), Buffer.from([6]), u32(2048), u32(4)]);
|
||||
|
||||
const packet = ipfixPacket(7, [
|
||||
flowset(2, templateBody(302, fields)),
|
||||
flowset(302, dataRecord),
|
||||
]);
|
||||
const result = decodePacket(packet, '10.0.0.1', cache);
|
||||
expect(result.flows).toHaveLength(1);
|
||||
expect(result.flows[0].srcAddr).toMatch(/^fd00:/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
// Flow → application category classification by IP protocol + well-known port.
|
||||
// Port-based heuristics (no DPI): good enough to distinguish web, DNS, VPN,
|
||||
// mail, etc. The remote endpoint's port is matched first — a client browsing
|
||||
// uses an ephemeral local port and the service's well-known remote port.
|
||||
|
||||
const TCP = 6;
|
||||
const UDP = 17;
|
||||
const ICMP = 1;
|
||||
const ICMPV6 = 58;
|
||||
|
||||
export const APP_LAN = 'LAN';
|
||||
export const APP_OTHER = 'Other';
|
||||
|
||||
// `${protocol}/${port}` → category
|
||||
const PORT_MAP: Record<string, string> = {
|
||||
[`${TCP}/443`]: 'HTTPS',
|
||||
[`${UDP}/443`]: 'QUIC',
|
||||
[`${TCP}/80`]: 'HTTP',
|
||||
[`${UDP}/80`]: 'HTTP',
|
||||
[`${TCP}/53`]: 'DNS',
|
||||
[`${UDP}/53`]: 'DNS',
|
||||
[`${TCP}/853`]: 'DNS over TLS',
|
||||
[`${UDP}/853`]: 'DNS over TLS',
|
||||
[`${TCP}/22`]: 'SSH',
|
||||
[`${TCP}/23`]: 'Telnet',
|
||||
[`${TCP}/25`]: 'Email',
|
||||
[`${TCP}/465`]: 'Email',
|
||||
[`${TCP}/587`]: 'Email',
|
||||
[`${TCP}/110`]: 'Email',
|
||||
[`${TCP}/995`]: 'Email',
|
||||
[`${TCP}/143`]: 'Email',
|
||||
[`${TCP}/993`]: 'Email',
|
||||
[`${UDP}/123`]: 'NTP',
|
||||
[`${UDP}/51820`]: 'WireGuard',
|
||||
[`${UDP}/500`]: 'IPsec',
|
||||
[`${UDP}/4500`]: 'IPsec',
|
||||
[`${TCP}/1723`]: 'VPN (PPTP)',
|
||||
[`${TCP}/1194`]: 'OpenVPN',
|
||||
[`${UDP}/1194`]: 'OpenVPN',
|
||||
[`${TCP}/3389`]: 'RDP',
|
||||
[`${UDP}/3389`]: 'RDP',
|
||||
[`${TCP}/445`]: 'SMB',
|
||||
[`${TCP}/139`]: 'SMB',
|
||||
[`${TCP}/21`]: 'FTP',
|
||||
[`${UDP}/67`]: 'DHCP',
|
||||
[`${UDP}/68`]: 'DHCP',
|
||||
[`${UDP}/547`]: 'DHCP',
|
||||
[`${UDP}/5353`]: 'mDNS',
|
||||
[`${UDP}/1900`]: 'SSDP',
|
||||
[`${TCP}/8291`]: 'Management',
|
||||
[`${TCP}/8728`]: 'Management',
|
||||
[`${TCP}/8729`]: 'Management',
|
||||
[`${UDP}/161`]: 'Management',
|
||||
[`${UDP}/3478`]: 'Video/Voice Calls',
|
||||
[`${TCP}/5060`]: 'Video/Voice Calls',
|
||||
[`${UDP}/5060`]: 'Video/Voice Calls',
|
||||
[`${TCP}/5061`]: 'Video/Voice Calls',
|
||||
};
|
||||
|
||||
export function classifyApp(
|
||||
protocol: number,
|
||||
localPort: number,
|
||||
remotePort: number
|
||||
): string {
|
||||
if (protocol === ICMP || protocol === ICMPV6) return 'ICMP';
|
||||
if (protocol === 47) return 'GRE';
|
||||
if (protocol === 50) return 'IPsec';
|
||||
|
||||
// Remote port first (the service the client is talking to), then local port
|
||||
// (the client is hosting the service, e.g. inbound SSH).
|
||||
const byRemote = PORT_MAP[`${protocol}/${remotePort}`];
|
||||
if (byRemote) return byRemote;
|
||||
const byLocal = PORT_MAP[`${protocol}/${localPort}`];
|
||||
if (byLocal) return byLocal;
|
||||
|
||||
if (protocol === TCP) return 'Other TCP';
|
||||
if (protocol === UDP) return 'Other UDP';
|
||||
return APP_OTHER;
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
// NetFlow v9 / IPFIX (v10) decoder — purpose-built for the fields RouterOS
|
||||
// Traffic Flow exports. Both protocols share the same structure (header +
|
||||
// sets; template sets describe field layouts, data sets reference them), and
|
||||
// the field IDs we care about are identical in v9 field types and IPFIX
|
||||
// information elements, so one decoder covers both.
|
||||
|
||||
export interface FlowRecord {
|
||||
srcAddr: string;
|
||||
dstAddr: string;
|
||||
srcPort: number;
|
||||
dstPort: number;
|
||||
protocol: number;
|
||||
bytes: number;
|
||||
packets: number;
|
||||
}
|
||||
|
||||
interface TemplateField {
|
||||
id: number;
|
||||
length: number; // 65535 = IPFIX variable-length
|
||||
enterprise: boolean;
|
||||
}
|
||||
|
||||
interface Template {
|
||||
fields: TemplateField[];
|
||||
// Minimum bytes a record can occupy (var-length fields count as 1)
|
||||
minLength: number;
|
||||
}
|
||||
|
||||
// Keyed by `${exporterKey}|${sourceId}|${templateId}` so two exporters (or two
|
||||
// observation domains on one exporter) can't clobber each other's templates.
|
||||
export type TemplateCache = Map<string, Template>;
|
||||
|
||||
export interface DecodeResult {
|
||||
flows: FlowRecord[];
|
||||
templatesParsed: number;
|
||||
// Data records that arrived before their template (dropped)
|
||||
recordsWithoutTemplate: number;
|
||||
}
|
||||
|
||||
const FIELD = {
|
||||
BYTES: 1, // IN_BYTES / octetDeltaCount
|
||||
PACKETS: 2, // IN_PKTS / packetDeltaCount
|
||||
PROTOCOL: 4, // PROTOCOL / protocolIdentifier
|
||||
SRC_PORT: 7, // L4_SRC_PORT / sourceTransportPort
|
||||
SRC_IPV4: 8, // IPV4_SRC_ADDR / sourceIPv4Address
|
||||
DST_PORT: 11, // L4_DST_PORT / destinationTransportPort
|
||||
DST_IPV4: 12, // IPV4_DST_ADDR / destinationIPv4Address
|
||||
SRC_IPV6: 27, // IPV6_SRC_ADDR / sourceIPv6Address
|
||||
DST_IPV6: 28, // IPV6_DST_ADDR / destinationIPv6Address
|
||||
} as const;
|
||||
|
||||
function readUInt(buf: Buffer, offset: number, length: number): number {
|
||||
if (length === 8) {
|
||||
// 64-bit counters (IPFIX octetDeltaCount is often 8 bytes)
|
||||
return Number(buf.readBigUInt64BE(offset));
|
||||
}
|
||||
if (length >= 1 && length <= 6) {
|
||||
return buf.readUIntBE(offset, length);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function formatIPv4(buf: Buffer, offset: number): string {
|
||||
return `${buf[offset]}.${buf[offset + 1]}.${buf[offset + 2]}.${buf[offset + 3]}`;
|
||||
}
|
||||
|
||||
function formatIPv6(buf: Buffer, offset: number): string {
|
||||
const parts: string[] = [];
|
||||
for (let i = 0; i < 8; i++) {
|
||||
parts.push(buf.readUInt16BE(offset + i * 2).toString(16));
|
||||
}
|
||||
return parts.join(':');
|
||||
}
|
||||
|
||||
function parseTemplates(
|
||||
buf: Buffer,
|
||||
start: number,
|
||||
setEnd: number,
|
||||
ipfix: boolean,
|
||||
exporterKey: string,
|
||||
sourceId: number,
|
||||
cache: TemplateCache
|
||||
): number {
|
||||
let off = start;
|
||||
let parsed = 0;
|
||||
// A template set can contain multiple template records back to back
|
||||
while (off + 4 <= setEnd) {
|
||||
const templateId = buf.readUInt16BE(off);
|
||||
const fieldCount = buf.readUInt16BE(off + 2);
|
||||
off += 4;
|
||||
if (templateId < 256 || fieldCount === 0 || fieldCount > 128) break;
|
||||
|
||||
const fields: TemplateField[] = [];
|
||||
let minLength = 0;
|
||||
let ok = true;
|
||||
for (let i = 0; i < fieldCount; i++) {
|
||||
if (off + 4 > setEnd) { ok = false; break; }
|
||||
let id = buf.readUInt16BE(off);
|
||||
const length = buf.readUInt16BE(off + 2);
|
||||
off += 4;
|
||||
let enterprise = false;
|
||||
if (ipfix && (id & 0x8000) !== 0) {
|
||||
// Enterprise-specific element: 4-byte enterprise number follows
|
||||
if (off + 4 > setEnd) { ok = false; break; }
|
||||
off += 4;
|
||||
id = id & 0x7fff;
|
||||
enterprise = true;
|
||||
}
|
||||
fields.push({ id, length, enterprise });
|
||||
minLength += length === 0xffff ? 1 : length;
|
||||
}
|
||||
if (!ok) break;
|
||||
cache.set(`${exporterKey}|${sourceId}|${templateId}`, { fields, minLength });
|
||||
parsed++;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseDataSet(
|
||||
buf: Buffer,
|
||||
start: number,
|
||||
setEnd: number,
|
||||
template: Template,
|
||||
flows: FlowRecord[]
|
||||
): void {
|
||||
let off = start;
|
||||
// Records are packed back to back; trailing bytes < minLength are padding
|
||||
while (off + template.minLength <= setEnd) {
|
||||
let srcAddr = '';
|
||||
let dstAddr = '';
|
||||
let srcPort = 0;
|
||||
let dstPort = 0;
|
||||
let protocol = 0;
|
||||
let bytes = -1;
|
||||
let packets = 0;
|
||||
let valid = true;
|
||||
|
||||
for (const field of template.fields) {
|
||||
let len = field.length;
|
||||
if (len === 0xffff) {
|
||||
// IPFIX variable-length: first byte is the length (255 → next 2 bytes)
|
||||
if (off + 1 > setEnd) { valid = false; break; }
|
||||
len = buf[off];
|
||||
off += 1;
|
||||
if (len === 255) {
|
||||
if (off + 2 > setEnd) { valid = false; break; }
|
||||
len = buf.readUInt16BE(off);
|
||||
off += 2;
|
||||
}
|
||||
}
|
||||
if (off + len > setEnd) { valid = false; break; }
|
||||
|
||||
if (!field.enterprise) {
|
||||
switch (field.id) {
|
||||
case FIELD.BYTES: bytes = readUInt(buf, off, len); break;
|
||||
case FIELD.PACKETS: packets = readUInt(buf, off, len); break;
|
||||
case FIELD.PROTOCOL: protocol = readUInt(buf, off, len); break;
|
||||
case FIELD.SRC_PORT: srcPort = readUInt(buf, off, len); break;
|
||||
case FIELD.DST_PORT: dstPort = readUInt(buf, off, len); break;
|
||||
case FIELD.SRC_IPV4: if (len === 4) srcAddr = formatIPv4(buf, off); break;
|
||||
case FIELD.DST_IPV4: if (len === 4) dstAddr = formatIPv4(buf, off); break;
|
||||
case FIELD.SRC_IPV6: if (len === 16 && !srcAddr) srcAddr = formatIPv6(buf, off); break;
|
||||
case FIELD.DST_IPV6: if (len === 16 && !dstAddr) dstAddr = formatIPv6(buf, off); break;
|
||||
}
|
||||
}
|
||||
off += len;
|
||||
}
|
||||
|
||||
if (!valid) break;
|
||||
// Only emit records that look like flows (options/stats records lack these)
|
||||
if (srcAddr && dstAddr && bytes >= 0) {
|
||||
flows.push({ srcAddr, dstAddr, srcPort, dstPort, protocol, bytes, packets });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function decodePacket(buf: Buffer, exporterKey: string, cache: TemplateCache): DecodeResult {
|
||||
const result: DecodeResult = { flows: [], templatesParsed: 0, recordsWithoutTemplate: 0 };
|
||||
if (buf.length < 16) return result;
|
||||
|
||||
const version = buf.readUInt16BE(0);
|
||||
let offset: number;
|
||||
let end: number;
|
||||
let sourceId: number;
|
||||
let ipfix: boolean;
|
||||
|
||||
if (version === 9) {
|
||||
if (buf.length < 20) return result;
|
||||
ipfix = false;
|
||||
sourceId = buf.readUInt32BE(16);
|
||||
offset = 20;
|
||||
end = buf.length;
|
||||
} else if (version === 10) {
|
||||
ipfix = true;
|
||||
const declaredLength = buf.readUInt16BE(2);
|
||||
sourceId = buf.readUInt32BE(12); // observation domain ID
|
||||
offset = 16;
|
||||
end = Math.min(declaredLength, buf.length);
|
||||
} else {
|
||||
return result; // unsupported version (v5 etc.)
|
||||
}
|
||||
|
||||
while (offset + 4 <= end) {
|
||||
const setId = buf.readUInt16BE(offset);
|
||||
const setLength = buf.readUInt16BE(offset + 2);
|
||||
if (setLength < 4 || offset + setLength > end) break;
|
||||
const setStart = offset + 4;
|
||||
const setEnd = offset + setLength;
|
||||
|
||||
const isTemplateSet = ipfix ? setId === 2 : setId === 0;
|
||||
const isOptionsSet = ipfix ? setId === 3 : setId === 1;
|
||||
|
||||
if (isTemplateSet) {
|
||||
result.templatesParsed += parseTemplates(buf, setStart, setEnd, ipfix, exporterKey, sourceId, cache);
|
||||
} else if (!isOptionsSet && setId >= 256) {
|
||||
const template = cache.get(`${exporterKey}|${sourceId}|${setId}`);
|
||||
if (template) {
|
||||
parseDataSet(buf, setStart, setEnd, template, result.flows);
|
||||
} else {
|
||||
result.recordsWithoutTemplate++;
|
||||
}
|
||||
}
|
||||
offset = setEnd;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -34,6 +34,11 @@ services:
|
||||
ENCRYPTION_KEY: ${ENCRYPTION_KEY:-changeme32byteslongencryptionkey}
|
||||
CORS_ORIGIN: ${CORS_ORIGIN:-https://localhost,http://localhost,https://127.0.0.1,http://127.0.0.1}
|
||||
PORT: 3001
|
||||
NETFLOW_BIND_PORT: 2055
|
||||
ports:
|
||||
# NetFlow/IPFIX collector — UDP goes straight to the backend (nginx not involved).
|
||||
# Host port must match the netflow_collector_port app setting (default 2055).
|
||||
- "${NETFLOW_PORT:-2055}:2055/udp"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mikrotik-manager-frontend",
|
||||
"version": "0.12.3-beta",
|
||||
"version": "0.13.0-beta",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -24,6 +24,8 @@ import NetworkServicesDNSPage from './pages/NetworkServicesDNSPage';
|
||||
import NetworkServicesNTPPage from './pages/NetworkServicesNTPPage';
|
||||
import NetworkServicesWireGuardPage from './pages/NetworkServicesWireGuardPage';
|
||||
import NetworkServicesSyslogPage from './pages/NetworkServicesSyslogPage';
|
||||
import NetworkServicesNetflowPage from './pages/NetworkServicesNetflowPage';
|
||||
import TrafficAnalyticsPage from './pages/TrafficAnalyticsPage';
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token);
|
||||
@@ -67,6 +69,8 @@ export default function App() {
|
||||
<Route path="network-services/ntp" element={<NetworkServicesNTPPage />} />
|
||||
<Route path="network-services/wireguard" element={<NetworkServicesWireGuardPage />} />
|
||||
<Route path="network-services/syslog" element={<NetworkServicesSyslogPage />} />
|
||||
<Route path="network-services/netflow" element={<NetworkServicesNetflowPage />} />
|
||||
<Route path="traffic" element={<TrafficAnalyticsPage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import {
|
||||
LayoutDashboard, Router, Users, Bell, GitBranch, HardDrive,
|
||||
Settings, Network, ChevronLeft, ChevronRight, Layers, ChevronDown, SlidersHorizontal, X, Wifi,
|
||||
Server, Globe, Clock, Shield, FileText,
|
||||
Server, Globe, Clock, Shield, FileText, Activity, BarChart3,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { APP_VERSION } from '../../version';
|
||||
@@ -14,6 +14,7 @@ const navItems = [
|
||||
{ to: '/dashboard', icon: LayoutDashboard, label: 'Dashboard' },
|
||||
{ to: '/devices', icon: Router, label: 'Devices' },
|
||||
{ to: '/clients', icon: Users, label: 'Clients' },
|
||||
{ to: '/traffic', icon: Activity, label: 'Traffic' },
|
||||
{ to: '/events', icon: Bell, label: 'Events' },
|
||||
{ to: '/topology', icon: GitBranch, label: 'Topology' },
|
||||
{ to: '/backups', icon: HardDrive, label: 'Backups' },
|
||||
@@ -42,6 +43,7 @@ const networkServicesSubItems = [
|
||||
{ to: '/network-services/ntp', icon: Clock, label: 'NTP' },
|
||||
{ to: '/network-services/wireguard', icon: Shield, label: 'WireGuard' },
|
||||
{ to: '/network-services/syslog', icon: FileText, label: 'Syslog' },
|
||||
{ to: '/network-services/netflow', icon: BarChart3, label: 'NetFlow' },
|
||||
];
|
||||
|
||||
interface SidebarProps {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
BarChart, Bar, Cell, ReferenceLine,
|
||||
} from 'recharts';
|
||||
import { format, formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import { clientsApi } from '../services/api';
|
||||
import { clientsApi, trafficApi } from '../services/api';
|
||||
import type { ClientDetail } from '../services/api';
|
||||
import type { SignalPoint } from '../services/api';
|
||||
import clsx from 'clsx';
|
||||
@@ -581,6 +581,127 @@ function TrafficCard({ mac }: { mac: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── App Traffic (NetFlow) ────────────────────────────────────────────────────
|
||||
|
||||
const APP_COLORS = [
|
||||
'#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#06b6d4',
|
||||
'#ec4899', '#84cc16', '#f97316', '#6366f1', '#14b8a6', '#a855f7',
|
||||
];
|
||||
|
||||
function AppTrafficCard({ mac }: { mac: string }) {
|
||||
const [range, setRange] = useState<'1h' | '24h' | '7d' | '30d'>('24h');
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['client-app-traffic', mac, range],
|
||||
queryFn: () => trafficApi.client(mac, range).then(r => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
const ranges = [
|
||||
{ value: '1h', label: '1h' },
|
||||
{ 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'),
|
||||
}));
|
||||
|
||||
const totalAppBytes = apps.reduce((s, a) => s + a.bytes, 0);
|
||||
|
||||
return (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-gray-800 dark:text-white flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-gray-400" />
|
||||
App Traffic
|
||||
<span className="text-xs font-normal text-gray-400 dark:text-slate-500">(NetFlow)</span>
|
||||
</h2>
|
||||
<div className="flex gap-1">
|
||||
{ranges.map(r => (
|
||||
<button
|
||||
key={r.value}
|
||||
onClick={() => setRange(r.value)}
|
||||
className={clsx(
|
||||
'px-2 py-1 text-xs rounded font-medium transition-colors',
|
||||
range === r.value
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'text-gray-500 dark:text-slate-400 hover:bg-gray-100 dark:hover:bg-slate-700'
|
||||
)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="h-48 bg-gray-100 dark:bg-slate-700/40 rounded animate-pulse" />
|
||||
) : (
|
||||
<>
|
||||
{chartData.length > 0 && (
|
||||
<ResponsiveContainer width="100%" height={180}>
|
||||
<AreaChart data={chartData} margin={{ top: 4, right: 8, bottom: 0, left: 0 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
tick={{ fontSize: 10, fill: 'currentColor' }}
|
||||
className="text-gray-400 dark:text-slate-500"
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis
|
||||
tickFormatter={(v) => formatBytes(v)}
|
||||
tick={{ fontSize: 10, fill: 'currentColor' }}
|
||||
className="text-gray-400 dark:text-slate-500"
|
||||
width={60}
|
||||
/>
|
||||
<Tooltip
|
||||
formatter={(value: number) => [formatBytes(value), '']}
|
||||
contentStyle={{
|
||||
fontSize: '11px',
|
||||
backgroundColor: 'var(--tooltip-bg, #1e293b)',
|
||||
borderColor: 'var(--tooltip-border, #334155)',
|
||||
color: 'var(--tooltip-color, #e2e8f0)',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '11px' }} />
|
||||
<Area type="monotone" dataKey="Download" stroke="#3b82f6" fill="#3b82f620" strokeWidth={1.5} dot={false} />
|
||||
<Area type="monotone" dataKey="Upload" stroke="#8b5cf6" fill="#8b5cf620" strokeWidth={1.5} dot={false} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
|
||||
{apps.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{apps.slice(0, 8).map((a, i) => (
|
||||
<div key={a.app} className="flex items-center gap-2 text-xs">
|
||||
<span className="w-2 h-2 rounded-sm flex-shrink-0" style={{ backgroundColor: APP_COLORS[i % APP_COLORS.length] }} />
|
||||
<span className="text-gray-700 dark:text-slate-300 flex-1 truncate">{a.app}</span>
|
||||
<span className="text-gray-400 dark:text-slate-500">
|
||||
{totalAppBytes > 0 ? `${((a.bytes / totalAppBytes) * 100).toFixed(1)}%` : ''}
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white w-16 text-right">{formatBytes(a.bytes)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Signal Strength Graph ────────────────────────────────────────────────────
|
||||
|
||||
function qualityColor(dbm: number): string {
|
||||
@@ -800,12 +921,15 @@ export default function ClientDetailPage() {
|
||||
{/* Details on left; Traffic + Signal stacked on right */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 items-start">
|
||||
<ClientDetailsCard client={client} canWrite={canWrite} />
|
||||
{client.client_type === 'wireless' && (
|
||||
<div className="space-y-4">
|
||||
<TrafficCard mac={mac!} />
|
||||
<SignalCard mac={mac!} />
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-4">
|
||||
<AppTrafficCard mac={client.mac_address} />
|
||||
{client.client_type === 'wireless' && (
|
||||
<>
|
||||
<TrafficCard mac={mac!} />
|
||||
<SignalCard mac={mac!} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -19,6 +19,13 @@ const REFRESH_OPTIONS = [
|
||||
|
||||
const STORAGE_KEY = 'clients-refresh-interval';
|
||||
|
||||
function formatDataBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
function readStoredInterval(): number | null {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw === 'null') return null;
|
||||
@@ -189,6 +196,7 @@ export default function ClientsPage() {
|
||||
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 = '';
|
||||
}
|
||||
@@ -298,6 +306,7 @@ export default function ClientsPage() {
|
||||
{ col: 'interface_name', label: 'Port', align: 'left' },
|
||||
{ col: 'vlan_id', label: 'VLAN', align: 'left' },
|
||||
{ col: 'device_name', label: 'Device', align: 'left' },
|
||||
{ col: 'traffic_today_bytes', label: 'Data (today)', align: 'left' },
|
||||
{ col: 'last_seen', label: 'Last Seen', align: 'left' },
|
||||
] as { col: string; label: string; align: 'left' | 'right' }[]).map(({ col, label, align }) => (
|
||||
<th
|
||||
@@ -392,6 +401,9 @@ export default function ClientsPage() {
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400">
|
||||
{client.device_name || '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-500 dark:text-slate-400 whitespace-nowrap">
|
||||
{client.traffic_today_bytes ? formatDataBytes(client.traffic_today_bytes) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-xs text-gray-400 dark:text-slate-500">
|
||||
{client.last_seen
|
||||
? formatDistanceToNow(new Date(client.last_seen), { addSuffix: true })
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
BarChart3, RefreshCw, AlertTriangle, Save, CheckCircle, XCircle, Radio,
|
||||
} from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
import { formatDistanceToNow, parseISO } from 'date-fns';
|
||||
import { networkServicesApi, settingsApi, trafficApi } from '../services/api';
|
||||
import { useCanWrite } from '../hooks/useCanWrite';
|
||||
|
||||
function Toggle({ checked, onChange, disabled }: { checked: boolean; onChange: (v: boolean) => void; disabled?: boolean }) {
|
||||
return (
|
||||
<button
|
||||
role="switch" aria-checked={checked}
|
||||
onClick={() => !disabled && onChange(!checked)}
|
||||
className={clsx(
|
||||
'relative inline-flex h-5 w-9 flex-shrink-0 rounded-full border-2 border-transparent transition-colors',
|
||||
checked ? 'bg-blue-600' : 'bg-gray-300 dark:bg-slate-600',
|
||||
disabled && 'opacity-50 cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<span className={clsx('pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow transform transition-transform', checked ? 'translate-x-4' : 'translate-x-0')} />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export default function NetworkServicesNetflowPage() {
|
||||
const canWrite = useCanWrite();
|
||||
const qc = useQueryClient();
|
||||
|
||||
// ── Collector settings ────────────────────────────────────────────────────
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['app-settings'],
|
||||
queryFn: () => settingsApi.get().then(r => r.data),
|
||||
});
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [address, setAddress] = useState('');
|
||||
const [port, setPort] = useState('2055');
|
||||
const [version, setVersion] = useState('9');
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveMsg, setSaveMsg] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (settings && !loaded) {
|
||||
setEnabled(settings['netflow_enabled'] === true);
|
||||
setAddress(String(settings['netflow_collector_address'] ?? ''));
|
||||
setPort(String(settings['netflow_collector_port'] ?? '2055'));
|
||||
setVersion(String(settings['netflow_version'] ?? '9'));
|
||||
setLoaded(true);
|
||||
}
|
||||
}, [settings, loaded]);
|
||||
|
||||
async function saveCollectorSettings(overrides: Record<string, unknown> = {}) {
|
||||
setSaving(true); setSaveMsg('');
|
||||
try {
|
||||
await settingsApi.update({
|
||||
netflow_enabled: enabled,
|
||||
netflow_collector_address: address.trim(),
|
||||
netflow_collector_port: parseInt(port, 10) || 2055,
|
||||
netflow_version: version,
|
||||
...overrides,
|
||||
});
|
||||
qc.invalidateQueries({ queryKey: ['app-settings'] });
|
||||
qc.invalidateQueries({ queryKey: ['traffic-status'] });
|
||||
setSaveMsg('Saved');
|
||||
setTimeout(() => setSaveMsg(''), 3000);
|
||||
} catch (e) {
|
||||
setSaveMsg(`Failed: ${(e as Error).message}`);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleListenerToggle(value: boolean) {
|
||||
setEnabled(value);
|
||||
await saveCollectorSettings({ netflow_enabled: value });
|
||||
}
|
||||
|
||||
// ── Collector status (live) ───────────────────────────────────────────────
|
||||
const { data: status } = useQuery({
|
||||
queryKey: ['traffic-status'],
|
||||
queryFn: () => trafficApi.status().then(r => r.data),
|
||||
refetchInterval: 10_000,
|
||||
});
|
||||
|
||||
// ── Fleet device state ────────────────────────────────────────────────────
|
||||
const { data: fleet, isLoading: fleetLoading, refetch, isFetching, error: fleetError } = useQuery({
|
||||
queryKey: ['ns-netflow-fleet'],
|
||||
queryFn: () => networkServicesApi.netflowFleet().then(r => r.data),
|
||||
});
|
||||
|
||||
const [togglePending, setTogglePending] = useState<number | null>(null);
|
||||
const [toggleError, setToggleError] = useState('');
|
||||
|
||||
async function handleDeviceToggle(deviceId: number, value: boolean) {
|
||||
setTogglePending(deviceId); setToggleError('');
|
||||
try {
|
||||
await networkServicesApi.setNetflow(deviceId, value);
|
||||
qc.invalidateQueries({ queryKey: ['ns-netflow-fleet'] });
|
||||
} catch (e) {
|
||||
const err = e as { response?: { data?: { error?: string } }; message: string };
|
||||
setToggleError(err.response?.data?.error || err.message);
|
||||
} finally {
|
||||
setTogglePending(null);
|
||||
}
|
||||
}
|
||||
|
||||
const addressMissing = !address.trim();
|
||||
const devices = fleet?.devices ?? [];
|
||||
const unidentified = (status?.exporters ?? []).filter(e => e.deviceId < 0);
|
||||
|
||||
function deviceBadge(d: (typeof devices)[number]) {
|
||||
if (d.error) {
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-400"><XCircle className="w-3 h-3" />Unreachable</span>;
|
||||
}
|
||||
if (!d.enabled) {
|
||||
return <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400">Disabled</span>;
|
||||
}
|
||||
if (!d.target_matches_collector) {
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-400"><AlertTriangle className="w-3 h-3" />Wrong target</span>;
|
||||
}
|
||||
if (!d.last_flow_at) {
|
||||
return <span className="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-400">No flows yet</span>;
|
||||
}
|
||||
return <span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400"><CheckCircle className="w-3 h-3" />Exporting</span>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">NetFlow</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">
|
||||
Per-client traffic analytics via NetFlow/IPFIX export — view results on the{' '}
|
||||
<Link to="/traffic" className="text-blue-600 dark:text-blue-400 hover:underline">Traffic</Link> page
|
||||
</p>
|
||||
</div>
|
||||
<button onClick={() => refetch()} disabled={isFetching}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 disabled:opacity-50 transition-colors">
|
||||
<RefreshCw className={clsx('w-3.5 h-3.5', isFetching && 'animate-spin')} />Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Collector card */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<Radio className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Collector</h2>
|
||||
{status && (
|
||||
<span className={clsx('inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium',
|
||||
status.listening
|
||||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-400'
|
||||
: 'bg-gray-100 dark:bg-slate-700 text-gray-500 dark:text-slate-400')}>
|
||||
<span className={clsx('w-1.5 h-1.5 rounded-full', status.listening ? 'bg-green-500' : 'bg-gray-400')} />
|
||||
{status.listening ? `Listening on udp/${status.port}` : 'Stopped'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Toggle checked={enabled} onChange={handleListenerToggle} disabled={!canWrite || saving} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-gray-700 dark:text-slate-200">Enable NetFlow collector</div>
|
||||
<p className="text-xs text-gray-400 dark:text-slate-500">
|
||||
Receives NetFlow v9 / IPFIX exports from your MikroTik devices and aggregates per-client traffic.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div className="sm:col-span-1">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">
|
||||
Collector Address <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input className="input w-full" value={address} onChange={e => setAddress(e.target.value)}
|
||||
placeholder="192.168.1.100" disabled={!canWrite} />
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-slate-500">
|
||||
The IP of this server as reachable by your devices (the Docker host IP, not the container).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">Port</label>
|
||||
<input type="number" className="input w-full" value={port} onChange={e => setPort(e.target.value)}
|
||||
min="1" max="65535" disabled={!canWrite} />
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-slate-500">
|
||||
Must match the host port mapping (NETFLOW_PORT, default 2055).
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-slate-200 mb-1">Version</label>
|
||||
<select className="input w-full" value={version} onChange={e => setVersion(e.target.value)} disabled={!canWrite}>
|
||||
<option value="9">NetFlow v9</option>
|
||||
<option value="ipfix">IPFIX</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{addressMissing && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-amber-600 dark:text-amber-400">
|
||||
<AlertTriangle className="w-3.5 h-3.5" />Set the collector address before enabling devices below.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{canWrite && (
|
||||
<button onClick={() => saveCollectorSettings()} disabled={saving}
|
||||
className="flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg disabled:opacity-50 transition-colors">
|
||||
<Save className="w-3.5 h-3.5" />{saving ? 'Saving…' : 'Save Settings'}
|
||||
</button>
|
||||
)}
|
||||
{saveMsg && (
|
||||
<span className={clsx('text-sm', saveMsg === 'Saved' ? 'text-green-600 dark:text-green-400' : 'text-red-500')}>{saveMsg}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{unidentified.length > 0 && (
|
||||
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-50 dark:bg-amber-900/20 text-xs text-amber-700 dark:text-amber-400">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0 mt-0.5" />
|
||||
<div>
|
||||
Receiving flows from unidentified source{unidentified.length > 1 ? 's' : ''}{' '}
|
||||
<span className="font-mono">{unidentified.map(e => e.deviceName.replace(/^Unidentified \((.+)\)$/, '$1')).join(', ')}</span>.
|
||||
This usually means NAT sits between your routers and the collector. Flows are still
|
||||
processed and attributed to clients, but can't be tied to a specific exporting device.
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && status.packetsReceived > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 pt-2 border-t border-gray-100 dark:border-slate-800">
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">Packets received</div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{status.packetsReceived.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">Flows decoded</div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{status.flowsDecoded.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">Flows attributed</div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{status.flowsAttributed.toLocaleString()}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">Unknown exporters</div>
|
||||
<div className="text-sm font-semibold text-gray-900 dark:text-white">{status.packetsFromUnknownExporter.toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Devices */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-indigo-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">
|
||||
Devices<span className="ml-2 text-xs font-normal text-gray-400 dark:text-slate-500">({devices.length} online)</span>
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{toggleError && (
|
||||
<div className="px-5 py-3 flex items-center gap-2 text-sm text-red-600 dark:text-red-400 border-b border-gray-100 dark:border-slate-800">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />{toggleError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fleetLoading && <div className="p-8 text-center text-sm text-gray-400 dark:text-slate-500">Loading device state…</div>}
|
||||
{!!fleetError && (
|
||||
<div className="p-4 flex items-center gap-2 text-sm text-red-600 dark:text-red-400">
|
||||
<AlertTriangle className="w-4 h-4 flex-shrink-0" />Failed: {(fleetError as Error).message}
|
||||
</div>
|
||||
)}
|
||||
{!fleetLoading && !fleetError && devices.length === 0 && (
|
||||
<div className="p-8 text-center text-sm text-gray-400 dark:text-slate-500">No online devices found.</div>
|
||||
)}
|
||||
|
||||
{devices.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 dark:border-slate-700 bg-gray-50 dark:bg-slate-800/40">
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Device</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Status</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Flows</th>
|
||||
<th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Last Flow</th>
|
||||
<th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-500 dark:text-slate-400 uppercase tracking-wide">Export</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices.map((d, i) => (
|
||||
<tr key={d.id}
|
||||
className={clsx('border-b border-gray-100 dark:border-slate-800 transition-colors hover:bg-blue-50 dark:hover:bg-slate-700/40',
|
||||
i % 2 === 0 ? 'bg-white dark:bg-transparent' : 'bg-gray-50 dark:bg-slate-800/40')}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="font-medium text-gray-900 dark:text-white">{d.name}</div>
|
||||
<div className="font-mono text-xs text-gray-400 dark:text-slate-500">{d.ip_address}</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">{deviceBadge(d)}</td>
|
||||
<td className="px-4 py-3 text-gray-700 dark:text-slate-300">{d.flows_received.toLocaleString()}</td>
|
||||
<td className="px-4 py-3 text-gray-500 dark:text-slate-400 text-xs">
|
||||
{d.last_flow_at ? formatDistanceToNow(parseISO(d.last_flow_at), { addSuffix: true }) : '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
<div className="flex justify-end">
|
||||
<Toggle
|
||||
checked={!!d.enabled && d.target_matches_collector}
|
||||
onChange={v => handleDeviceToggle(d.id, v)}
|
||||
disabled={!canWrite || !!d.error || togglePending === d.id || (addressMissing && !d.enabled)}
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -667,6 +667,8 @@ export default function SettingsPage() {
|
||||
{[
|
||||
{ key: 'retention_events_days', label: 'Events retention', desc: 'Auto-delete event log entries older than this many days' },
|
||||
{ key: 'retention_clients_days', label: 'Client retention', desc: 'Auto-delete inactive client records not seen within this many days' },
|
||||
{ key: 'netflow_retention_days', label: 'Traffic detail retention', desc: 'Auto-delete per-client NetFlow time-series data older than this many days' },
|
||||
{ key: 'netflow_daily_retention_days', label: 'Traffic daily rollup retention', desc: 'Auto-delete per-client daily traffic totals older than this many days' },
|
||||
].map(({ key, label, desc }) => (
|
||||
<div key={key} className="flex items-center justify-between gap-4">
|
||||
<div>
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
import { useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
|
||||
PieChart, Pie, Cell, Legend,
|
||||
} from 'recharts';
|
||||
import { Activity, ArrowDown, ArrowUp, BarChart3, Settings2 } from 'lucide-react';
|
||||
import { format, parseISO } from 'date-fns';
|
||||
import clsx from 'clsx';
|
||||
import { trafficApi } from '../services/api';
|
||||
|
||||
type Range = '1h' | '24h' | '7d' | '30d';
|
||||
const RANGES: Range[] = ['1h', '24h', '7d', '30d'];
|
||||
|
||||
const APP_COLORS = [
|
||||
'#3b82f6', '#8b5cf6', '#10b981', '#f59e0b', '#ef4444', '#06b6d4',
|
||||
'#ec4899', '#84cc16', '#f97316', '#6366f1', '#14b8a6', '#a855f7',
|
||||
'#64748b', '#0ea5e9', '#d946ef', '#22c55e',
|
||||
];
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
if (bytes < 1024 ** 4) return `${(bytes / 1024 ** 3).toFixed(2)} GB`;
|
||||
return `${(bytes / 1024 ** 4).toFixed(2)} TB`;
|
||||
}
|
||||
|
||||
function timeFormatter(range: Range) {
|
||||
return (t: string) => {
|
||||
try {
|
||||
const d = parseISO(t);
|
||||
return range === '7d' || range === '30d' ? format(d, 'MMM d') : format(d, 'HH:mm');
|
||||
} catch {
|
||||
return t;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function clientLabel(c: { mac: string; custom_name: string | null; hostname: string | null; vendor: string | null }): string {
|
||||
if (c.mac === 'unknown') return 'Unattributed (local)';
|
||||
if (c.mac === 'other') return 'Other clients';
|
||||
return c.custom_name || c.hostname || c.vendor || c.mac;
|
||||
}
|
||||
|
||||
export default function TrafficAnalyticsPage() {
|
||||
const [range, setRange] = useState<Range>('24h');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: series = [], isLoading: seriesLoading } = useQuery({
|
||||
queryKey: ['traffic-timeseries', range],
|
||||
queryFn: () => trafficApi.timeseries(range).then(r => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: topClients = [], isLoading: clientsLoading } = useQuery({
|
||||
queryKey: ['traffic-top-clients', range],
|
||||
queryFn: () => trafficApi.topClients(range, 10).then(r => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const { data: apps = [], isLoading: appsLoading } = useQuery({
|
||||
queryKey: ['traffic-apps', range],
|
||||
queryFn: () => trafficApi.apps(range).then(r => r.data),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
|
||||
const isLoading = seriesLoading || clientsLoading || appsLoading;
|
||||
const hasData = series.length > 0 || topClients.length > 0 || apps.length > 0;
|
||||
|
||||
const totalUpload = series.reduce((s, p) => s + p.upload, 0);
|
||||
const totalDownload = series.reduce((s, p) => s + p.download, 0);
|
||||
const totalAppBytes = apps.reduce((s, a) => s + a.bytes, 0);
|
||||
const maxClientBytes = topClients[0]?.total_bytes || 1;
|
||||
|
||||
// Donut: top 8 categories, rest folded into "Other"
|
||||
const donutData = (() => {
|
||||
const top = apps.slice(0, 8);
|
||||
const rest = apps.slice(8).reduce((s, a) => s + a.bytes, 0);
|
||||
return rest > 0 ? [...top, { app: 'Other', bytes: rest }] : top;
|
||||
})();
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Traffic</h1>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400">Per-client traffic analytics from NetFlow/IPFIX</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="card p-1 flex rounded-lg gap-1">
|
||||
{RANGES.map(r => (
|
||||
<button key={r} onClick={() => setRange(r)}
|
||||
className={clsx('px-3 py-1 rounded-md text-sm font-medium transition-colors',
|
||||
range === r
|
||||
? 'bg-blue-600 text-white shadow-sm'
|
||||
: 'text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700')}>
|
||||
{r}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<Link to="/network-services/netflow"
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-gray-300 dark:border-slate-600 text-sm text-gray-600 dark:text-slate-300 hover:bg-gray-100 dark:hover:bg-slate-700 transition-colors">
|
||||
<Settings2 className="w-3.5 h-3.5" />Configure
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty state */}
|
||||
{!isLoading && !hasData && (
|
||||
<div className="card p-12 text-center space-y-3">
|
||||
<Activity className="w-10 h-10 mx-auto text-gray-300 dark:text-slate-600" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">No traffic data yet</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-slate-400 max-w-md mx-auto">
|
||||
Enable the NetFlow collector and turn on traffic export for your routers to start
|
||||
collecting per-client usage and protocol analytics.
|
||||
</p>
|
||||
<Link to="/network-services/netflow"
|
||||
className="inline-flex items-center gap-1.5 px-4 py-1.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors">
|
||||
<Settings2 className="w-3.5 h-3.5" />Set up NetFlow
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasData && (
|
||||
<>
|
||||
{/* Totals */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="card p-4 flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900/30">
|
||||
<ArrowDown className="w-5 h-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">Download ({range})</div>
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">{formatBytes(totalDownload)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card p-4 flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-violet-100 dark:bg-violet-900/30">
|
||||
<ArrowUp className="w-5 h-5 text-violet-600 dark:text-violet-400" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-gray-400 dark:text-slate-500">Upload ({range})</div>
|
||||
<div className="text-lg font-bold text-gray-900 dark:text-white">{formatBytes(totalUpload)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Throughput chart */}
|
||||
<div className="card p-5">
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200 mb-4">Network Traffic</h2>
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={series}>
|
||||
<defs>
|
||||
<linearGradient id="dl" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#3b82f6" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="#3b82f6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
<linearGradient id="ul" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#8b5cf6" stopOpacity={0.35} />
|
||||
<stop offset="95%" stopColor="#8b5cf6" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" className="stroke-gray-200 dark:stroke-slate-700" />
|
||||
<XAxis dataKey="time" tickFormatter={timeFormatter(range)} tick={{ fontSize: 11 }} stroke="currentColor" />
|
||||
<YAxis tickFormatter={v => formatBytes(v)} tick={{ fontSize: 11 }} width={70} stroke="currentColor" />
|
||||
<Tooltip
|
||||
labelFormatter={t => { try { return format(parseISO(String(t)), 'MMM d, HH:mm'); } catch { return String(t); } }}
|
||||
formatter={(value: number, name: string) => [formatBytes(value), name === 'download' ? 'Download' : 'Upload']}
|
||||
/>
|
||||
<Legend formatter={(v: string) => (v === 'download' ? 'Download' : 'Upload')} />
|
||||
<Area type="monotone" dataKey="download" stroke="#3b82f6" fill="url(#dl)" strokeWidth={2} />
|
||||
<Area type="monotone" dataKey="upload" stroke="#8b5cf6" fill="url(#ul)" strokeWidth={2} />
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Top talkers */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<BarChart3 className="w-4 h-4 text-blue-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Top Talkers</h2>
|
||||
</div>
|
||||
{topClients.length === 0
|
||||
? <div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">No client traffic recorded.</div>
|
||||
: (
|
||||
<div className="divide-y divide-gray-100 dark:divide-slate-800">
|
||||
{topClients.map(c => {
|
||||
const isReal = c.mac !== 'unknown' && c.mac !== 'other';
|
||||
return (
|
||||
<div key={c.mac}
|
||||
onClick={() => isReal && navigate(`/clients/${encodeURIComponent(c.mac)}`)}
|
||||
className={clsx('px-5 py-3', isReal && 'cursor-pointer hover:bg-blue-50 dark:hover:bg-slate-700/40 transition-colors')}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<div className="min-w-0">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-white truncate">{clientLabel(c)}</span>
|
||||
{isReal && (
|
||||
<span className="ml-2 font-mono text-xs text-gray-400 dark:text-slate-500">{c.ip_address || c.mac}</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="text-sm font-semibold text-gray-700 dark:text-slate-200 flex-shrink-0">{formatBytes(c.total_bytes)}</span>
|
||||
</div>
|
||||
<div className="h-1.5 rounded-full bg-gray-100 dark:bg-slate-700 overflow-hidden">
|
||||
<div className="h-full rounded-full bg-blue-500" style={{ width: `${Math.max(2, (c.total_bytes / maxClientBytes) * 100)}%` }} />
|
||||
</div>
|
||||
<div className="mt-1 flex gap-3 text-xs text-gray-400 dark:text-slate-500">
|
||||
<span className="inline-flex items-center gap-0.5"><ArrowDown className="w-3 h-3" />{formatBytes(c.download_bytes)}</span>
|
||||
<span className="inline-flex items-center gap-0.5"><ArrowUp className="w-3 h-3" />{formatBytes(c.upload_bytes)}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* App categories */}
|
||||
<div className="card overflow-hidden">
|
||||
<div className="px-5 py-3 border-b border-gray-200 dark:border-slate-700 flex items-center gap-2">
|
||||
<Activity className="w-4 h-4 text-indigo-500" />
|
||||
<h2 className="text-sm font-semibold text-gray-700 dark:text-slate-200">Traffic by Application</h2>
|
||||
</div>
|
||||
{apps.length === 0
|
||||
? <div className="p-6 text-center text-sm text-gray-400 dark:text-slate-500">No application data recorded.</div>
|
||||
: (
|
||||
<div className="p-5">
|
||||
<ResponsiveContainer width="100%" height={220}>
|
||||
<PieChart>
|
||||
<Pie data={donutData} dataKey="bytes" nameKey="app" innerRadius={55} outerRadius={85} paddingAngle={2}>
|
||||
{donutData.map((entry, i) => (
|
||||
<Cell key={entry.app} fill={APP_COLORS[i % APP_COLORS.length]} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip formatter={(value: number, name: string) => [formatBytes(value), name]} />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
<div className="mt-2 space-y-1.5">
|
||||
{apps.slice(0, 10).map((a, i) => (
|
||||
<div key={a.app} className="flex items-center gap-2 text-sm">
|
||||
<span className="w-2.5 h-2.5 rounded-sm flex-shrink-0" style={{ backgroundColor: APP_COLORS[i % APP_COLORS.length] }} />
|
||||
<span className="text-gray-700 dark:text-slate-300 flex-1 truncate">{a.app}</span>
|
||||
<span className="text-gray-500 dark:text-slate-400 text-xs">
|
||||
{totalAppBytes > 0 ? `${((a.bytes / totalAppBytes) * 100).toFixed(1)}%` : ''}
|
||||
</span>
|
||||
<span className="font-medium text-gray-900 dark:text-white w-20 text-right">{formatBytes(a.bytes)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -775,6 +775,73 @@ export const networkServicesApi = {
|
||||
api.put<NS[]>(`/network-services/wireguard/peer/${encodeURIComponent(id)}`, body, { params: { deviceId } }),
|
||||
deleteWireGuardPeer: (deviceId: number, id: string) =>
|
||||
api.delete(`/network-services/wireguard/peer/${encodeURIComponent(id)}`, { params: { deviceId } }),
|
||||
|
||||
// ── NetFlow ───────────────────────────────────────────────────────────────
|
||||
netflowFleet: () =>
|
||||
api.get<{
|
||||
collector: { address: string; port: number; version: string; listening: boolean };
|
||||
devices: NetflowDeviceState[];
|
||||
}>('/network-services/netflow/fleet', { timeout: 60_000 }),
|
||||
getNetflow: (deviceId: number) =>
|
||||
api.get<{ settings: NS | null; targets: NS[]; target_matches_collector: boolean }>(
|
||||
'/network-services/netflow', { params: { deviceId } }
|
||||
),
|
||||
setNetflow: (deviceId: number, enabled: boolean) =>
|
||||
api.put<{ settings: NS | null; targets: NS[]; target_matches_collector: boolean }>(
|
||||
'/network-services/netflow', { enabled }, { params: { deviceId } }
|
||||
),
|
||||
};
|
||||
|
||||
export interface NetflowDeviceState {
|
||||
id: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
enabled: boolean | null;
|
||||
interfaces: string;
|
||||
targets: { id: string; dst_address: string; port: number; version: string; disabled: boolean }[];
|
||||
target_matches_collector: boolean;
|
||||
flows_received: number;
|
||||
last_flow_at: string | null;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
// ─── Traffic Analytics (NetFlow) ──────────────────────────────────────────────
|
||||
|
||||
export interface ClientTrafficPoint { time: string; upload: number; download: number }
|
||||
export interface TrafficTopClient {
|
||||
mac: string;
|
||||
upload_bytes: number;
|
||||
download_bytes: number;
|
||||
total_bytes: number;
|
||||
hostname: string | null;
|
||||
custom_name: string | null;
|
||||
vendor: string | null;
|
||||
ip_address: string | null;
|
||||
}
|
||||
export interface TrafficApp { app: string; bytes: number }
|
||||
export interface TrafficCollectorStats {
|
||||
listening: boolean;
|
||||
port: number;
|
||||
packetsReceived: number;
|
||||
flowsDecoded: number;
|
||||
flowsAttributed: number;
|
||||
packetsFromUnknownExporter: number;
|
||||
recordsWithoutTemplate: number;
|
||||
exporters: { deviceId: number; deviceName: string; packets: number; flows: number; lastSeen: string | null }[];
|
||||
}
|
||||
|
||||
export const trafficApi = {
|
||||
status: () => api.get<TrafficCollectorStats>('/traffic/status'),
|
||||
timeseries: (range = '24h') =>
|
||||
api.get<ClientTrafficPoint[]>('/traffic/timeseries', { params: { range } }),
|
||||
topClients: (range = '24h', limit = 10) =>
|
||||
api.get<TrafficTopClient[]>('/traffic/top-clients', { params: { range, limit } }),
|
||||
apps: (range = '24h', mac?: string) =>
|
||||
api.get<TrafficApp[]>('/traffic/apps', { params: { range, mac } }),
|
||||
client: (mac: string, range = '24h') =>
|
||||
api.get<{ series: ClientTrafficPoint[]; apps: TrafficApp[] }>(
|
||||
`/traffic/client/${encodeURIComponent(mac)}`, { params: { range } }
|
||||
),
|
||||
};
|
||||
|
||||
// ─── Maintenance Windows ──────────────────────────────────────────────────────
|
||||
|
||||
@@ -179,6 +179,8 @@ export interface Client {
|
||||
client_type: 'wired' | 'wireless';
|
||||
active: boolean;
|
||||
last_seen?: string;
|
||||
/** Today's NetFlow upload+download total (0 when NetFlow is not collecting). */
|
||||
traffic_today_bytes?: number;
|
||||
}
|
||||
|
||||
export interface Backup {
|
||||
|
||||
@@ -1 +1 @@
|
||||
export const APP_VERSION = 'v0.12.3 Beta';
|
||||
export const APP_VERSION = 'v0.13.0 Beta';
|
||||
|
||||
Reference in New Issue
Block a user