feat(TrafficDashboard): add traffic statistics route and integrate TrafficDashboard component into the application
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s

This commit is contained in:
2026-02-17 13:19:16 +07:00
parent 630ceb30b7
commit 2b957c6d9a
7 changed files with 663 additions and 8 deletions
+11 -5
View File
@@ -22,7 +22,8 @@ import {
IconMenu2,
IconArrowsExchange,
IconLayoutSidebarLeftExpand,
IconLayoutNavbar
IconLayoutNavbar,
IconChartPie
} from '@tabler/icons-react';
import ServerManager from './ServerManager';
import FilterManager from './FilterManager';
@@ -36,6 +37,7 @@ import CommunitiesManager from './CommunitiesManager';
import NetworkConfigManager from './NetworkConfigManager';
import MikrotikTools from './MikrotikTools.jsx';
import Dashboard from './Dashboard';
import TrafficDashboard from './TrafficDashboard.jsx';
import MikrotikBackupsManager from './MikrotikBackupsManager.jsx';
import PingServicesManager from './PingServicesManager.jsx';
import './App.css';
@@ -56,7 +58,7 @@ function LanguageProvider({ children }) {
const dict = {
ru: {
home: 'Главная', data: 'Данные', management: 'Управление', tools: 'Инструменты',
dashboard: 'Панель', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
dashboard: 'Панель', trafficTraffic: 'Расход трафика', domains: 'Домены', ipRanges: 'IP-диапазоны', asns: 'AS',
communities: 'Community', servers: 'Серверы', filters: 'Фильтры', billing: 'Биллинг', autoUrls: 'Авто URL',
easySwitch: 'Easy Switch', networkConfig: 'Сетевые настройки', mikrotikBackups: 'MikroTik Бэкапы', pingServices: 'Пинг сервисов',
light: 'Светлая', dark: 'Тёмная',
@@ -64,7 +66,7 @@ function LanguageProvider({ children }) {
},
en: {
home: 'Home', data: 'Data', management: 'Management', tools: 'Tools',
dashboard: 'Dashboard', domains: 'Domains', ipRanges: 'IP Ranges', asns: 'ASNs',
dashboard: 'Dashboard', trafficTraffic: 'Traffic Usage', 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',
light: 'Light', dark: 'Dark',
@@ -189,8 +191,10 @@ function MainLayout() {
id: 'home',
title: t('home'),
icon: IconHome,
path: '/dashboard',
single: true
items: [
{ id: 'dashboard', title: t('dashboard'), path: '/dashboard', icon: IconHome },
{ id: 'traffic', title: t('trafficTraffic'), path: '/traffic', icon: IconChartPie }
]
},
{
id: 'data',
@@ -370,6 +374,7 @@ function MainLayout() {
<main id="main-content" className="container-xl mt-4 flex-grow-1" tabIndex="-1">
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
@@ -526,6 +531,7 @@ function MainLayout() {
<main id="main-content" tabIndex="-1">
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/traffic" element={<TrafficDashboard />} />
<Route path="/domains" element={<DomainsNewManager />} />
<Route path="/ip-ranges" element={<IPRangesManager />} />
<Route path="/asns" element={<ASNsNewManager />} />
+200
View File
@@ -0,0 +1,200 @@
import { useState, useEffect, useCallback } from 'react';
import api from './lib/api.js';
import {
IconChartPie,
IconRefresh,
IconServer,
IconAlertCircle,
} from '@tabler/icons-react';
import {
PieChart,
Pie,
Cell,
ResponsiveContainer,
Legend,
Tooltip,
} from 'recharts';
import PageHeader from './components/PageHeader.jsx';
const CHART_COLORS = [
'#206bc4', '#4299e1', '#2fb344', '#5eba00', '#e64980', '#f76707',
'#fd7e14', '#fab005', '#e67700', '#20c997', '#0dcaf0', '#6f42c1',
'#6366f1', '#8b5cf6', '#a855f7', '#d63384', '#0d6efd', '#198754',
];
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
function JumphostCard({ jumphost }) {
const { name, host, error, interfaces } = jumphost;
const hasData = Array.isArray(interfaces) && interfaces.length > 0;
const totalBytes = hasData ? interfaces.reduce((acc, i) => acc + (i.totalBytes || 0), 0) : 0;
const chartData = hasData
? interfaces
.filter((i) => (i.totalBytes || 0) > 0)
.map((i, idx) => ({
name: i.name,
value: i.totalBytes,
rxBytes: i.rxBytes,
txBytes: i.txBytes,
fill: CHART_COLORS[idx % CHART_COLORS.length],
}))
: [];
return (
<div className="card h-100">
<div className="card-header d-flex align-items-center">
<span className="avatar avatar-sm me-2 bg-blue-lt text-blue">
<IconServer size={18} />
</span>
<div className="flex-grow-1 min-w-0">
<h3 className="card-title mb-0 text-truncate" title={name}>
{name || host || 'Jumphost'}
</h3>
{host && (
<div className="text-muted small text-truncate" title={host}>
{host}
</div>
)}
</div>
</div>
<div className="card-body">
{error && (
<div className="alert alert-warning py-2 mb-0 d-flex align-items-center">
<IconAlertCircle className="me-2 flex-shrink-0" size={18} />
<span className="small">{error}</span>
</div>
)}
{!error && !hasData && (
<div className="text-muted text-center py-4 small">
Нет данных по интерфейсам
</div>
)}
{!error && hasData && chartData.length === 0 && (
<div className="text-muted text-center py-4 small">
Все счётчики нулевые
</div>
)}
{!error && chartData.length > 0 && (
<>
<div className="mb-2 small text-muted">
Всего: <strong>{formatBytes(totalBytes)}</strong> (RX + TX)
</div>
<ResponsiveContainer width="100%" height={260}>
<PieChart>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
outerRadius={90}
label={({ name, percent }) =>
percent > 0.05 ? `${name} ${(percent * 100).toFixed(0)}%` : ''
}
labelLine={false}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.fill} />
))}
</Pie>
<Tooltip
formatter={(value) => formatBytes(value)}
contentStyle={{ fontSize: '12px' }}
labelFormatter={(name, payload) => {
const p = payload?.[0];
if (!p) return name;
return `${name} — RX: ${formatBytes(p.rxBytes || 0)}, TX: ${formatBytes(p.txBytes || 0)}`;
}}
/>
<Legend
formatter={(value, entry) => {
const item = chartData.find((d) => d.name === value);
return item ? `${value} (${formatBytes(item.value)})` : value;
}}
wrapperStyle={{ fontSize: '11px' }}
/>
</PieChart>
</ResponsiveContainer>
</>
)}
</div>
</div>
);
}
export default function TrafficDashboard() {
const [jumphosts, setJumphosts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchStats = useCallback(async () => {
setLoading(true);
setError(null);
try {
const { data } = await api.get('/traffic/interface-stats');
setJumphosts(Array.isArray(data?.jumphosts) ? data.jumphosts : []);
} catch (e) {
console.error('Traffic stats:', e);
setError(e?.response?.data?.message || e?.message || 'Не удалось загрузить статистику');
setJumphosts([]);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchStats();
}, [fetchStats]);
return (
<div>
<PageHeader
title="Расход трафика"
icon={<IconChartPie size={24} />}
meta="Статистика по интерфейсам MikroTik RouterOS (jumphost)"
actions={
<button
type="button"
className="btn btn-outline-primary"
onClick={fetchStats}
disabled={loading}
>
<IconRefresh className={loading ? 'spin me-2' : 'me-2'} size={18} />
{loading ? 'Загрузка…' : 'Обновить'}
</button>
}
/>
{error && (
<div className="alert alert-danger d-flex align-items-center">
<IconAlertCircle className="me-2" size={20} />
{error}
</div>
)}
{loading && jumphosts.length === 0 && !error && (
<div className="text-center py-5 text-muted">
Загрузка статистики с роутеров
</div>
)}
{!loading && !error && jumphosts.length === 0 && (
<div className="alert alert-info">
Нет jumphost-серверов с настроенным MikroTik API. Добавьте серверы типа «Jumphost» и укажите учётные данные RouterOS.
</div>
)}
{!loading && jumphosts.length > 0 && (
<div className="row row-cards g-3">
{jumphosts.map((jh) => (
<div key={jh.serverId || jh.host || jh.name} className="col-12 col-md-6 col-xl-4">
<JumphostCard jumphost={jh} />
</div>
))}
</div>
)}
</div>
);
}
+3 -1
View File
@@ -10,7 +10,8 @@ import {
IconDatabase,
IconCreditCard,
IconDownload,
IconKeyboard
IconKeyboard,
IconChartPie
} from '@tabler/icons-react'
/**
@@ -38,6 +39,7 @@ function CommandPalette() {
// Список команд
const commands = [
{ icon: IconHome, label: 'Главная', description: 'Панель управления', action: () => navigate('/dashboard'), keywords: ['главная', 'панель', 'dashboard'] },
{ icon: IconChartPie, label: 'Расход трафика', description: 'Статистика по интерфейсам MikroTik', action: () => navigate('/traffic'), keywords: ['трафик', 'traffic', 'mikrotik', 'интерфейсы'] },
{ icon: IconWorld, label: 'Домены', description: 'Управление доменами', action: () => navigate('/domains'), keywords: ['домены', 'domains'] },
{ icon: IconNetwork, label: 'IP-диапазоны', description: 'Управление IP диапазонами', action: () => navigate('/ip-ranges'), keywords: ['ip', 'диапазоны', 'ranges'] },
{ icon: IconNetwork, label: 'ASN', description: 'Управление Autonomous Systems', action: () => navigate('/asns'), keywords: ['asn', 'as', 'autonomous'] },