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, Pie,
Cell, Cell,
ResponsiveContainer, ResponsiveContainer,
Legend,
Tooltip, Tooltip,
} from 'recharts'; } from 'recharts';
import PageHeader from './components/PageHeader.jsx'; import PageHeader from './components/PageHeader.jsx';
/** Палитра в стиле UniFi: зелёные, жёлтые, оранжевые, красные, фиолетовые, серые */
const CHART_COLORS = [ const CHART_COLORS = [
'#206bc4', '#4299e1', '#2fb344', '#5eba00', '#e64980', '#f76707', '#2fb344', '#5eba00', '#94c748', '#fab005', '#fd7e14', '#f76707',
'#fd7e14', '#fab005', '#e67700', '#20c997', '#0dcaf0', '#6f42c1', '#e64980', '#ae3ec9', '#7950f2', '#5c7cfa', '#748cab', '#868e96',
'#6366f1', '#8b5cf6', '#a855f7', '#d63384', '#0d6efd', '#198754', '#adb5bd', '#ced4da', '#206bc4', '#20c997',
]; ];
function formatBytes(bytes) { function formatBytes(bytes) {
@@ -30,6 +30,10 @@ function formatBytes(bytes) {
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; 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 }) { function JumphostCard({ jumphost }) {
const { name, host, error, interfaces } = jumphost; const { name, host, error, interfaces } = jumphost;
const hasData = Array.isArray(interfaces) && interfaces.length > 0; const hasData = Array.isArray(interfaces) && interfaces.length > 0;
@@ -81,47 +85,73 @@ function JumphostCard({ jumphost }) {
</div> </div>
)} )}
{!error && chartData.length > 0 && ( {!error && chartData.length > 0 && (
<> <div className="traffic-donut-wrapper d-flex flex-wrap align-items-start gap-3">
<div className="mb-2 small text-muted"> <div
Всего: <strong>{formatBytes(totalBytes)}</strong> (RX + TX) 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> </div>
<ResponsiveContainer width="100%" height={260}> <div className="traffic-legend flex-grow-1 min-w-0" style={{ maxWidth: 240 }}>
<PieChart> {chartData.map((item) => (
<Pie <div
data={chartData} key={item.name}
dataKey="value" className="d-flex align-items-center mb-2 small"
nameKey="name" title={`${item.name}: RX ${formatBytes(item.rxBytes)}, TX ${formatBytes(item.txBytes)}`}
cx="50%"
cy="50%"
outerRadius={90}
label={({ name, percent }) =>
percent > 0.05 ? `${name} ${(percent * 100).toFixed(0)}%` : ''
}
labelLine={false}
> >
{chartData.map((entry, index) => ( <span
<Cell key={`cell-${index}`} fill={entry.fill} /> className="flex-shrink-0 rounded"
))} style={{
</Pie> width: 10,
<Tooltip height: 10,
formatter={(value) => formatBytes(value)} backgroundColor: item.fill,
contentStyle={{ fontSize: '12px' }} }}
labelFormatter={(name, payload) => { />
const p = payload?.[0]; <span className="ms-2 text-truncate">
if (!p) return name; {item.name} - {formatBytes(item.value)}
return `${name} — RX: ${formatBytes(p.rxBytes || 0)}, TX: ${formatBytes(p.txBytes || 0)}`; </span>
}} </div>
/> ))}
<Legend </div>
formatter={(value, entry) => { </div>
const item = chartData.find((d) => d.name === value);
return item ? `${value} (${formatBytes(item.value)})` : value;
}}
wrapperStyle={{ fontSize: '11px' }}
/>
</PieChart>
</ResponsiveContainer>
</>
)} )}
</div> </div>
</div> </div>