diff --git a/backend/routes/trafficRoutes.js b/backend/routes/trafficRoutes.js new file mode 100644 index 0000000..2fa6089 --- /dev/null +++ b/backend/routes/trafficRoutes.js @@ -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, +}; diff --git a/backend/server.js b/backend/server.js index f16f629..79519a0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -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); diff --git a/frontend/package-lock.json b/frontend/package-lock.json index af5ac83..d809031 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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, diff --git a/frontend/package.json b/frontend/package.json index a7be315..a71e0f6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7b14c42..1edad65 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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() {
} /> + } /> } /> } /> } /> @@ -526,6 +531,7 @@ function MainLayout() {
} /> + } /> } /> } /> } /> diff --git a/frontend/src/TrafficDashboard.jsx b/frontend/src/TrafficDashboard.jsx new file mode 100644 index 0000000..9cf62e9 --- /dev/null +++ b/frontend/src/TrafficDashboard.jsx @@ -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 ( +
+
+ + + +
+

+ {name || host || 'Jumphost'} +

+ {host && ( +
+ {host} +
+ )} +
+
+
+ {error && ( +
+ + {error} +
+ )} + {!error && !hasData && ( +
+ Нет данных по интерфейсам +
+ )} + {!error && hasData && chartData.length === 0 && ( +
+ Все счётчики нулевые +
+ )} + {!error && chartData.length > 0 && ( + <> +
+ Всего: {formatBytes(totalBytes)} (RX + TX) +
+ + + + percent > 0.05 ? `${name} ${(percent * 100).toFixed(0)}%` : '' + } + labelLine={false} + > + {chartData.map((entry, index) => ( + + ))} + + 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)}`; + }} + /> + { + const item = chartData.find((d) => d.name === value); + return item ? `${value} (${formatBytes(item.value)})` : value; + }} + wrapperStyle={{ fontSize: '11px' }} + /> + + + + )} +
+
+ ); +} + +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 ( +
+ } + meta="Статистика по интерфейсам MikroTik RouterOS (jumphost)" + actions={ + + } + /> + {error && ( +
+ + {error} +
+ )} + {loading && jumphosts.length === 0 && !error && ( +
+ Загрузка статистики с роутеров… +
+ )} + {!loading && !error && jumphosts.length === 0 && ( +
+ Нет jumphost-серверов с настроенным MikroTik API. Добавьте серверы типа «Jumphost» и укажите учётные данные RouterOS. +
+ )} + {!loading && jumphosts.length > 0 && ( +
+ {jumphosts.map((jh) => ( +
+ +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/components/CommandPalette.jsx b/frontend/src/components/CommandPalette.jsx index 24ea182..2cc319b 100644 --- a/frontend/src/components/CommandPalette.jsx +++ b/frontend/src/components/CommandPalette.jsx @@ -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'] },