'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 { DashboardAlert, DashboardNodeHealth, DashboardPeakNode, } 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'; function formatPercent(value?: number | null) { if (value === undefined || value === null || Number.isNaN(value)) { return '—'; } return `${value.toFixed(1)}%`; } function formatBytes(value?: number | null) { if (!value || value <= 0) { return '—'; } const units = ['B', 'KB', 'MB', 'GB', 'TB']; let current = value; let index = 0; while (current >= 1024 && index < units.length - 1) { current /= 1024; index += 1; } return `${current.toFixed(current >= 100 || index === 0 ? 0 : 1)} ${units[index]}`; } 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 formatPeakHour(value: string) { if (!value) { return '暂无'; } const date = new Date(value); if (Number.isNaN(date.getTime())) { return '暂无'; } return `${formatTrendHour(value)} - ${date .getMinutes() .toString() .padStart(2, '0')}`; } function getAlertVariant( alert: DashboardAlert, ): 'success' | 'warning' | 'danger' | 'info' { if (alert.status === 'resolved') { return 'success'; } if (alert.severity === 'critical') { return 'danger'; } if (alert.severity === 'warning') { return 'warning'; } return 'info'; } function formatPeakNode(node: DashboardPeakNode | null) { if (!node) { return { title: '暂无', hint: '当前没有可用节点数据', }; } return { title: node.node_name, hint: `请求 ${node.request_count} · 错误 ${node.error_count} · CPU ${formatPercent(node.cpu_usage_percent)}`, }; } 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 OverviewMetric({ label, value, hint, accent = 'sky', }: { label: string; value: string | number; hint?: string; accent?: 'sky' | 'amber' | 'emerald' | 'rose'; }) { const accentClass = accent === 'amber' ? 'from-amber-400/14 to-transparent' : accent === 'emerald' ? 'from-emerald-400/14 to-transparent' : accent === 'rose' ? 'from-rose-400/14 to-transparent' : 'from-sky-400/14 to-transparent'; return (

{label}

{value}

{hint ? (

{hint}

) : null}
); } function RiskSignal({ label, value, tone, hint, href, }: { label: string; value: number; tone: 'danger' | 'warning' | 'info' | 'success'; hint: string; href?: string; }) { const toneClass = tone === 'danger' ? 'border-rose-400/28 bg-rose-500/10 text-rose-100' : tone === 'warning' ? 'border-amber-400/28 bg-amber-500/10 text-amber-100' : tone === 'success' ? 'border-emerald-400/28 bg-emerald-500/10 text-emerald-100' : 'border-sky-400/28 bg-sky-500/10 text-sky-100'; const content = (

{label}

{value}

{hint}

); if (!href) { return content; } return ( {content} ); } function PeakCard({ label, value, hint, href, }: { label: string; value: string; hint: string; href?: string; }) { const content = (

{label}

{value}

{hint}

); if (!href) { return content; } return ( {content} ); } 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 ( ); } const nodeIdMap = new Map( overview.nodes.map((node) => [node.node_id, node.id] as const), ); 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), )} 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} 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: formatBytes, }, { label: 'OpenResty 出站', color: '#38bdf8', values: overview.trends.network_24h.map( (point) => point.openresty_tx_bytes, ), valueFormatter: formatBytes, }, ]} /> formatTrendHour(point.bucket_started_at), )} height={180} 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, }, ]} />
{overview.active_alerts.length === 0 ? ( ) : (
{overview.active_alerts.map((alert) => (

{alert.node_name}

{alert.message || '暂无详细消息'}

{formatRelativeTime(alert.last_triggered_at)} ·{' '} {formatDateTime(alert.last_triggered_at)}

))}
)}
({ 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="暂无域名分布" />

流量最高节点

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

错误热点节点

node.error_count, )} color="#f97316" emptyMessage="暂无错误热点" />

容量压力节点

Math.round( Math.max( node.cpu_usage_percent, node.memory_usage_percent, node.storage_usage_percent, ), ), )} color="#ef4444" valueFormatter={(value) => `${value}%`} emptyMessage="暂无容量压力数据" />

第一优先级

先处理活动异常与 OpenResty 不健康节点

当前有 {overview.summary.active_alerts} 个活动异常,{` `} {overview.summary.unhealthy_nodes} 个 OpenResty 不健康节点。

第二优先级

检查配置落后与容量逼近节点

当前有 {overview.summary.lagging_nodes} 个配置落后节点,{` `} {overview.capacity.high_cpu_nodes + overview.capacity.high_memory_nodes + overview.capacity.high_storage_nodes}{' '} 个节点出现明显容量压力。

观察项

关注峰值时段与来源分布变化

请求峰值出现在 {formatPeakHour( overview.peaks.peak_request_hour.bucket_started_at, )} ,当前主要来源集中在 {overview.distributions.source_countries .slice(0, 2) .map((item) => item.key) .join(' / ') || ' 暂无来源数据'} 。

进入节点页 } > {overview.nodes.length === 0 ? ( ) : (
{overview.nodes.slice(0, 8).map((node) => ( ))}
)}
); }