feat(TrafficDashboard): enhance traffic visualization with donut chart and updated color palette
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s

This commit is contained in:
2026-02-17 13:25:50 +07:00
parent 2b957c6d9a
commit 8278017459
+73 -43
View File
@@ -11,15 +11,15 @@ import {
Pie,
Cell,
ResponsiveContainer,
Legend,
Tooltip,
} from 'recharts';
import PageHeader from './components/PageHeader.jsx';
/** Палитра в стиле UniFi: зелёные, жёлтые, оранжевые, красные, фиолетовые, серые */
const CHART_COLORS = [
'#206bc4', '#4299e1', '#2fb344', '#5eba00', '#e64980', '#f76707',
'#fd7e14', '#fab005', '#e67700', '#20c997', '#0dcaf0', '#6f42c1',
'#6366f1', '#8b5cf6', '#a855f7', '#d63384', '#0d6efd', '#198754',
'#2fb344', '#5eba00', '#94c748', '#fab005', '#fd7e14', '#f76707',
'#e64980', '#ae3ec9', '#7950f2', '#5c7cfa', '#748cab', '#868e96',
'#adb5bd', '#ced4da', '#206bc4', '#20c997',
];
function formatBytes(bytes) {
@@ -30,6 +30,10 @@ function formatBytes(bytes) {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
const DONUT_SIZE = 200;
const DONUT_OUTER = 88;
const DONUT_INNER = 52;
function JumphostCard({ jumphost }) {
const { name, host, error, interfaces } = jumphost;
const hasData = Array.isArray(interfaces) && interfaces.length > 0;
@@ -81,47 +85,73 @@ function JumphostCard({ jumphost }) {
</div>
)}
{!error && chartData.length > 0 && (
<>
<div className="mb-2 small text-muted">
Всего: <strong>{formatBytes(totalBytes)}</strong> (RX + TX)
<div className="traffic-donut-wrapper d-flex flex-wrap align-items-start gap-3">
<div
className="position-relative flex-shrink-0"
style={{ width: DONUT_SIZE, height: DONUT_SIZE }}
>
<ResponsiveContainer width={DONUT_SIZE} height={DONUT_SIZE}>
<PieChart margin={{ top: 0, right: 0, bottom: 0, left: 0 }}>
<Pie
data={chartData}
dataKey="value"
nameKey="name"
cx="50%"
cy="50%"
innerRadius={DONUT_INNER}
outerRadius={DONUT_OUTER}
paddingAngle={0}
stroke="none"
>
{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)}`;
}}
/>
</PieChart>
</ResponsiveContainer>
<div
className="position-absolute top-50 start-50 translate-middle text-center text-body"
style={{ pointerEvents: 'none', width: '60%' }}
>
<div className="fw-bold lh-1" style={{ fontSize: '1.1rem' }}>
{formatBytes(totalBytes)}
</div>
<div className="text-muted small mt-1" style={{ fontSize: '0.65rem', letterSpacing: '0.02em' }}>
TRAFFIC
</div>
</div>
</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}
<div className="traffic-legend flex-grow-1 min-w-0" style={{ maxWidth: 240 }}>
{chartData.map((item) => (
<div
key={item.name}
className="d-flex align-items-center mb-2 small"
title={`${item.name}: RX ${formatBytes(item.rxBytes)}, TX ${formatBytes(item.txBytes)}`}
>
{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>
</>
<span
className="flex-shrink-0 rounded"
style={{
width: 10,
height: 10,
backgroundColor: item.fill,
}}
/>
<span className="ms-2 text-truncate">
{item.name} - {formatBytes(item.value)}
</span>
</div>
))}
</div>
</div>
)}
</div>
</div>