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 mikrotikConfigRoutes = require('./routes/mikrotikConfigRoutes');
|
||||||
const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
|
const mikrotikBackupRoutes = require('./routes/mikrotikBackupRoutes');
|
||||||
const trafficRoutes = require('./routes/trafficRoutes');
|
const trafficRoutes = require('./routes/trafficRoutes');
|
||||||
|
const resourceStatsRoutes = require('./routes/resourceStatsRoutes');
|
||||||
const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
|
const { initMikrotikBackupScheduler } = require('./services/mikrotikBackupScheduler');
|
||||||
const { initNetworkMapScheduler } = require('./services/networkMapScheduler');
|
const { initNetworkMapScheduler } = require('./services/networkMapScheduler');
|
||||||
const { initPingServicesScheduler } = require('./services/pingServicesScheduler');
|
const { initPingServicesScheduler } = require('./services/pingServicesScheduler');
|
||||||
@@ -479,6 +480,9 @@ app.post('/api/uptime/check', writeLimiter, mikrotikConfigRoutes.uptimeCheck);
|
|||||||
// === TRAFFIC STATS (MikroTik interfaces by jumphost) ===
|
// === TRAFFIC STATS (MikroTik interfaces by jumphost) ===
|
||||||
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
|
app.get('/api/traffic/interface-stats', trafficRoutes.getInterfaceStats);
|
||||||
|
|
||||||
|
// === RESOURCE STATS (RAM/CPU/HDD по роутерам) ===
|
||||||
|
app.get('/api/resources/stats', resourceStatsRoutes.getResourceStats);
|
||||||
|
|
||||||
// === MIKROTIK BACKUPS (S3) ===
|
// === MIKROTIK BACKUPS (S3) ===
|
||||||
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
|
app.post('/api/mikrotik/backups', writeLimiter, mikrotikBackupRoutes.createBackup);
|
||||||
app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups);
|
app.get('/api/mikrotik/backups', mikrotikBackupRoutes.listBackups);
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
IconLayoutSidebarLeftExpand,
|
IconLayoutSidebarLeftExpand,
|
||||||
IconLayoutNavbar,
|
IconLayoutNavbar,
|
||||||
IconChartPie,
|
IconChartPie,
|
||||||
|
IconChartBar,
|
||||||
IconClock,
|
IconClock,
|
||||||
IconClockPlay,
|
IconClockPlay,
|
||||||
IconShield
|
IconShield
|
||||||
@@ -49,6 +50,7 @@ import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
|
|||||||
import PingServicesManager from './PingServicesManager.jsx';
|
import PingServicesManager from './PingServicesManager.jsx';
|
||||||
import FirewallPage from './FirewallPage.jsx';
|
import FirewallPage from './FirewallPage.jsx';
|
||||||
import SettingsPage from './SettingsPage.jsx';
|
import SettingsPage from './SettingsPage.jsx';
|
||||||
|
import ResourceStatsPage from './ResourceStatsPage.jsx';
|
||||||
import './App.css';
|
import './App.css';
|
||||||
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
import { NotifyProvider } from './components/NotifyProvider.jsx';
|
||||||
import ToastContainer from './components/ToastContainer.jsx';
|
import ToastContainer from './components/ToastContainer.jsx';
|
||||||
@@ -66,7 +68,7 @@ function LanguageProvider({ children }) {
|
|||||||
const dict = {
|
const dict = {
|
||||||
ru: {
|
ru: {
|
||||||
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
|
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',
|
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
|
||||||
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall',
|
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов', firewall: 'Firewall',
|
||||||
light: 'Светлая', dark: 'Тёмная',
|
light: 'Светлая', dark: 'Тёмная',
|
||||||
@@ -74,7 +76,7 @@ function LanguageProvider({ children }) {
|
|||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
|
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',
|
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',
|
easySwitch: 'Easy Switch', networkConfig: 'Network Config', mikrotikBackups: 'MikroTik Backups', pingServices: 'Ping Services', firewall: 'Firewall',
|
||||||
light: 'Light', dark: 'Dark',
|
light: 'Light', dark: 'Dark',
|
||||||
@@ -201,6 +203,7 @@ function MainLayout() {
|
|||||||
items: [
|
items: [
|
||||||
{ id: 'dashboard', title: t('dashboard'), path: '/dashboard', icon: IconHome },
|
{ id: 'dashboard', title: t('dashboard'), path: '/dashboard', icon: IconHome },
|
||||||
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie },
|
{ 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: 'network-map', title: t('networkMap'), path: '/network-map', icon: IconNetwork },
|
||||||
{ id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock }
|
{ id: 'uptime-monitor', title: t('uptimeMonitor'), path: '/uptime-monitor', icon: IconClock }
|
||||||
]
|
]
|
||||||
@@ -394,6 +397,7 @@ function MainLayout() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/traffic" element={<TrafficDashboard />} />
|
<Route path="/traffic" element={<TrafficDashboard />} />
|
||||||
|
<Route path="/resource-stats" element={<ResourceStatsPage />} />
|
||||||
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
||||||
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
||||||
<Route path="/domains" element={<DomainsNewManager />} />
|
<Route path="/domains" element={<DomainsNewManager />} />
|
||||||
@@ -556,6 +560,7 @@ function MainLayout() {
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/traffic" element={<TrafficDashboard />} />
|
<Route path="/traffic" element={<TrafficDashboard />} />
|
||||||
|
<Route path="/resource-stats" element={<ResourceStatsPage />} />
|
||||||
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
<Route path="/network-map" element={<NetworkMapDashboard />} />
|
||||||
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
<Route path="/uptime-monitor" element={<UptimeMonitorPage />} />
|
||||||
<Route path="/domains" element={<DomainsNewManager />} />
|
<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