'use client'; import { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { Loader2, Trash2 } from 'lucide-react'; import { toast } from 'sonner'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { formatDateTime } from '@/lib/utils'; import type { NodeHealthEvent, NodeItem, NodeSystemProfile, } from '@/lib/services/openflare'; import { NodeService } from '@/lib/services/openflare'; import { CapacityTrendChart } from '../../components/dashboard/capacity-trend-chart'; import { TrafficTrendChart } from '../../components/dashboard/traffic-trend-chart'; import { DiskIOTrendChart } from './disk-io-trend-chart'; import { DistributionList } from './distribution-list'; import { NetworkTrendChart } from './network-trend-chart'; import { NodeStatusBadge } from './node-status-badge'; import { aggregateTrafficBreakdown, formatBytes, formatBytesPerSecond, formatMetricCount, formatPercent, formatRelativeTime, formatUptime, formatUsageRatio, getErrorMessage, getHealthEventLabel, getHealthEventTone, getNodeStatusLabel, getNodeStatusTone, getOpenrestyStatusLabel, getOpenrestyStatusTone, isMeaningfulTime, } from './node-utils'; type HealthEventFilter = 'all' | 'active' | 'resolved'; type NodeObservabilityVariant = 'edge' | 'compact'; function MetricBar({ label, value, progress, hint, }: { label: string; value: string; progress?: number | null; hint?: string; }) { return (

{label}

{hint ? (

{hint}

) : null}

{value}

{progress !== null && progress !== undefined ? (
) : null}
); } function SummaryStat({ label, value, hint, }: { label: string; value: string; hint: string; }) { return ( {label} {value}

{hint}

); } function SystemProfileCard({ profile, nodeName, }: { profile: NodeSystemProfile; nodeName: string; }) { return (

主机名

{profile.hostname || nodeName}

操作系统

{profile.os_name || 'unknown'} {profile.os_version ? ` ${profile.os_version}` : ''}

内核 / 架构

{profile.kernel_version || 'unknown'} ·{' '} {profile.architecture || 'unknown'}

在线时长

{formatUptime(profile.uptime_seconds)}

CPU

{profile.cpu_model || 'unknown'}

{profile.cpu_cores || 0} 核

总内存

{formatBytes(profile.total_memory_bytes)}

总存储

{formatBytes(profile.total_disk_bytes)}

上报时间

{isMeaningfulTime(profile.reported_at) ? formatDateTime(profile.reported_at) : '—'}

); } function HealthEventTimeline({ events, allEventsCount, healthEventFilter, onFilterChange, onCleanup, cleanupPending, }: { events: NodeHealthEvent[]; allEventsCount: number; healthEventFilter: HealthEventFilter; onFilterChange: (filter: HealthEventFilter) => void; onCleanup: () => void; cleanupPending: boolean; }) { return (
健康事件时间线 保留活动与已恢复事件,帮助判断运行状态。
{allEventsCount > 0 ? ( <>
{( [ ['all', '全部事件'], ['active', '活动中'], ['resolved', '已恢复'], ] as const ).map(([filter, label]) => ( ))}
{events.length === 0 ? (

当前筛选下没有健康事件。

) : null} {events.slice(0, 8).map((event) => (

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

首次触发: {isMeaningfulTime(event.first_triggered_at) ? ` ${formatDateTime(event.first_triggered_at)}` : ' —'}

最近触发: {isMeaningfulTime(event.last_triggered_at) ? ` ${formatDateTime(event.last_triggered_at)}` : ' —'}

恢复时间: {isMeaningfulTime(event.resolved_at) && event.resolved_at ? ` ${formatDateTime(event.resolved_at)}` : ' —'}

))} ) : (

节点当前还没有上报健康事件记录。

)}
); } export function NodeObservability({ nodeId, node, variant = 'edge', connectionHint = 'OpenResty 当前连接', }: { nodeId: number; node?: NodeItem; variant?: NodeObservabilityVariant; connectionHint?: string; }) { const queryClient = useQueryClient(); const [healthEventFilter, setHealthEventFilter] = useState('all'); const [cleanupOpen, setCleanupOpen] = useState(false); const observabilityQuery = useQuery({ queryKey: ['openflare', 'node-observability', nodeId], queryFn: () => NodeService.getObservability(nodeId, { hours: 24, limit: 48 }), refetchInterval: 30000, }); const cleanupMutation = useMutation({ mutationFn: () => NodeService.cleanupHealthEvents(nodeId), onSuccess: async (result) => { toast.success( result.deleted_count > 0 ? `已清理 ${result.deleted_count} 条健康事件日志` : '当前没有可清理的健康事件日志', ); setCleanupOpen(false); await Promise.all([ queryClient.invalidateQueries({ queryKey: ['openflare', 'node-observability', nodeId], }), queryClient.invalidateQueries({ queryKey: ['openflare', 'dashboard', 'overview'], }), ]); }, onError: (error) => toast.error(getErrorMessage(error)), }); const observability = observabilityQuery.data ?? null; const profile = observability?.profile ?? null; const latestMetric = observability?.metric_snapshots?.[0] ?? null; const activeHealthEvents = useMemo( () => observability?.health_events.filter( (event) => event.status === 'active', ) ?? [], [observability?.health_events], ); const resolvedHealthEvents = useMemo( () => observability?.health_events.filter( (event) => event.status === 'resolved', ) ?? [], [observability?.health_events], ); const filteredHealthEvents = useMemo(() => { switch (healthEventFilter) { case 'active': return activeHealthEvents; case 'resolved': return resolvedHealthEvents; default: return observability?.health_events ?? []; } }, [ activeHealthEvents, healthEventFilter, observability?.health_events, resolvedHealthEvents, ]); const statusCodeDistribution = useMemo( () => aggregateTrafficBreakdown( observability?.traffic_reports, 'status_codes_json', ), [observability?.traffic_reports], ); const topDomains = useMemo( () => aggregateTrafficBreakdown( observability?.traffic_reports, 'top_domains_json', ), [observability?.traffic_reports], ); const trafficSummary = observability?.analytics?.traffic ?? null; const healthSummary = observability?.analytics?.health ?? null; const topSourceCountry = observability?.analytics?.distributions?.source_countries?.[0] ?? null; const latestHealthEvent = activeHealthEvents[0] ?? null; const dominantStatusCode = statusCodeDistribution[0] ?? null; const dominantDomain = topDomains[0] ?? null; const memoryUsageRatio = formatUsageRatio( latestMetric?.memory_used_bytes, latestMetric?.memory_total_bytes, ); const storageUsageRatio = formatUsageRatio( latestMetric?.storage_used_bytes, latestMetric?.storage_total_bytes, ); const trends = observability?.trends; const nodeName = node?.name ?? observability?.node_id ?? String(nodeId); if (observabilityQuery.isLoading) { return ( 加载运行观测数据中... ); } if (observabilityQuery.isError) { return (

{getErrorMessage(observabilityQuery.error)}

); } if (variant === 'compact') { return (
系统画像 节点上报的主机与硬件信息 {profile ? ( ) : (

节点已接入,但尚未上报完整系统画像。

)}
实时资源快照 最近一次 metrics 上报 {latestMetric ? (
) : (

节点已接入,但尚未上报资源快照。

)}
setCleanupOpen(true)} cleanupPending={cleanupMutation.isPending} /> 清理健康事件日志 该操作会删除此节点在控制端记录的所有健康诊断事件历史,不会影响后续新事件上报。 取消 cleanupMutation.mutate()} > {cleanupMutation.isPending ? '清理中...' : '确认清理'}
); } return (
系统信息 {profile ? ( ) : (

节点已经接入,但还没有上报完整系统画像。

)}
实时资源 {latestMetric ? (
) : (

节点已经接入,但还没有上报资源快照。

)}
网络流量 {latestMetric ? ( <> {node ? (
) : null}

OpenResty 吞吐

入站: {formatBytesPerSecond( latestMetric.openresty_rx_bytes, 60, )}

出站: {formatBytesPerSecond( latestMetric.openresty_tx_bytes, 60, )}

节点网络

入站:{formatBytes(latestMetric.network_rx_bytes)}

出站:{formatBytes(latestMetric.network_tx_bytes)}

请求/分钟

{formatMetricCount(trafficSummary?.request_count)}

{trafficSummary ? `近 60 秒 · 窗口UV ${formatMetricCount(trafficSummary.unique_visitor_count)}` : '暂无窗口流量摘要'}

近 60 秒错误

{formatMetricCount(trafficSummary?.error_count)}

{trafficSummary ? `错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%` : '暂无错误率摘要'}

) : (

节点已经接入,但还没有上报网络流量相关快照。

)}
请求结构分布 聚合最近 24 小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。

主状态码

{dominantStatusCode?.label ?? '—'}

{dominantStatusCode ? `${dominantStatusCode.value} 次` : '暂无状态码分布'}

Top Domain

{dominantDomain?.label ?? '—'}

{dominantDomain ? `${dominantDomain.value} 次` : '暂无域名分布'}

已恢复事件

{resolvedHealthEvents.length}

最近 24 小时已恢复健康事件

状态码分布

Top Domain

setCleanupOpen(true)} cleanupPending={cleanupMutation.isPending} />
清理健康事件日志 该操作会删除此节点在控制端记录的所有健康诊断事件历史,不会影响后续新事件上报。 取消 cleanupMutation.mutate()} > {cleanupMutation.isPending ? '清理中...' : '确认清理'}
); }