feat(TrafficDashboard): add traffic statistics route and integrate TrafficDashboard component into the application
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s

This commit is contained in:
2026-02-17 13:19:16 +07:00
parent 630ceb30b7
commit 2b957c6d9a
7 changed files with 663 additions and 8 deletions
+98
View File
@@ -0,0 +1,98 @@
/**
* Роуты для сбора статистики трафика с MikroTik RouterOS (интерфейсы по jumphost).
* GET /api/traffic/interface-stats — статистика rx/tx по каждому интерфейсу для всех jumphost.
*/
const { sendError } = require('../middleware/errorHandler');
const { decrypt } = require('../utils/encryption');
const { readServersFromS3 } = require('./serversRoutes');
const { createRosClient } = require('../services/mikrotikApplyService');
/** Получить MikroTik credentials из сервера (jumphost или home) */
function getMikrotikCredentials(server) {
if (!server || (server.type !== 'jumphost' && server.type !== 'home')) return null;
const host = server.mikrotikHost || server.ip || server.dns;
if (!host) return null;
const port = server.mikrotikPort;
const restPort = (port === 80 || port === 443 || port === 8443) ? port : 80;
const user = server.mikrotikUser || 'admin';
let password = '';
if (server.encryptedMikrotikPassword) {
try {
password = decrypt(server.encryptedMikrotikPassword);
} catch (_) {
return null;
}
}
return { host, port: restPort, user, password, secure: false };
}
/**
* GET /api/traffic/interface-stats
* Возвращает по каждому jumphost список интерфейсов с rx-byte, tx-byte (из RouterOS REST /interface).
*/
async function getInterfaceStats(req, res) {
try {
const servers = await readServersFromS3();
const jumphosts = (Array.isArray(servers) ? servers : []).filter(
(s) => s && (String(s.type || '').toLowerCase() === 'jumphost' || String(s.type || '').toLowerCase() === 'home')
);
const results = await Promise.all(
jumphosts.map(async (server) => {
const creds = getMikrotikCredentials(server);
if (!creds) {
return {
serverId: server.id,
name: server.name || server.dns || server.ip || server.id,
host: server.mikrotikHost || server.ip || server.dns,
error: 'MikroTik API не настроен или нет пароля',
interfaces: [],
};
}
try {
const client = createRosClient(creds);
const apiRes = await client.print('interface');
const raw = apiRes?.data;
const list = Array.isArray(raw) ? raw : raw ? [raw] : [];
const interfaces = list.map((iface) => {
const rx = iface['rx-byte'] != null ? parseInt(String(iface['rx-byte']), 10) : 0;
const tx = iface['tx-byte'] != null ? parseInt(String(iface['tx-byte']), 10) : 0;
const total = rx + tx;
return {
name: iface.name != null ? String(iface.name) : '',
type: iface.type != null ? String(iface.type) : '',
rxBytes: Number.isNaN(rx) ? 0 : rx,
txBytes: Number.isNaN(tx) ? 0 : tx,
totalBytes: Number.isNaN(total) ? 0 : total,
};
}).filter((i) => i.name && i.totalBytes >= 0);
return {
serverId: server.id,
name: server.name || server.dns || server.ip || server.id,
host: server.mikrotikHost || server.ip || server.dns,
interfaces,
};
} catch (err) {
const msg = err.response?.data?.message || err.message || 'Ошибка запроса к RouterOS';
return {
serverId: server.id,
name: server.name || server.dns || server.ip || server.id,
host: server.mikrotikHost || server.ip || server.dns,
error: msg,
interfaces: [],
};
}
})
);
return res.json({ jumphosts: results });
} catch (error) {
console.error('getInterfaceStats:', error);
return sendError(res, 500, error.message || 'Ошибка сбора статистики трафика', 'E_TRAFFIC');
}
}
module.exports = {
getInterfaceStats,
};
+4
View File
@@ -29,6 +29,7 @@ const miscRoutes = require('./routes/miscRoutes');
const ipsecPasswordsRoutes = require('./routes/ipsecPasswordsRoutes');
const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes');
const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
const trafficRoutes = require('./routes/trafficRoutes');
const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
const app = express();
@@ -463,6 +464,9 @@ app.post('/api/mikrotik/traceroute', writeLimiter, mikrotikConfigRoutes.tracerou
app.post('/api/mikrotik/apply', mikrotikConfigRoutes.applyMikrotikConfig);
app.post('/api/mikrotik/run-script', mikrotikConfigRoutes.runScript);
// === TRAFFIC STATS (MikroTik interfaces by jumphost) ===
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
// === MIKROTIK BACKUPS (S3) ===
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups);
+345 -1
View File
@@ -20,7 +20,8 @@
"diff": "^8.0.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^6.30.1"
"react-router-dom": "^6.30.1",
"recharts": "^3.7.0"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
@@ -1010,6 +1011,42 @@
"url": "https://opencollective.com/popperjs"
}
},
"node_modules/@reduxjs/toolkit": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@standard-schema/utils": "^0.3.0",
"immer": "^11.0.0",
"redux": "^5.0.1",
"redux-thunk": "^3.1.0",
"reselect": "^5.1.0"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
},
"peerDependenciesMeta": {
"react": {
"optional": true
},
"react-redux": {
"optional": true
}
}
},
"node_modules/@reduxjs/toolkit/node_modules/immer": {
"version": "11.1.4",
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/@remix-run/router": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
@@ -1037,6 +1074,18 @@
"linux"
]
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"license": "MIT"
},
"node_modules/@standard-schema/utils": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
"license": "MIT"
},
"node_modules/@tabler/core": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/@tabler/core/-/core-1.3.2.tgz",
@@ -1189,6 +1238,12 @@
"@babel/types": "^7.20.7"
}
},
"node_modules/@types/d3-array": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
"license": "MIT"
},
"node_modules/@types/d3-color": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
@@ -1204,6 +1259,12 @@
"@types/d3-selection": "*"
}
},
"node_modules/@types/d3-ease": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
"license": "MIT"
},
"node_modules/@types/d3-interpolate": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
@@ -1213,12 +1274,48 @@
"@types/d3-color": "*"
}
},
"node_modules/@types/d3-path": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
"license": "MIT"
},
"node_modules/@types/d3-scale": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
"license": "MIT",
"dependencies": {
"@types/d3-time": "*"
}
},
"node_modules/@types/d3-selection": {
"version": "3.0.11",
"resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz",
"integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==",
"license": "MIT"
},
"node_modules/@types/d3-shape": {
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
"license": "MIT",
"dependencies": {
"@types/d3-path": "*"
}
},
"node_modules/@types/d3-time": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
"license": "MIT"
},
"node_modules/@types/d3-timer": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
"license": "MIT"
},
"node_modules/@types/d3-transition": {
"version": "3.0.9",
"resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz",
@@ -1264,6 +1361,12 @@
"@types/react": "^19.0.0"
}
},
"node_modules/@types/use-sync-external-store": {
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
"license": "MIT"
},
"node_modules/@vitejs/plugin-react": {
"version": "4.6.0",
"dev": true,
@@ -1485,6 +1588,15 @@
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
"license": "MIT"
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"dev": true,
@@ -1539,6 +1651,18 @@
"devOptional": true,
"license": "MIT"
},
"node_modules/d3-array": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
"license": "ISC",
"dependencies": {
"internmap": "1 - 2"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-color": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
@@ -1579,6 +1703,15 @@
"node": ">=12"
}
},
"node_modules/d3-format": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-interpolate": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
@@ -1591,6 +1724,31 @@
"node": ">=12"
}
},
"node_modules/d3-path": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/d3-scale": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
"license": "ISC",
"dependencies": {
"d3-array": "2.10.0 - 3",
"d3-format": "1 - 3",
"d3-interpolate": "1.2.0 - 3",
"d3-time": "2.1.1 - 3",
"d3-time-format": "2 - 4"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-selection": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz",
@@ -1600,6 +1758,42 @@
"node": ">=12"
}
},
"node_modules/d3-shape": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
"license": "ISC",
"dependencies": {
"d3-path": "^3.1.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
"license": "ISC",
"dependencies": {
"d3-array": "2 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-time-format": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
"license": "ISC",
"dependencies": {
"d3-time": "1 - 3"
},
"engines": {
"node": ">=12"
}
},
"node_modules/d3-timer": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
@@ -1660,6 +1854,12 @@
}
}
},
"node_modules/decimal.js-light": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
"license": "MIT"
},
"node_modules/deep-is": {
"version": "0.1.4",
"dev": true,
@@ -1735,6 +1935,16 @@
"node": ">= 0.4"
}
},
"node_modules/es-toolkit": {
"version": "1.44.0",
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.44.0.tgz",
"integrity": "sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==",
"license": "MIT",
"workspaces": [
"docs",
"benchmarks"
]
},
"node_modules/esbuild": {
"version": "0.25.5",
"dev": true,
@@ -1951,6 +2161,12 @@
"node": ">=0.10.0"
}
},
"node_modules/eventemitter3": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"dev": true,
@@ -2198,6 +2414,16 @@
"node": ">= 4"
}
},
"node_modules/immer": {
"version": "10.2.0",
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/immer"
}
},
"node_modules/import-fresh": {
"version": "3.3.1",
"dev": true,
@@ -2221,6 +2447,15 @@
"node": ">=0.8.19"
}
},
"node_modules/internmap": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
"license": "ISC",
"engines": {
"node": ">=12"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"dev": true,
@@ -2563,6 +2798,36 @@
"react": "^19.1.0"
}
},
"node_modules/react-is": {
"version": "19.2.4",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
"license": "MIT",
"peer": true
},
"node_modules/react-redux": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT",
"dependencies": {
"@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0"
},
"peerDependencies": {
"@types/react": "^18.2.25 || ^19",
"react": "^18.0 || ^19",
"redux": "^5.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"redux": {
"optional": true
}
}
},
"node_modules/react-refresh": {
"version": "0.17.0",
"dev": true,
@@ -2603,6 +2868,57 @@
"react-dom": ">=16.8"
}
},
"node_modules/recharts": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.7.0.tgz",
"integrity": "sha512-l2VCsy3XXeraxIID9fx23eCb6iCBsxUQDnE8tWm6DFdszVAO7WVY/ChAD9wVit01y6B2PMupYiMmQwhgPHc9Ew==",
"license": "MIT",
"workspaces": [
"www"
],
"dependencies": {
"@reduxjs/toolkit": "1.x.x || 2.x.x",
"clsx": "^2.1.1",
"decimal.js-light": "^2.5.1",
"es-toolkit": "^1.39.3",
"eventemitter3": "^5.0.1",
"immer": "^10.1.1",
"react-redux": "8.x.x || 9.x.x",
"reselect": "5.1.1",
"tiny-invariant": "^1.3.3",
"use-sync-external-store": "^1.2.2",
"victory-vendor": "^37.0.2"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/redux": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT"
},
"node_modules/redux-thunk": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
"license": "MIT",
"peerDependencies": {
"redux": "^5.0.0"
}
},
"node_modules/reselect": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
"license": "MIT"
},
"node_modules/resolve-from": {
"version": "4.0.0",
"dev": true,
@@ -2672,6 +2988,12 @@
"node": ">=8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.14",
"dev": true,
@@ -2764,6 +3086,28 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/victory-vendor": {
"version": "37.3.6",
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
"license": "MIT AND ISC",
"dependencies": {
"@types/d3-array": "^3.0.3",
"@types/d3-ease": "^3.0.0",
"@types/d3-interpolate": "^3.0.1",
"@types/d3-scale": "^4.0.2",
"@types/d3-shape": "^3.1.0",
"@types/d3-time": "^3.0.0",
"@types/d3-timer": "^3.0.0",
"d3-array": "^3.1.6",
"d3-ease": "^3.0.1",
"d3-interpolate": "^3.0.1",
"d3-scale": "^4.0.2",
"d3-shape": "^3.1.0",
"d3-time": "^3.0.0",
"d3-timer": "^3.0.1"
}
},
"node_modules/vite": {
"version": "7.0.2",
"dev": true,
+2 -1
View File
@@ -24,7 +24,8 @@
"diff": "^8.0.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^6.30.1"
"react-router-dom": "^6.30.1",
"recharts": "^3.7.0"
},
"devDependencies": {
"@eslint/js": "^9.29.0",
+11 -5
View File
@@ -22,7 +22,8 @@ import {
IconMenu2,
IconArrowsExchange,
IconLayoutSidebarLeftExpand,
IconLayoutNavbar
IconLayoutNavbar,
IconChartPie
} from '@tabler/icons-react';
import ServerManager from './ServerManager';
import FilterManager from './FilterManager';
@@ -36,6 +37,7 @@ import CommunitiesManager from './CommunitiesManager';
import NetworkConfigManager from './NetworkConfigManager';
import MikrotikTools from './MikrotikTools.jsx';
import Dashboard from './Dashboard';
import TrafficDashboard from './TrafficDashboard.jsx';
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
import PingServicesManager from './PingServicesManager.jsx';
import './App.css';
@@ -56,7 +58,7 @@ function LanguageProvider({ children }) {
const dict = {
ru: {
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
dashboard: 'Панель', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
dashboard: 'Панель', trafficTraffic: 'Расход трафика', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов',
light: 'Светлая', dark: 'Тёмная',
@@ -64,7 +66,7 @@ function LanguageProvider({ children }) {
},
en: {
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
dashboard: 'Dashboard', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
communities: 'Communities', servers: 'Servers', filters: 'Filters', billing: 'Billing', autoUrls: 'Auto URLs',
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services',
light: 'Light', dark: 'Dark',
@@ -189,8 +191,10 @@ function MainLayout() {
id: 'home',
title: t('home'),
icon: IconHome,
path: '/dashboard',
single: true
items: [
{ id: 'dashboard', title: t('dashboard'), path: '/dashboard', icon: IconHome },
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie }
]
},
{
id: 'data',
@@ -370,6 +374,7 @@ function MainLayout() {
<main id="main-content" className="container-xl mt-4 flex-grow-1" tabIndex="-1">
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
@@ -526,6 +531,7 @@ function MainLayout() {
<main id="main-content" tabIndex="-1">
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
+200
View File
@@ -0,0 +1,200 @@
import { useState, useEffect, useCallback } from 'react';
import api from './lib/api.js';
import {
IconChartPie,
IconRefresh,
IconServer,
IconAlertCircle,
} from '@tabler/icons-react';
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Legend,
Tooltip,
} from 'recharts';
import PageHeader from './components/PageHeader.jsx';
const CHART_COLORS = [
'#206bc4', '#4299e1', '#2fb344', '#5eba00', '#e64980', '#f76707',
'#fd7e14', '#fab005', '#e67700', '#20c997', '#0dcaf0', '#6f42c1',
'#6366f1', '#8b5cf6', '#a855f7', '#d63384', '#0d6efd', '#198754',
];
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
function JumphostCard({ jumphost }) {
const { name, host, error, interfaces } = jumphost;
const hasData = Array.isArray(interfaces) && interfaces.length > 0;
const totalBytes = hasData ? interfaces.reduce((acc, i) => acc + (i.totalBytes || 0), 0) : 0;
const chartData = hasData
? interfaces
.filter((i) => (i.totalBytes || 0) > 0)
.map((i, idx) => ({
name: i.name,
value: i.totalBytes,
rxBytes: i.rxBytes,
txBytes: i.txBytes,
fill: CHART_COLORS[idx % CHART_COLORS.length],
}))
: [];
return (
<div className="card h-100">
<div className="card-header d-flex align-items-center">
<span className="avatar avatar-sm me-2 bg-blue-lt text-blue">
<IconServer size={18} />
</span>
<div className="flex-grow-1 min-w-0">
<h3 className="card-title mb-0 text-truncate" title={name}>
{name || host || 'Jumphost'}
</h3>
{host && (
<div className="text-muted small text-truncate" title={host}>
{host}
</div>
)}
</div>
</div>
<div className="card-body">
{error && (
<div className="alert alert-warning py-2 mb-0 d-flex align-items-center">
<IconAlertCircle className="me-2 flex-shrink-0" size={18} />
<span className="small">{error}</span>
</div>
)}
{!error && !hasData && (
<div className="text-muted text-center py-4 small">
Нет данных по интерфейсам
</div>
)}
{!error && hasData && chartData.length === 0 && (
<div className="text-muted text-center py-4 small">
Все счётчики нулевые
</div>
)}
{!error && chartData.length > 0 && (
<>
<div className="mb-2 small text-muted">
Всего: <strong>{formatBytes(totalBytes)}</strong> (RX + TX)
</div>
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={90}
label={({ name, percent }) =>
percent > 0.05 ? `${name} ${(percent * 100).toFixed(0)}%` : ''
}
labelLine={false}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<Tooltip
formatter={(value) => formatBytes(value)}
contentStyle={{ fontSize: '12px' }}
labelFormatter={(name, payload) => {
const p = payload?.[0];
if (!p) return name;
return `${name} — RX: ${formatBytes(p.rxBytes || 0)}, TX: ${formatBytes(p.txBytes || 0)}`;
}}
/>
<Legend
formatter={(value, entry) => {
const item = chartData.find((d) => d.name === value);
return item ? `${value} (${formatBytes(item.value)})` : value;
}}
wrapperStyle={{ fontSize: '11px' }}
/>
</PieChart>
</ResponsiveContainer>
</>
)}
</div>
</div>
);
}
export default function TrafficDashboard() {
const [jumphosts, setJumphosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchStats = useCallback(async () => {
setLoading(true);
setError(null);
try {
const { data } = await api.get('/traffic/interface-stats');
setJumphosts(Array.isArray(data?.jumphosts) ? data.jumphosts : []);
} catch (e) {
console.error('Traffic stats:', e);
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить статистику');
setJumphosts([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
return (
<div>
<PageHeader
title="Расход трафика"
icon={<IconChartPie size={24} />}
meta="Статистика по интерфейсам MikroTik RouterOS (jumphost)"
actions={
<button
type="button"
className="btn btn-outline-primary"
onClick={fetchStats}
disabled={loading}
>
<IconRefresh className={loading ? 'spin me-2' : 'me-2'} size={18} />
{loading ? 'Загрузка…' : 'Обновить'}
</button>
}
/>
{error && (
<div className="alert alert-danger d-flex align-items-center">
<IconAlertCircle className="me-2" size={20} />
{error}
</div>
)}
{loading && jumphosts.length === 0 && !error && (
<div className="text-center py-5 text-muted">
Загрузка статистики с роутеров
</div>
)}
{!loading && !error && jumphosts.length === 0 && (
<div className="alert alert-info">
Нет jumphost-серверов с настроенным MikroTik API. Добавьте серверы типа «Jumphost» и укажите учётные данные RouterOS.
</div>
)}
{!loading && jumphosts.length > 0 && (
<div className="row row-cards g-3">
{jumphosts.map((jh) => (
<div key={jh.serverId || jh.host || jh.name} className="col-12 col-md-6 col-xl-4">
<JumphostCard jumphost={jh} />
</div>
))}
</div>
)}
</div>
);
}
+3 -1
View File
@@ -10,7 +10,8 @@ import {
IconDatabase,
IconCreditCard,
IconDownload,
IconKeyboard
IconKeyboard,
IconChartPie
} from '@tabler/icons-react'
/**
@@ -38,6 +39,7 @@ function CommandPalette() {
// Список команд
const commands = [
{ icon: IconHome, label: 'Главная', description: 'Панель управления', action: () => navigate('/dashboard'), keywords: ['главная', 'панель', 'dashboard'] },
{ icon: IconChartPie, label: 'Расход трафика', description: 'Статистика по интерфейсам MikroTik', action: () => navigate('/traffic'), keywords: ['трафик', 'traffic', 'mikrotik', 'интерфейсы'] },
{ icon: IconWorld, label: 'Домены', description: 'Управление доменами', action: () => navigate('/domains'), keywords: ['домены', 'domains'] },
{ icon: IconNetwork, label: 'IP-диапазоны', description: 'Управление IP диапазонами', action: () => navigate('/ip-ranges'), keywords: ['ip', 'диапазоны', 'ranges'] },
{ icon: IconNetwork, label: 'ASN', description: 'Управление Autonomous Systems', action: () => navigate('/asns'), keywords: ['asn', 'as', 'autonomous'] },