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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m40s
This commit is contained in:
+11
-5
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'] },
|
||||
|
||||
Reference in New Issue
Block a user