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,
|
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>
|
||||||
|
|||||||
Reference in New Issue
Block a user