'use client'; import Link from 'next/link'; import { useQuery } from '@tanstack/react-query'; import { RankChart } from '@/components/data/rank-chart'; import { TrendChart } from '@/components/data/trend-chart'; import { EmptyState } from '@/components/feedback/empty-state'; import { ErrorState } from '@/components/feedback/error-state'; import { LoadingState } from '@/components/feedback/loading-state'; import { AppCard } from '@/components/ui/app-card'; import { StatusBadge } from '@/components/ui/status-badge'; import { getDashboardOverview } from '@/features/dashboard/api/overview'; import type { DashboardNodeHealth } from '@/features/dashboard/types'; import { WorldStage } from '@/features/dashboard/components/world-stage'; import { getNodeStatusLabel, getNodeStatusVariant, getOpenrestyStatusLabel, getOpenrestyStatusVariant, } from '@/features/nodes/utils'; import { formatDateTime, formatRelativeTime } from '@/lib/utils/date'; import { formatBytes, formatBytesPerSecond, formatPercent, } from '@/lib/utils/metrics'; function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } function formatTrendHour(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) { return '—'; } return `${date.getHours().toString().padStart(2, '0')}:00`; } function buildNodeDetailHref(id?: number | null) { if (!id) { return '/node'; } return `/node/detail?id=${id}`; } function buildNodeRankItems( nodes: DashboardNodeHealth[], selector: (node: DashboardNodeHealth) => number, limit = 5, ) { return [...nodes] .sort((left, right) => { const leftValue = selector(left); const rightValue = selector(right); if (leftValue === rightValue) { return left.name.localeCompare(right.name, 'zh-CN'); } return rightValue - leftValue; }) .slice(0, limit) .filter((node) => selector(node) > 0) .map((node) => ({ label: node.name, value: selector(node), })); } function NodeHealthRow({ node }: { node: DashboardNodeHealth }) { return (

{node.name}

{node.node_id}

CPU

{formatPercent(node.cpu_usage_percent)}

内存

{formatPercent(node.memory_usage_percent)}

最近窗口请求

{node.request_count.toLocaleString('zh-CN')}

活动异常

{node.active_event_count}

当前版本:{node.current_version || '未应用'}

最近心跳: {node.last_seen_at ? ` ${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}` : ' 暂无'}

); } export function DashboardOverview() { const overviewQuery = useQuery({ queryKey: ['dashboard', 'overview'], queryFn: getDashboardOverview, refetchInterval: 10000, }); if (overviewQuery.isLoading) { return ; } if (overviewQuery.isError) { return ( ); } const overview = overviewQuery.data; if (!overview) { return ( ); } return (
formatTrendHour(point.bucket_started_at), )} series={[ { label: '请求量', color: '#f59e0b', fillColor: 'rgba(245, 158, 11, 0.18)', variant: 'area', values: overview.trends.traffic_24h.map( (point) => point.request_count, ), }, { label: '错误量', color: '#ef4444', values: overview.trends.traffic_24h.map( (point) => point.error_count, ), }, ]} /> formatTrendHour(point.bucket_started_at), )} yAxisValueFormatter={formatPercent} series={[ { label: '平均 CPU', color: '#0f766e', fillColor: 'rgba(15, 118, 110, 0.15)', variant: 'area', values: overview.trends.capacity_24h.map( (point) => point.average_cpu_usage_percent, ), valueFormatter: formatPercent, }, { label: '平均内存', color: '#2563eb', values: overview.trends.capacity_24h.map( (point) => point.average_memory_usage_percent, ), valueFormatter: formatPercent, }, ]} />
formatTrendHour(point.bucket_started_at), )} height={180} yAxisValueFormatter={(value) => formatBytesPerSecond(value, 3600)} series={[ { label: 'OpenResty 入站', color: '#22c55e', fillColor: 'rgba(34, 197, 94, 0.14)', variant: 'area', values: overview.trends.network_24h.map( (point) => point.openresty_rx_bytes, ), valueFormatter: (value) => formatBytesPerSecond(value, 3600), }, { label: 'OpenResty 出站', color: '#38bdf8', values: overview.trends.network_24h.map( (point) => point.openresty_tx_bytes, ), valueFormatter: (value) => formatBytesPerSecond(value, 3600), }, ]} /> formatTrendHour(point.bucket_started_at), )} height={180} yAxisValueFormatter={formatBytes} series={[ { label: '磁盘读', color: '#a78bfa', fillColor: 'rgba(167, 139, 250, 0.14)', variant: 'area', values: overview.trends.disk_io_24h.map( (point) => point.disk_read_bytes, ), valueFormatter: formatBytes, }, { label: '磁盘写', color: '#fb7185', values: overview.trends.disk_io_24h.map( (point) => point.disk_write_bytes, ), valueFormatter: formatBytes, }, ]} />

流量最高节点

node.request_count, )} color="#38bdf8" emptyMessage="暂无流量榜单" />

容量压力节点

Math.round( Math.max( node.cpu_usage_percent, node.memory_usage_percent, node.storage_usage_percent, ), ), )} color="#ef4444" valueFormatter={(value) => `${value}%`} emptyMessage="暂无容量压力数据" />
({ label: item.key, value: item.value, }))} color="#38bdf8" emptyMessage="暂无来源分布数据" /> ({ label: `HTTP ${item.key}`, value: item.value, }))} color="#f59e0b" emptyMessage="暂无状态码分布" /> ({ label: item.key, value: item.value, }))} color="#34d399" emptyMessage="暂无域名分布" />
进入节点页 } > {overview.nodes.length === 0 ? ( ) : (
{overview.nodes.slice(0, 8).map((node) => ( ))}
)}
); }