"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(); 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(); 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(); 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(); 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>(() => 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 (

流量与节点趋势

{selectedUserLabel} {statusLabel}
24h 总流量 {formatBytes(trafficTotals.totalBytes)} TX {formatBytes(trafficTotals.txBytes)} / RX {formatBytes(trafficTotals.rxBytes)}
24h 峰值在线 {peakLoad.onlineUsers} 峰值流数 {peakLoad.streamCount}
14d 鉴权失败 {totalFailures} 失败请求数
节点状态 {statusLabel} 异常 {degradedNodes} / 已禁用 {disabledNodes}

节点流量

最近 24 小时
{nodeHealth.map((node, index) => { const hidden = hiddenNodeIds.has(node.id); const color = getNodeColor(index); return ( ); })}
{hasTrafficData && hasVisibleTrafficData ? (
[ formatBytes(Number(value ?? 0)), String(name), ]} labelFormatter={(label) => `时间 ${label}`} /> {visibleNodes.map((node, index) => ( ))}
) : (
暂无可见节点流量。
)}

在线负载

24h
{hasLoadData ? (
[ `${Number(value ?? 0)}`, String(name) === "onlineUsers" ? "在线用户" : "流数量", ]} labelFormatter={(label) => `时间 ${label}`} />
) : (
暂无同步快照。
)}

鉴权失败

14d
{hasFailureData ? (
[`${Number(value ?? 0)} 次`, "失败次数"]} labelFormatter={(label) => `日期 ${label}`} />
) : (
暂无鉴权失败。
)}

用户流量排行

7d
{hasConsumerData ? (
{ const payload = item?.payload as { username?: string } | undefined; return [ formatBytes(Number(value ?? 0)), String(name) === "totalBytes" ? payload?.username ?? "累计流量" : String(name), ]; }} labelFormatter={() => "用户"} />
) : (
暂无用户流量。
)}
); }