hy2-panel/app/admin/dashboard/dashboard-charts.tsx
2026-05-06 00:10:45 +08:00

676 lines
20 KiB
TypeScript

"use client";
import { startTransition, useId, useState } from "react";
import {
Bar,
BarChart,
CartesianGrid,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
type NodeHealth = {
id: number;
name: string;
slug: string;
enabled: number;
last_error_message: string | null;
};
type TrafficTrendRow = {
bucket_start: string;
node_id: number;
auth_id: string;
tx_bytes: number;
rx_bytes: number;
total_bytes: number;
};
type TrafficUserOption = {
auth_id: string;
username: string;
total_bytes: number;
};
type LoadTrendRow = {
bucket_start: string;
node_id: number;
online_users: number;
stream_count: number;
};
type FailureTrendRow = {
bucket_start: string;
node_id: number;
failed_count: number;
};
type TopConsumerRow = {
node_id: number;
auth_id: string;
username: string;
tx_bytes: number;
rx_bytes: number;
total_bytes: number;
};
type DashboardChartsProps = {
nodeHealth: NodeHealth[];
trafficTrend: TrafficTrendRow[];
trafficBuckets: string[];
trafficUserOptions: TrafficUserOption[];
loadTrend: LoadTrendRow[];
failureTrend: FailureTrendRow[];
topConsumerRows: TopConsumerRow[];
};
type TrafficPoint = {
bucketStart: string;
label: string;
[key: string]: string | number;
};
const nodePalette = [
"#c96233",
"#2b6c80",
"#5d7b63",
"#7a3116",
"#6f5aa8",
"#9c6b12",
"#2f5d52",
"#8a3f55",
];
const axisTick = {
fill: "#3f4b57",
fontSize: 11,
};
const tooltipStyle = {
borderRadius: 12,
border: "1px solid rgba(24, 34, 44, 0.12)",
backgroundColor: "rgba(255, 251, 245, 0.96)",
boxShadow: "0 12px 34px rgba(51, 35, 18, 0.1)",
};
function formatBytes(bytes: number) {
if (bytes < 1024) return `${bytes} B`;
const units = ["KB", "MB", "GB", "TB", "PB"];
let value = bytes / 1024;
let unit = units[0];
for (let index = 1; index < units.length && value >= 1024; index += 1) {
value /= 1024;
unit = units[index];
}
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)} ${unit}`;
}
function formatCompactBytes(bytes: number) {
if (bytes < 1024) return `${bytes}B`;
const units = ["KB", "MB", "GB", "TB", "PB"];
let value = bytes / 1024;
let unit = units[0];
for (let index = 1; index < units.length && value >= 1024; index += 1) {
value /= 1024;
unit = units[index];
}
return `${value.toFixed(value >= 100 ? 0 : value >= 10 ? 1 : 2)}${unit}`;
}
function formatBucketLabel(value: string, mode: "hour" | "day") {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("zh-CN", {
month: "2-digit",
day: "2-digit",
...(mode === "hour"
? { hour: "2-digit", minute: "2-digit", hourCycle: "h23" }
: {}),
}).format(date);
}
function truncateLabel(value: string, maxLength = 12) {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength)}`;
}
function getNodeColor(index: number) {
return nodePalette[index % nodePalette.length];
}
function getNodeDataKey(nodeId: number) {
return `node_${nodeId}`;
}
function matchesUser(row: { auth_id: string }, selectedAuthId: string) {
return selectedAuthId === "all" || row.auth_id === selectedAuthId;
}
function buildNodeTrafficSeries(
rows: TrafficTrendRow[],
buckets: string[],
nodes: NodeHealth[],
selectedAuthId: string,
) {
const series = buckets.map((bucketStart) => {
const point: TrafficPoint = {
bucketStart,
label: formatBucketLabel(bucketStart, "hour"),
};
for (const node of nodes) {
point[getNodeDataKey(node.id)] = 0;
}
return point;
});
const pointMap = new Map(series.map((point) => [point.bucketStart, point]));
for (const row of rows) {
if (!matchesUser(row, selectedAuthId)) {
continue;
}
const point = pointMap.get(row.bucket_start);
if (!point) {
continue;
}
const key = getNodeDataKey(row.node_id);
point[key] = Number(point[key] ?? 0) + row.total_bytes;
}
return series;
}
function aggregateTrafficTotals(rows: TrafficTrendRow[], selectedAuthId: string) {
return rows.reduce(
(accumulator, row) => {
if (!matchesUser(row, selectedAuthId)) {
return accumulator;
}
return {
txBytes: accumulator.txBytes + row.tx_bytes,
rxBytes: accumulator.rxBytes + row.rx_bytes,
totalBytes: accumulator.totalBytes + row.total_bytes,
};
},
{ txBytes: 0, rxBytes: 0, totalBytes: 0 },
);
}
function aggregateNodeTrafficTotals(rows: TrafficTrendRow[], selectedAuthId: string) {
const totals = new Map<number, number>();
for (const row of rows) {
if (!matchesUser(row, selectedAuthId)) {
continue;
}
totals.set(row.node_id, (totals.get(row.node_id) ?? 0) + row.total_bytes);
}
return totals;
}
function aggregateLoadSeries(rows: LoadTrendRow[]) {
const map = new Map<string, {
bucketStart: string;
onlineUsers: number;
streamCount: number;
}>();
for (const row of rows) {
const existing = map.get(row.bucket_start) ?? {
bucketStart: row.bucket_start,
onlineUsers: 0,
streamCount: 0,
};
existing.onlineUsers += row.online_users;
existing.streamCount += row.stream_count;
map.set(row.bucket_start, existing);
}
return Array.from(map.values())
.sort((left, right) => left.bucketStart.localeCompare(right.bucketStart))
.map((item) => ({
...item,
label: formatBucketLabel(item.bucketStart, "hour"),
}));
}
function aggregateFailureSeries(rows: FailureTrendRow[]) {
const map = new Map<string, { bucketStart: string; failedCount: number }>();
for (const row of rows) {
const existing = map.get(row.bucket_start) ?? {
bucketStart: row.bucket_start,
failedCount: 0,
};
existing.failedCount += row.failed_count;
map.set(row.bucket_start, existing);
}
return Array.from(map.values())
.sort((left, right) => left.bucketStart.localeCompare(right.bucketStart))
.map((item) => ({
...item,
label: formatBucketLabel(item.bucketStart, "day"),
}));
}
function aggregateTopConsumers(rows: TopConsumerRow[], selectedAuthId: string) {
const map = new Map<string, {
authId: string;
username: string;
txBytes: number;
rxBytes: number;
totalBytes: number;
}>();
for (const row of rows) {
if (!matchesUser(row, selectedAuthId)) {
continue;
}
const existing = map.get(row.auth_id) ?? {
authId: row.auth_id,
username: row.username,
txBytes: 0,
rxBytes: 0,
totalBytes: 0,
};
existing.txBytes += row.tx_bytes;
existing.rxBytes += row.rx_bytes;
existing.totalBytes += row.total_bytes;
map.set(row.auth_id, existing);
}
return Array.from(map.values())
.sort((left, right) => right.totalBytes - left.totalBytes)
.slice(0, 6)
.map((item) => ({
...item,
label: truncateLabel(item.username),
}));
}
export function DashboardCharts({
nodeHealth,
trafficTrend,
trafficBuckets,
trafficUserOptions,
loadTrend,
failureTrend,
topConsumerRows,
}: DashboardChartsProps) {
const userSelectId = useId();
const [selectedAuthId, setSelectedAuthId] = useState("all");
const [hiddenNodeIds, setHiddenNodeIds] = useState<Set<number>>(() => new Set());
const selectedUser = selectedAuthId === "all"
? null
: trafficUserOptions.find((user) => user.auth_id === selectedAuthId) ?? null;
const selectedUserLabel = selectedUser?.username ?? "全部用户";
const trafficSeries = buildNodeTrafficSeries(
trafficTrend,
trafficBuckets,
nodeHealth,
selectedAuthId,
);
const trafficTotals = aggregateTrafficTotals(trafficTrend, selectedAuthId);
const nodeTrafficTotals = aggregateNodeTrafficTotals(trafficTrend, selectedAuthId);
const loadSeries = aggregateLoadSeries(loadTrend);
const failureSeries = aggregateFailureSeries(failureTrend);
const consumerSeries = aggregateTopConsumers(topConsumerRows, selectedAuthId);
const visibleNodes = nodeHealth.filter((node) => !hiddenNodeIds.has(node.id));
const peakLoad = loadSeries.reduce(
(accumulator, item) => ({
onlineUsers: Math.max(accumulator.onlineUsers, item.onlineUsers),
streamCount: Math.max(accumulator.streamCount, item.streamCount),
}),
{ onlineUsers: 0, streamCount: 0 },
);
const totalFailures = failureSeries.reduce(
(accumulator, item) => accumulator + item.failedCount,
0,
);
const healthyNodes = nodeHealth.filter(
(node) => node.enabled && !node.last_error_message,
).length;
const disabledNodes = nodeHealth.filter((node) => !node.enabled).length;
const degradedNodes = nodeHealth.length - healthyNodes - disabledNodes;
const statusTone = degradedNodes > 0 ? "danger" : disabledNodes > 0 ? "warm" : "ok";
const statusLabel = `${healthyNodes}/${nodeHealth.length} 节点健康`;
const hasTrafficData = trafficTotals.totalBytes > 0;
const hasVisibleTrafficData = visibleNodes.some((node) => {
const key = getNodeDataKey(node.id);
return trafficSeries.some((point) => Number(point[key] ?? 0) > 0);
});
const hasLoadData = loadSeries.some(
(item) => item.onlineUsers > 0 || item.streamCount > 0,
);
const hasFailureData = failureSeries.some((item) => item.failedCount > 0);
const hasConsumerData = consumerSeries.length > 0;
function toggleNodeLine(nodeId: number) {
startTransition(() => {
setHiddenNodeIds((current) => {
const next = new Set(current);
if (next.has(nodeId)) {
next.delete(nodeId);
} else {
next.add(nodeId);
}
return next;
});
});
}
return (
<section className="panel-card dashboard-analytics">
<div className="dashboard-toolbar">
<div className="stack-tight">
<div className="section-title section-title--top">
<div>
<h3></h3>
</div>
</div>
<div className="badge-row">
<span className="status-pill status-pill--warm">{selectedUserLabel}</span>
<span className={`status-pill status-pill--${statusTone}`}>{statusLabel}</span>
</div>
</div>
<div className="chart-filter">
<label htmlFor={userSelectId}></label>
<select
id={userSelectId}
value={selectedAuthId}
onChange={(event) => {
const nextValue = event.target.value;
startTransition(() => {
setSelectedAuthId(nextValue);
});
}}
>
<option value="all"></option>
{trafficUserOptions.map((user) => (
<option key={user.auth_id} value={user.auth_id}>
{user.username} / {formatCompactBytes(user.total_bytes)}
</option>
))}
</select>
</div>
</div>
<div className="dashboard-glance-grid">
<article className="dashboard-glance-card dashboard-glance-card--warm">
<span>24h </span>
<strong>{formatBytes(trafficTotals.totalBytes)}</strong>
<small>TX {formatBytes(trafficTotals.txBytes)} / RX {formatBytes(trafficTotals.rxBytes)}</small>
</article>
<article className="dashboard-glance-card dashboard-glance-card--ink">
<span>24h 线</span>
<strong>{peakLoad.onlineUsers}</strong>
<small> {peakLoad.streamCount}</small>
</article>
<article className="dashboard-glance-card dashboard-glance-card--sage">
<span>14d </span>
<strong>{totalFailures}</strong>
<small></small>
</article>
<article className="dashboard-glance-card dashboard-glance-card--paper">
<span></span>
<strong>{statusLabel}</strong>
<small> {degradedNodes} / {disabledNodes}</small>
</article>
</div>
<div className="dashboard-chart-grid">
<article className="dashboard-chart-card dashboard-chart-card--wide">
<div className="dashboard-chart-card__head">
<div className="section-title">
<h4></h4>
<span className="status-pill status-pill--warm"> 24 </span>
</div>
<div className="node-line-toggles">
{nodeHealth.map((node, index) => {
const hidden = hiddenNodeIds.has(node.id);
const color = getNodeColor(index);
return (
<button
key={node.id}
type="button"
className={`node-line-toggle ${hidden ? "is-hidden" : ""}`}
aria-pressed={!hidden}
onClick={() => toggleNodeLine(node.id)}
>
<span
className="node-line-toggle__swatch"
style={{ backgroundColor: color }}
/>
<span>{node.name}</span>
<small>{formatCompactBytes(nodeTrafficTotals.get(node.id) ?? 0)}</small>
</button>
);
})}
</div>
</div>
{hasTrafficData && hasVisibleTrafficData ? (
<div className="chart-shell chart-shell--wide">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={trafficSeries} margin={{ top: 6, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
<XAxis
dataKey="label"
minTickGap={22}
tick={axisTick}
tickLine={false}
axisLine={false}
/>
<YAxis
tickFormatter={formatCompactBytes}
tick={axisTick}
tickLine={false}
axisLine={false}
width={58}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value, name) => [
formatBytes(Number(value ?? 0)),
String(name),
]}
labelFormatter={(label) => `时间 ${label}`}
/>
{visibleNodes.map((node, index) => (
<Line
key={node.id}
type="monotone"
dataKey={getNodeDataKey(node.id)}
name={node.name}
stroke={getNodeColor(index)}
strokeWidth={2.2}
dot={false}
isAnimationActive={false}
/>
))}
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="chart-empty"></div>
)}
</article>
<article className="dashboard-chart-card">
<div className="section-title">
<h4>线</h4>
<span className="status-pill status-pill--ink">24h</span>
</div>
{hasLoadData ? (
<div className="chart-shell">
<ResponsiveContainer width="100%" height="100%">
<LineChart data={loadSeries} margin={{ top: 6, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
<XAxis
dataKey="label"
minTickGap={22}
tick={axisTick}
tickLine={false}
axisLine={false}
/>
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={36} />
<Tooltip
contentStyle={tooltipStyle}
formatter={(value, name) => [
`${Number(value ?? 0)}`,
String(name) === "onlineUsers" ? "在线用户" : "流数量",
]}
labelFormatter={(label) => `时间 ${label}`}
/>
<Line
type="monotone"
dataKey="onlineUsers"
name="在线用户"
stroke="#18222c"
strokeWidth={2.2}
dot={false}
isAnimationActive={false}
/>
<Line
type="monotone"
dataKey="streamCount"
name="流数量"
stroke="#5d7b63"
strokeWidth={2.2}
dot={false}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
</div>
) : (
<div className="chart-empty"></div>
)}
</article>
<article className="dashboard-chart-card">
<div className="section-title">
<h4></h4>
<span className="status-pill status-pill--danger">14d</span>
</div>
{hasFailureData ? (
<div className="chart-shell">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={failureSeries} margin={{ top: 6, right: 12, left: 0, bottom: 0 }}>
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
<XAxis dataKey="label" tick={axisTick} tickLine={false} axisLine={false} />
<YAxis tick={axisTick} tickLine={false} axisLine={false} width={36} />
<Tooltip
contentStyle={tooltipStyle}
formatter={(value) => [`${Number(value ?? 0)}`, "失败次数"]}
labelFormatter={(label) => `日期 ${label}`}
/>
<Bar
dataKey="failedCount"
name="失败次数"
fill="#af2d21"
radius={[6, 6, 0, 0]}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="chart-empty"></div>
)}
</article>
<article className="dashboard-chart-card">
<div className="section-title">
<h4></h4>
<span className="status-pill status-pill--sage">7d</span>
</div>
{hasConsumerData ? (
<div className="chart-shell">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={consumerSeries}
layout="vertical"
margin={{ top: 6, right: 14, left: 0, bottom: 0 }}
>
<CartesianGrid stroke="rgba(24, 34, 44, 0.08)" strokeDasharray="3 3" />
<XAxis
type="number"
tickFormatter={formatCompactBytes}
tick={axisTick}
tickLine={false}
axisLine={false}
/>
<YAxis
type="category"
dataKey="label"
tick={axisTick}
tickLine={false}
axisLine={false}
width={86}
/>
<Tooltip
contentStyle={tooltipStyle}
formatter={(value, name, item) => {
const payload = item?.payload as { username?: string } | undefined;
return [
formatBytes(Number(value ?? 0)),
String(name) === "totalBytes"
? payload?.username ?? "累计流量"
: String(name),
];
}}
labelFormatter={() => "用户"}
/>
<Bar
dataKey="totalBytes"
name="totalBytes"
fill="#7a3116"
radius={[0, 6, 6, 0]}
isAnimationActive={false}
/>
</BarChart>
</ResponsiveContainer>
</div>
) : (
<div className="chart-empty"></div>
)}
</article>
</div>
</section>
);
}