feat(ResourceStats): add resource statistics API endpoint and integrate into frontend with routing and localization updates
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m35s
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 2m35s
This commit is contained in:
@@ -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,
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/traffic" element={<TrafficDashboard />} />
|
||||
<Route path="/resource-stats" element={<ResourceStatsPage />} />
|
||||
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
||||
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
||||
<Route path="/domains" element={<DomainsNewManager />} />
|
||||
@@ -556,6 +560,7 @@ function MainLayout() {
|
||||
<Routes>
|
||||
<Route path="/dashboard" element={<Dashboard />} />
|
||||
<Route path="/traffic" element={<TrafficDashboard />} />
|
||||
<Route path="/resource-stats" element={<ResourceStatsPage />} />
|
||||
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
||||
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
||||
<Route path="/domains" element={<DomainsNewManager />} />
|
||||
|
||||
@@ -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 (
|
||||
<div className="mb-3">
|
||||
<div className="text-uppercase small fw-semibold text-secondary mb-1">{label}</div>
|
||||
<div className="d-flex align-items-baseline gap-2 flex-wrap">
|
||||
<span className="h4 mb-0 fw-bold">{value}</span>
|
||||
{percent != null && (
|
||||
<span className={`small fw-medium text-${barVariant}`}>{percent}%</span>
|
||||
)}
|
||||
</div>
|
||||
{percent != null && (
|
||||
<div className="progress progress-sm mt-1" style={{ height: 6 }}>
|
||||
<div
|
||||
className={`progress-bar bg-${barVariant}`}
|
||||
role="progressbar"
|
||||
style={{ width: `${Math.min(100, percent)}%` }}
|
||||
aria-valuenow={percent}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Карточка одного роутера: приветствие (имя), сводка, KPI (RAM, CPU, HDD) + декоративная иконка справа */
|
||||
function RouterResourceCard({ router }) {
|
||||
const { name, host, error, resource } = router;
|
||||
const hasResource = resource && !error;
|
||||
|
||||
return (
|
||||
<div className="card rounded-3 shadow-sm border-0 overflow-hidden h-100">
|
||||
<div className="card-body d-flex flex-column flex-md-row align-items-stretch p-4">
|
||||
<div className="flex-grow-1 pe-0 pe-md-3">
|
||||
<h3 className="h4 fw-bold mb-1 text-body">
|
||||
{name}
|
||||
</h3>
|
||||
<p className="text-secondary small mb-3">
|
||||
{host}
|
||||
{resource?.version && (
|
||||
<span className="ms-2"> · RouterOS {resource.version}</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger py-2 mb-3 d-flex align-items-center">
|
||||
<IconAlertCircle size={20} className="me-2 flex-shrink-0" />
|
||||
<span className="small">{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasResource && (
|
||||
<div className="row g-3">
|
||||
{/* RAM */}
|
||||
<div className="col-12 col-md-4">
|
||||
<KpiBlock
|
||||
label="RAM"
|
||||
value={
|
||||
resource.usedMemory != null && resource.totalMemory != null
|
||||
? `${formatBytes(resource.usedMemory)} / ${formatBytes(resource.totalMemory)}`
|
||||
: '—'
|
||||
}
|
||||
percent={resource.memoryUsagePercent}
|
||||
/>
|
||||
</div>
|
||||
{/* CPU */}
|
||||
<div className="col-12 col-md-4">
|
||||
<KpiBlock
|
||||
label="CPU"
|
||||
value={
|
||||
resource.cpuLoad != null
|
||||
? `${resource.cpuLoad}%`
|
||||
: '—'
|
||||
}
|
||||
percent={resource.cpuLoad}
|
||||
/>
|
||||
</div>
|
||||
{/* HDD */}
|
||||
<div className="col-12 col-md-4">
|
||||
<KpiBlock
|
||||
label="HDD"
|
||||
value={
|
||||
resource.usedHddSpace != null && resource.totalHddSpace != null
|
||||
? `${formatBytes(resource.usedHddSpace)} / ${formatBytes(resource.totalHddSpace)}`
|
||||
: resource.totalHddSpace != null
|
||||
? formatBytes(resource.totalHddSpace)
|
||||
: '—'
|
||||
}
|
||||
percent={resource.hddUsagePercent}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasResource && (resource.boardName || resource.uptime) && (
|
||||
<div className="mt-2 pt-2 border-top small text-muted">
|
||||
{resource.boardName && <span>{resource.boardName}</span>}
|
||||
{resource.uptime && (
|
||||
<span className="ms-2"> · Uptime: {resource.uptime}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="d-none d-md-flex align-items-center justify-content-center flex-shrink-0 mt-3 mt-md-0 ms-md-3">
|
||||
<span className="avatar avatar-xl bg-primary-lt text-primary rounded-3">
|
||||
<IconServer size={48} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<div className="page-body">
|
||||
<PageHeader
|
||||
title="Статистика ресурсов"
|
||||
pretitle="Роутеры"
|
||||
icon={<IconChartBar size={28} />}
|
||||
meta="Потребление RAM, CPU и HDD по каждому роутеру (MikroTik)"
|
||||
actions={
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline-primary"
|
||||
onClick={fetchStats}
|
||||
disabled={loading}
|
||||
>
|
||||
<IconRefresh size={18} className={loading ? 'spin' : ''} />
|
||||
<span className="ms-1">Обновить</span>
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-danger d-flex align-items-center">
|
||||
<IconAlertCircle size={24} className="me-2" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && routers.length === 0 ? (
|
||||
<div className="row">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="col-12 col-lg-6 col-xl-4 mb-4">
|
||||
<div className="card rounded-3 border-0 placeholder-glow">
|
||||
<div className="card-body p-4">
|
||||
<div className="placeholder col-6 mb-2" />
|
||||
<div className="placeholder col-8 mb-3" />
|
||||
<div className="placeholder col-12" style={{ height: 60 }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : routers.length === 0 ? (
|
||||
<div className="card rounded-3 border-0">
|
||||
<div className="card-body text-center py-5 text-muted">
|
||||
<IconServer size={48} className="mb-2 opacity-50" />
|
||||
<p className="mb-0">Нет роутеров с настроенным MikroTik API (jumphost/home).</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="row">
|
||||
{routers.map((router) => (
|
||||
<div key={router.serverId || router.name} className="col-12 col-lg-6 col-xl-4 mb-4">
|
||||
<RouterResourceCard router={router} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.spin {
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user