Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m49s
244 lines
8.6 KiB
React
244 lines
8.6 KiB
React
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>
|
||
);
|
||
}
|