Files
router-lists-ui/frontend/src/ResourceStatsPage.jsx
T
2026-02-24 13:53:41 +07:00

244 lines
8.6 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
const REQUEST_TIMEOUT_MS = 15000;
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 p-4">
<div className="d-flex align-items-center mb-3">
<span className="avatar avatar-lg bg-primary-lt text-primary rounded-3 me-3 flex-shrink-0">
<IconServer size={28} />
</span>
<div className="min-w-0">
<h3 className="h4 fw-bold mb-0 text-body text-truncate">
{name}
</h3>
<p className="text-secondary small mb-0 mt-1">
{host}
{resource?.version && (
<span className="ms-1"> · RouterOS {resource.version}</span>
)}
</p>
</div>
</div>
<div>
{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>
</div>
);
}
export default function ResourceStatsPage() {
const [routers, setRouters] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchStats = useCallback(async (abortSignal) => {
setLoading(true);
setError(null);
const opts = { timeout: REQUEST_TIMEOUT_MS, noRetry: true, ...(abortSignal ? { signal: abortSignal } : {}) };
try {
const res = await api.get('/resources/stats', opts);
const data = res?.data;
setRouters(Array.isArray(data?.routers) ? data.routers : []);
} catch (e) {
if (e?.name === 'CanceledError' || e?.name === 'AbortError' || e?.code === 'ERR_CANCELED') return;
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить статистику');
// Не очищаем список — пользователь видит старые данные и сообщение об ошибке
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
const controller = new AbortController();
fetchStats(controller.signal);
return () => controller.abort();
}, [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>
);
}