diff --git a/backend/routes/resourceStatsRoutes.js b/backend/routes/resourceStatsRoutes.js new file mode 100644 index 0000000..5a7d4ed --- /dev/null +++ b/backend/routes/resourceStatsRoutes.js @@ -0,0 +1,149 @@ +/** + * Роуты для сбора статистики ресурсов (RAM, CPU, HDD) с MikroTik RouterOS. + * GET /api/resources/stats — system/resource по каждому jumphost/home с учётами MikroTik. + */ + +const { sendError } = require('../middleware/errorHandler'); +const { decrypt } = require('../utils/encryption'); +const { readServersFromS3 } = require('./serversRoutes'); +const { createRosClient } = require('../services/mikrotikApplyService'); + +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 }; +} + +/** + * Парсинг system/resource в нормализованный объект для UI. + * MikroTik REST возвращает: total-memory, free-memory (bytes), cpu-load (%), + * total-hdd-space, free-hdd-space (bytes), uptime, version, board-name и др. + */ +function parseResource(raw) { + if (!raw || typeof raw !== 'object') return null; + const num = (v) => (v != null && v !== '' ? parseInt(String(v), 10) : null); + const totalMemory = num(raw['total-memory']); + const freeMemory = num(raw['free-memory']); + const usedMemory = totalMemory != null && freeMemory != null ? totalMemory - freeMemory : null; + const totalHdd = num(raw['total-hdd-space']); + const freeHdd = num(raw['free-hdd-space']); + const usedHdd = totalHdd != null && freeHdd != null ? totalHdd - freeHdd : null; + const cpuLoad = raw['cpu-load'] != null ? parseFloat(String(raw['cpu-load'])) : null; + + return { + // RAM (bytes и проценты) + totalMemory: totalMemory, + freeMemory: freeMemory, + usedMemory: usedMemory, + memoryUsagePercent: + totalMemory != null && totalMemory > 0 && usedMemory != null + ? Math.round((usedMemory / totalMemory) * 100) + : null, + // CPU (%) + cpuLoad: cpuLoad != null ? Math.round(cpuLoad) : null, + cpuCount: num(raw['cpu-count']) ?? null, + cpuFrequency: raw['cpu-frequency'] != null ? String(raw['cpu-frequency']) : null, + cpu: raw.cpu != null ? String(raw.cpu) : null, + // HDD (bytes и проценты) + totalHddSpace: totalHdd, + freeHddSpace: freeHdd, + usedHddSpace: usedHdd, + hddUsagePercent: + totalHdd != null && totalHdd > 0 && usedHdd != null + ? Math.round((usedHdd / totalHdd) * 100) + : null, + // прочее + uptime: raw.uptime != null ? String(raw.uptime) : null, + version: raw.version != null ? String(raw.version) : null, + boardName: raw['board-name'] != null ? String(raw['board-name']) : null, + architectureName: raw['architecture-name'] != null ? String(raw['architecture-name']) : null, + platform: raw.platform != null ? String(raw.platform) : null, + }; +} + +/** + * GET /api/resources/stats + * По каждому jumphost/home с MikroTik — запрос system/resource, ответ с группировкой по роутерам. + */ +async function getResourceStats(req, res) { + try { + const servers = await readServersFromS3(); + const routers = (Array.isArray(servers) ? servers : []).filter( + (s) => + s && + (String(s.type || '').toLowerCase() === 'jumphost' || + String(s.type || '').toLowerCase() === 'home') + ); + + const results = await Promise.all( + routers.map(async (server) => { + const serverId = server.id || server.dns || server.ip; + const name = server.name || server.dns || server.ip || serverId; + const host = server.mikrotikHost || server.ip || server.dns; + + const creds = getMikrotikCredentials(server); + if (!creds) { + return { + serverId, + name, + host, + groupKey: server.groupKey || null, + error: 'MikroTik API не настроен или нет пароля', + resource: null, + }; + } + try { + const client = createRosClient(creds); + const apiRes = await client.print('system/resource'); + const raw = apiRes?.data; + const single = Array.isArray(raw) ? raw[0] : raw; + const resource = parseResource(single); + return { + serverId, + name, + host, + groupKey: server.groupKey || null, + error: null, + resource, + }; + } catch (err) { + const msg = err.response?.data?.message || err.message || 'Ошибка запроса к RouterOS'; + return { + serverId, + name, + host, + groupKey: server.groupKey || null, + error: msg, + resource: null, + }; + } + }) + ); + + return res.json({ routers: results }); + } catch (error) { + console.error('getResourceStats:', error); + return sendError( + res, + 500, + error.message || 'Ошибка сбора статистики ресурсов', + 'E_RESOURCE_STATS' + ); + } +} + +module.exports = { + getResourceStats, +}; diff --git a/backend/server.js b/backend/server.js index a9f5c14..627f8fe 100644 --- a/backend/server.js +++ b/backend/server.js @@ -30,6 +30,7 @@ const ipsecPasswordsRoutes = require('./routes/ipsecPasswordsRoutes'); const mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes'); const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes'); const trafficRoutes = require('./routes/trafficRoutes'); +const resourceStatsRoutes = require('./routes/resourceStatsRoutes'); const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler'); const { initNetworkMapScheduler } = require('./services/networkMapScheduler'); const { initPingServicesScheduler } = require('./services/pingServicesScheduler'); @@ -479,6 +480,9 @@ app.post('/api/uptime/check', writeLimiter, mikrotikConfigRoutes.uptimeCheck); // === TRAFFIC STATS (MikroTik interfaces by jumphost) === app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats); +// === RESOURCE STATS (RAM/CPU/HDD по роутерам) === +app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats); + // === MIKROTIK BACKUPS (S3) === app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup); app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups); diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f3ce92e..e71cc55 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -24,6 +24,7 @@ import { IconLayoutSidebarLeftExpand, IconLayoutNavbar, IconChartPie, + IconChartBar, IconClock, IconClockPlay, IconShield @@ -49,6 +50,7 @@ import MikrotikBackupsManager from './MikrotikBackupsManager.jsx'; import PingServicesManager from './PingServicesManager.jsx'; import FirewallPage from './FirewallPage.jsx'; import SettingsPage from './SettingsPage.jsx'; +import ResourceStatsPage from './ResourceStatsPage.jsx'; import './App.css'; import { NotifyProvider } from './components/NotifyProvider.jsx'; import ToastContainer from './components/ToastContainer.jsx'; @@ -66,7 +68,7 @@ function LanguageProvider({ children }) { const dict = { ru: { home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты', - dashboard: 'Панель', trafficTraffic: 'Расход трафика', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS', + dashboard: 'Панель', trafficTraffic: 'Расход трафика', resourceStats: 'Статистика ресурсов', networkMap: 'Карта сети', uptimeMonitor: 'Uptime Monitor', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS', communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL', easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall', light: 'Светлая', dark: 'Тёмная', @@ -74,7 +76,7 @@ function LanguageProvider({ children }) { }, en: { home: 'Home', data: 'Data', management: 'Management', tools: 'Tools', - dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', networkMap: 'Network Map', uptimeMonitor: 'Uptime Monitor', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs', + dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', resourceStats: 'Resource Stats', networkMap: 'Network Map', uptimeMonitor: 'Uptime Monitor', 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', firewall: 'Firewall', light: 'Light', dark: 'Dark', @@ -201,6 +203,7 @@ function MainLayout() { items: [ { id: 'dashboard', title: t('dashboard'), path: '/dashboard', icon: IconHome }, { id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie }, + { id: 'resource-stats', title: t('resourceStats'), path: '/resource-stats', icon: IconChartBar }, { id: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork }, { id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock } ] @@ -394,6 +397,7 @@ function MainLayout() { } /> } /> + } /> } /> } /> } /> @@ -556,6 +560,7 @@ function MainLayout() { } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/ResourceStatsPage.jsx b/frontend/src/ResourceStatsPage.jsx new file mode 100644 index 0000000..716711f --- /dev/null +++ b/frontend/src/ResourceStatsPage.jsx @@ -0,0 +1,236 @@ +import { useState, useEffect, useCallback } from 'react'; +import api from './lib/api.js'; +import { + IconServer, + IconRefresh, + IconChartBar, + IconAlertCircle, +} from '@tabler/icons-react'; +import PageHeader from './components/PageHeader.jsx'; + +function formatBytes(bytes) { + if (bytes == null || bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +} + +/** Цвет прогресс-бара по проценту: зелёный / жёлтый / красный */ +function progressVariant(percent) { + if (percent == null) return 'secondary'; + if (percent >= 90) return 'danger'; + if (percent >= 70) return 'warning'; + return 'success'; +} + +/** Один KPI-блок: заголовок, значение, прогресс-бар (как на референсной карточке) */ +function KpiBlock({ label, value, percent, variant }) { + const barVariant = variant || progressVariant(percent); + return ( +
+
{label}
+
+ {value} + {percent != null && ( + {percent}% + )} +
+ {percent != null && ( +
+
+
+ )} +
+ ); +} + +/** Карточка одного роутера: приветствие (имя), сводка, KPI (RAM, CPU, HDD) + декоративная иконка справа */ +function RouterResourceCard({ router }) { + const { name, host, error, resource } = router; + const hasResource = resource && !error; + + return ( +
+
+
+

+ {name} +

+

+ {host} + {resource?.version && ( + · RouterOS {resource.version} + )} +

+ + {error && ( +
+ + {error} +
+ )} + + {hasResource && ( +
+ {/* RAM */} +
+ +
+ {/* CPU */} +
+ +
+ {/* HDD */} +
+ +
+
+ )} + + {hasResource && (resource.boardName || resource.uptime) && ( +
+ {resource.boardName && {resource.boardName}} + {resource.uptime && ( + · Uptime: {resource.uptime} + )} +
+ )} +
+
+ + + +
+
+
+ ); +} + +export default function ResourceStatsPage() { + const [routers, setRouters] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchStats = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await api.get('/resources/stats'); + const data = res?.data; + setRouters(Array.isArray(data?.routers) ? data.routers : []); + } catch (e) { + setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить статистику'); + setRouters([]); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchStats(); + }, [fetchStats]); + + return ( +
+ } + meta="Потребление RAM, CPU и HDD по каждому роутеру (MikroTik)" + actions={ + + } + /> + + {error && ( +
+ + {error} +
+ )} + + {loading && routers.length === 0 ? ( +
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+ ) : routers.length === 0 ? ( +
+
+ +

Нет роутеров с настроенным MikroTik API (jumphost/home).

+
+
+ ) : ( +
+ {routers.map((router) => ( +
+ +
+ ))} +
+ )} + + +
+ ); +}