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
Publish Fast Tabler Docker image / build-and-push-fast (push) Successful in 1m56s
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user