mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 08:06:37 +08:00
merge: 合并 main 至 dev
同步边缘节点详情数据看板与 main 分支最新变更。
This commit is contained in:
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {Area, AreaChart, CartesianGrid, Line, XAxis, YAxis} from 'recharts';
|
||||||
|
|
||||||
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
ChartConfig,
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from '@/components/ui/chart';
|
||||||
|
import type {DiskIOTrendPoint} from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
import {formatTrendHour} from '../../components/dashboard/dashboard-utils';
|
||||||
|
import {formatBytes} from './node-utils';
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
diskRead: {
|
||||||
|
label: '磁盘读',
|
||||||
|
color: 'hsl(var(--chart-3))',
|
||||||
|
},
|
||||||
|
diskWrite: {
|
||||||
|
label: '磁盘写',
|
||||||
|
color: 'hsl(var(--chart-5))',
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
export function DiskIOTrendChart({
|
||||||
|
points,
|
||||||
|
title = '24 小时磁盘 IO 趋势',
|
||||||
|
description = '观察磁盘读写变化,辅助判断日志放大、缓存抖动或磁盘压力。',
|
||||||
|
}: {
|
||||||
|
points: DiskIOTrendPoint[];
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
const data = points.map((point) => ({
|
||||||
|
hour: formatTrendHour(point.bucket_started_at),
|
||||||
|
diskRead: point.disk_read_bytes,
|
||||||
|
diskWrite: point.disk_write_bytes,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||||
|
<CardDescription className="text-xs">{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex h-[280px] items-center justify-center text-xs text-muted-foreground">
|
||||||
|
暂无趋势数据
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||||
|
<CardDescription className="text-xs">{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pl-2 pr-4">
|
||||||
|
<div className="h-[280px] w-full">
|
||||||
|
<ChartContainer config={chartConfig} className="h-full w-full">
|
||||||
|
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="nodeDiskReadFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-diskRead)" stopOpacity={0.2} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-diskRead)" stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="hour"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={24}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tickFormatter={(value) => formatBytes(Number(value))}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
formatter={(value, name) => (
|
||||||
|
<span className="font-mono tabular-nums">
|
||||||
|
{formatBytes(Number(value))}
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
{name === 'diskRead' ? '读' : '写'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="diskRead"
|
||||||
|
stroke="var(--color-diskRead)"
|
||||||
|
fill="url(#nodeDiskReadFill)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="diskWrite"
|
||||||
|
stroke="var(--color-diskWrite)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {Progress} from '@/components/ui/progress';
|
||||||
|
|
||||||
|
import {formatCompactNumber} from '../../components/dashboard/dashboard-utils';
|
||||||
|
|
||||||
|
export function DistributionList({
|
||||||
|
items,
|
||||||
|
emptyMessage = '暂无分布数据',
|
||||||
|
}: {
|
||||||
|
items: Array<{ label: string; value: number }>;
|
||||||
|
emptyMessage?: string;
|
||||||
|
}) {
|
||||||
|
const sortedItems = [...items]
|
||||||
|
.sort((left, right) => right.value - left.value)
|
||||||
|
.slice(0, 8);
|
||||||
|
const maxValue = sortedItems[0]?.value ?? 0;
|
||||||
|
|
||||||
|
if (sortedItems.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-[180px] items-center justify-center text-xs text-muted-foreground">
|
||||||
|
{emptyMessage}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{sortedItems.map((item) => {
|
||||||
|
const ratio = maxValue > 0 ? (item.value / maxValue) * 100 : 0;
|
||||||
|
return (
|
||||||
|
<div key={item.label} className="space-y-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-3 text-xs">
|
||||||
|
<span className="truncate font-medium">{item.label || '未知'}</span>
|
||||||
|
<span className="shrink-0 font-mono tabular-nums text-muted-foreground">
|
||||||
|
{formatCompactNumber(item.value)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Progress value={ratio} className="h-1.5" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useRouter} from 'next/navigation';
|
import {useRouter} from 'next/navigation';
|
||||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
||||||
import {useState} from 'react';
|
import {useState} from 'react';
|
||||||
import {ArrowLeft, FileText, Loader2, RefreshCw, RotateCcw, Trash2, Upload,} from 'lucide-react';
|
import {ArrowLeft, FileText, RefreshCw, RotateCcw, Trash2, Upload,} from 'lucide-react';
|
||||||
import {toast} from 'sonner';
|
import {toast} from 'sonner';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,7 @@ import {NodeService} from '@/lib/services/openflare';
|
|||||||
|
|
||||||
import {AgentUpdateDialog} from './agent-update-dialog';
|
import {AgentUpdateDialog} from './agent-update-dialog';
|
||||||
import {NodeEditorDialog} from './node-editor-dialog';
|
import {NodeEditorDialog} from './node-editor-dialog';
|
||||||
|
import {NodeObservability} from './node-observability';
|
||||||
import {NodeStatusBadge} from './node-status-badge';
|
import {NodeStatusBadge} from './node-status-badge';
|
||||||
import {
|
import {
|
||||||
formatRelativeTime,
|
formatRelativeTime,
|
||||||
@@ -47,12 +48,6 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
|||||||
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
||||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||||
|
|
||||||
const observabilityQuery = useQuery({
|
|
||||||
queryKey: ['openflare', 'node-observability', node.id],
|
|
||||||
queryFn: () => NodeService.getObservability(node.id, { hours: 24, limit: 48 }),
|
|
||||||
refetchInterval: 10000,
|
|
||||||
});
|
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
|
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
|
||||||
NodeService.updateNode(node.id, payload),
|
NodeService.updateNode(node.id, payload),
|
||||||
@@ -114,11 +109,6 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
|||||||
onError: (error) => toast.error(getErrorMessage(error)),
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
});
|
});
|
||||||
|
|
||||||
const profile = observabilityQuery.data?.profile ?? null;
|
|
||||||
const latestMetric = observabilityQuery.data?.metric_snapshots?.[0] ?? null;
|
|
||||||
const activeHealthEvents =
|
|
||||||
observabilityQuery.data?.health_events.filter((event) => event.status === 'active') ?? [];
|
|
||||||
|
|
||||||
const handleRefresh = () => {
|
const handleRefresh = () => {
|
||||||
void Promise.all([
|
void Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
|
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
|
||||||
@@ -126,8 +116,6 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
|||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isRefreshing = observabilityQuery.isFetching;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="py-6 px-1 space-y-6">
|
<div className="py-6 px-1 space-y-6">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -153,13 +141,9 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
|||||||
size="sm"
|
size="sm"
|
||||||
className="h-7 text-xs"
|
className="h-7 text-xs"
|
||||||
onClick={handleRefresh}
|
onClick={handleRefresh}
|
||||||
disabled={isRefreshing}
|
disabled={false}
|
||||||
>
|
>
|
||||||
{isRefreshing ? (
|
<RefreshCw className="size-3.5 mr-1" />
|
||||||
<Loader2 className="size-3.5 mr-1 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<RefreshCw className="size-3.5 mr-1" />
|
|
||||||
)}
|
|
||||||
刷新
|
刷新
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@@ -233,121 +217,80 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-2">
|
<Card className="border-dashed shadow-none">
|
||||||
<Card className="border-dashed shadow-none">
|
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||||
<CardHeader>
|
<div>
|
||||||
<CardTitle className="text-base font-semibold">基本信息</CardTitle>
|
<CardTitle className="text-base font-semibold">节点信息</CardTitle>
|
||||||
<CardDescription>边缘代理节点 (edge_node)</CardDescription>
|
<CardDescription>边缘代理节点 (edge_node)</CardDescription>
|
||||||
</CardHeader>
|
</div>
|
||||||
<CardContent className="space-y-3 text-sm">
|
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||||
<div className="flex items-center justify-between gap-3">
|
<Link href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}>
|
||||||
<span className="text-muted-foreground">运行状态</span>
|
<FileText className="size-3.5 mr-1" />
|
||||||
<NodeStatusBadge
|
应用记录
|
||||||
label={getNodeStatusLabel(node.status)}
|
</Link>
|
||||||
tone={getNodeStatusTone(node.status)}
|
</Button>
|
||||||
/>
|
</CardHeader>
|
||||||
|
<CardContent className="grid gap-3 text-sm md:grid-cols-2 xl:grid-cols-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">运行状态</span>
|
||||||
|
<NodeStatusBadge
|
||||||
|
label={getNodeStatusLabel(node.status)}
|
||||||
|
tone={getNodeStatusTone(node.status)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">OpenResty 健康</span>
|
||||||
|
<NodeStatusBadge
|
||||||
|
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||||
|
tone={getOpenrestyStatusTone(node.openresty_status)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">最近应用</span>
|
||||||
|
<NodeStatusBadge
|
||||||
|
label={getApplyLabel(node.latest_apply_result)}
|
||||||
|
tone={getApplyTone(node.latest_apply_result)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">IP 地址</span>
|
||||||
|
<span>
|
||||||
|
{node.ip || '—'}
|
||||||
|
{node.ip_manual_override ? '(已锁定)' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">地图点位</span>
|
||||||
|
<span>{node.geo_name || '未配置'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">OpenResty 状态消息</span>
|
||||||
|
<span className="text-right max-w-[60%] break-words">
|
||||||
|
{node.openresty_message || '无'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">创建时间</span>
|
||||||
|
<span>{formatDateTime(node.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<span className="text-muted-foreground">更新时间</span>
|
||||||
|
<span>{formatDateTime(node.updated_at)}</span>
|
||||||
|
</div>
|
||||||
|
{node.last_error ? (
|
||||||
|
<div className="md:col-span-2 xl:col-span-3 rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-destructive">
|
||||||
|
{node.last_error}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between gap-3">
|
) : null}
|
||||||
<span className="text-muted-foreground">OpenResty 健康</span>
|
</CardContent>
|
||||||
<NodeStatusBadge
|
</Card>
|
||||||
label={getOpenrestyStatusLabel(node.openresty_status)}
|
|
||||||
tone={getOpenrestyStatusTone(node.openresty_status)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">最近应用</span>
|
|
||||||
<NodeStatusBadge
|
|
||||||
label={getApplyLabel(node.latest_apply_result)}
|
|
||||||
tone={getApplyTone(node.latest_apply_result)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">IP 地址</span>
|
|
||||||
<span>
|
|
||||||
{node.ip || '—'}
|
|
||||||
{node.ip_manual_override ? '(已锁定)' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">地图点位</span>
|
|
||||||
<span>{node.geo_name || '未配置'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">创建时间</span>
|
|
||||||
<span>{formatDateTime(node.created_at)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">更新时间</span>
|
|
||||||
<span>{formatDateTime(node.updated_at)}</span>
|
|
||||||
</div>
|
|
||||||
{node.last_error ? (
|
|
||||||
<div className="rounded-lg border border-destructive/20 bg-destructive/5 p-3 text-destructive">
|
|
||||||
{node.last_error}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card className="border-dashed shadow-none">
|
<NodeObservability
|
||||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
nodeId={node.id}
|
||||||
<div>
|
node={node}
|
||||||
<CardTitle className="text-base font-semibold">运行快照</CardTitle>
|
variant="edge"
|
||||||
<CardDescription>来自 observability 接口的最近上报</CardDescription>
|
connectionHint="OpenResty 当前连接"
|
||||||
</div>
|
/>
|
||||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
|
||||||
<Link href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}>
|
|
||||||
<FileText className="size-3.5 mr-1" />
|
|
||||||
应用记录
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="space-y-3 text-sm">
|
|
||||||
{observabilityQuery.isLoading ? (
|
|
||||||
<div className="flex items-center text-muted-foreground py-6 justify-center">
|
|
||||||
<Loader2 className="size-4 mr-2 animate-spin" />
|
|
||||||
加载运行快照中...
|
|
||||||
</div>
|
|
||||||
) : observabilityQuery.isError ? (
|
|
||||||
<p className="text-destructive">{getErrorMessage(observabilityQuery.error)}</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">活动健康事件</span>
|
|
||||||
<span>{activeHealthEvents.length}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">主机名</span>
|
|
||||||
<span>{profile?.hostname || '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">操作系统</span>
|
|
||||||
<span>
|
|
||||||
{profile
|
|
||||||
? `${profile.os_name || 'unknown'} ${profile.os_version || ''}`.trim()
|
|
||||||
: '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">CPU 使用率</span>
|
|
||||||
<span>
|
|
||||||
{latestMetric ? `${latestMetric.cpu_usage_percent.toFixed(1)}%` : '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">OpenResty 连接数</span>
|
|
||||||
<span>{latestMetric?.openresty_connections ?? '—'}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">OpenResty 状态消息</span>
|
|
||||||
<span className="text-right max-w-[60%] break-words">
|
|
||||||
{node.openresty_message || '无'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<NodeEditorDialog
|
<NodeEditorDialog
|
||||||
open={editorOpen}
|
open={editorOpen}
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import {Area, AreaChart, CartesianGrid, Line, XAxis, YAxis} from 'recharts';
|
||||||
|
|
||||||
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
|
import {
|
||||||
|
ChartConfig,
|
||||||
|
ChartContainer,
|
||||||
|
ChartLegend,
|
||||||
|
ChartLegendContent,
|
||||||
|
ChartTooltip,
|
||||||
|
ChartTooltipContent,
|
||||||
|
} from '@/components/ui/chart';
|
||||||
|
import type {NetworkTrendPoint} from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
import {formatTrendHour} from '../../components/dashboard/dashboard-utils';
|
||||||
|
import {formatBytesPerSecond} from './node-utils';
|
||||||
|
|
||||||
|
const chartConfig = {
|
||||||
|
openrestyRx: {
|
||||||
|
label: 'OpenResty 入站',
|
||||||
|
color: 'hsl(var(--chart-2))',
|
||||||
|
},
|
||||||
|
openrestyTx: {
|
||||||
|
label: 'OpenResty 出站',
|
||||||
|
color: 'hsl(var(--chart-4))',
|
||||||
|
},
|
||||||
|
} satisfies ChartConfig;
|
||||||
|
|
||||||
|
export function NetworkTrendChart({
|
||||||
|
points,
|
||||||
|
title = '24 小时网络趋势',
|
||||||
|
description = '观察 OpenResty 入站/出站吞吐的变化,辅助识别回源压力、突发流量或出口异常。',
|
||||||
|
}: {
|
||||||
|
points: NetworkTrendPoint[];
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}) {
|
||||||
|
const data = points.map((point) => ({
|
||||||
|
hour: formatTrendHour(point.bucket_started_at),
|
||||||
|
openrestyRx: point.openresty_rx_bytes,
|
||||||
|
openrestyTx: point.openresty_tx_bytes,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||||
|
<CardDescription className="text-xs">{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex h-[280px] items-center justify-center text-xs text-muted-foreground">
|
||||||
|
暂无趋势数据
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
|
||||||
|
<CardDescription className="text-xs">{description}</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pl-2 pr-4">
|
||||||
|
<div className="h-[280px] w-full">
|
||||||
|
<ChartContainer config={chartConfig} className="h-full w-full">
|
||||||
|
<AreaChart data={data} margin={{ top: 10, right: 10, left: 0, bottom: 0 }}>
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="nodeOpenrestyRxFill" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="5%" stopColor="var(--color-openrestyRx)" stopOpacity={0.2} />
|
||||||
|
<stop offset="95%" stopColor="var(--color-openrestyRx)" stopOpacity={0.02} />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" vertical={false} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="hour"
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
minTickGap={24}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickLine={false}
|
||||||
|
axisLine={false}
|
||||||
|
tickMargin={8}
|
||||||
|
tickFormatter={(value) => formatBytesPerSecond(Number(value), 3600)}
|
||||||
|
/>
|
||||||
|
<ChartTooltip
|
||||||
|
cursor={false}
|
||||||
|
content={
|
||||||
|
<ChartTooltipContent
|
||||||
|
formatter={(value, name) => (
|
||||||
|
<span className="font-mono tabular-nums">
|
||||||
|
{formatBytesPerSecond(Number(value), 3600)}
|
||||||
|
<span className="ml-1 text-muted-foreground">
|
||||||
|
{name === 'openrestyRx' ? '入站' : '出站'}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Area
|
||||||
|
type="monotone"
|
||||||
|
dataKey="openrestyRx"
|
||||||
|
stroke="var(--color-openrestyRx)"
|
||||||
|
fill="url(#nodeOpenrestyRxFill)"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="openrestyTx"
|
||||||
|
stroke="var(--color-openrestyTx)"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
/>
|
||||||
|
<ChartLegend content={<ChartLegendContent />} />
|
||||||
|
</AreaChart>
|
||||||
|
</ChartContainer>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,21 +18,35 @@ import {
|
|||||||
import {Button} from '@/components/ui/button';
|
import {Button} from '@/components/ui/button';
|
||||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
import {formatDateTime} from '@/lib/utils';
|
import {formatDateTime} from '@/lib/utils';
|
||||||
|
import type {NodeHealthEvent, NodeItem, NodeSystemProfile} from '@/lib/services/openflare';
|
||||||
import {NodeService} 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 {NodeStatusBadge} from './node-status-badge';
|
||||||
import {
|
import {
|
||||||
|
aggregateTrafficBreakdown,
|
||||||
formatBytes,
|
formatBytes,
|
||||||
|
formatBytesPerSecond,
|
||||||
|
formatPercent,
|
||||||
formatRelativeTime,
|
formatRelativeTime,
|
||||||
formatUptime,
|
formatUptime,
|
||||||
formatUsageRatio,
|
formatUsageRatio,
|
||||||
getErrorMessage,
|
getErrorMessage,
|
||||||
getHealthEventLabel,
|
getHealthEventLabel,
|
||||||
getHealthEventTone,
|
getHealthEventTone,
|
||||||
|
getNodeStatusLabel,
|
||||||
|
getNodeStatusTone,
|
||||||
|
getOpenrestyStatusLabel,
|
||||||
|
getOpenrestyStatusTone,
|
||||||
isMeaningfulTime,
|
isMeaningfulTime,
|
||||||
} from './node-utils';
|
} from './node-utils';
|
||||||
|
|
||||||
type HealthEventFilter = 'all' | 'active' | 'resolved';
|
type HealthEventFilter = 'all' | 'active' | 'resolved';
|
||||||
|
type NodeObservabilityVariant = 'edge' | 'compact';
|
||||||
|
|
||||||
function MetricBar({
|
function MetricBar({
|
||||||
label,
|
label,
|
||||||
@@ -66,11 +80,205 @@ function MetricBar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function SummaryStat({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
hint,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
hint: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed shadow-none">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<CardDescription>{label}</CardDescription>
|
||||||
|
<CardTitle className="text-base font-semibold">{value}</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">{hint}</p>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemProfileCard({
|
||||||
|
profile,
|
||||||
|
nodeName,
|
||||||
|
}: {
|
||||||
|
profile: NodeSystemProfile;
|
||||||
|
nodeName: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-4 rounded-lg border px-4 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">主机名</p>
|
||||||
|
<p className="mt-2 text-sm">{profile.hostname || nodeName}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">操作系统</p>
|
||||||
|
<p className="mt-2 text-sm">
|
||||||
|
{profile.os_name || 'unknown'}
|
||||||
|
{profile.os_version ? ` ${profile.os_version}` : ''}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">内核 / 架构</p>
|
||||||
|
<p className="mt-2 text-sm">
|
||||||
|
{profile.kernel_version || 'unknown'} · {profile.architecture || 'unknown'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">在线时长</p>
|
||||||
|
<p className="mt-2 text-sm">{formatUptime(profile.uptime_seconds)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 rounded-lg border px-4 py-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">CPU</p>
|
||||||
|
<p className="mt-2 text-sm">{profile.cpu_model || 'unknown'}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{profile.cpu_cores || 0} 核</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">总内存</p>
|
||||||
|
<p className="mt-2 text-sm">{formatBytes(profile.total_memory_bytes)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">总存储</p>
|
||||||
|
<p className="mt-2 text-sm">{formatBytes(profile.total_disk_bytes)}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">上报时间</p>
|
||||||
|
<p className="mt-2 text-sm">
|
||||||
|
{isMeaningfulTime(profile.reported_at)
|
||||||
|
? formatDateTime(profile.reported_at)
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function HealthEventTimeline({
|
||||||
|
events,
|
||||||
|
allEventsCount,
|
||||||
|
healthEventFilter,
|
||||||
|
onFilterChange,
|
||||||
|
onCleanup,
|
||||||
|
cleanupPending,
|
||||||
|
}: {
|
||||||
|
events: NodeHealthEvent[];
|
||||||
|
allEventsCount: number;
|
||||||
|
healthEventFilter: HealthEventFilter;
|
||||||
|
onFilterChange: (filter: HealthEventFilter) => void;
|
||||||
|
onCleanup: () => void;
|
||||||
|
cleanupPending: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Card className="border-dashed shadow-none">
|
||||||
|
<CardHeader className="flex-row items-center justify-between space-y-0">
|
||||||
|
<div>
|
||||||
|
<CardTitle className="text-base font-semibold">健康事件时间线</CardTitle>
|
||||||
|
<CardDescription>保留活动与已恢复事件,帮助判断运行状态。</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs text-destructive hover:text-destructive"
|
||||||
|
disabled={cleanupPending || allEventsCount === 0}
|
||||||
|
onClick={onCleanup}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-3.5 mr-1" />
|
||||||
|
清理日志
|
||||||
|
</Button>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{allEventsCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
['all', '全部事件'],
|
||||||
|
['active', '活动中'],
|
||||||
|
['resolved', '已恢复'],
|
||||||
|
] as const
|
||||||
|
).map(([filter, label]) => (
|
||||||
|
<Button
|
||||||
|
key={filter}
|
||||||
|
type="button"
|
||||||
|
variant={healthEventFilter === filter ? 'secondary' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-7 text-xs rounded-full"
|
||||||
|
onClick={() => onFilterChange(filter)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">当前筛选下没有健康事件。</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{events.slice(0, 8).map((event) => (
|
||||||
|
<div
|
||||||
|
key={`${event.event_type}-${event.last_triggered_at}-${event.status}`}
|
||||||
|
className="rounded-lg border px-4 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<NodeStatusBadge
|
||||||
|
label={getHealthEventLabel(event)}
|
||||||
|
tone={getHealthEventTone(event)}
|
||||||
|
/>
|
||||||
|
<NodeStatusBadge
|
||||||
|
label={event.status === 'active' ? '活动中' : '已恢复'}
|
||||||
|
tone={event.status === 'active' ? 'warning' : 'success'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{event.message || '暂无详细消息'}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-3">
|
||||||
|
<p>
|
||||||
|
首次触发:
|
||||||
|
{isMeaningfulTime(event.first_triggered_at)
|
||||||
|
? ` ${formatDateTime(event.first_triggered_at)}`
|
||||||
|
: ' —'}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
最近触发:
|
||||||
|
{isMeaningfulTime(event.last_triggered_at)
|
||||||
|
? ` ${formatDateTime(event.last_triggered_at)}`
|
||||||
|
: ' —'}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
恢复时间:
|
||||||
|
{isMeaningfulTime(event.resolved_at) && event.resolved_at
|
||||||
|
? ` ${formatDateTime(event.resolved_at)}`
|
||||||
|
: ' —'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">节点当前还没有上报健康事件记录。</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function NodeObservability({
|
export function NodeObservability({
|
||||||
nodeId,
|
nodeId,
|
||||||
connectionHint = '活动连接数',
|
node,
|
||||||
|
variant = 'edge',
|
||||||
|
connectionHint = 'OpenResty 当前连接',
|
||||||
}: {
|
}: {
|
||||||
nodeId: number;
|
nodeId: number;
|
||||||
|
node?: NodeItem;
|
||||||
|
variant?: NodeObservabilityVariant;
|
||||||
connectionHint?: string;
|
connectionHint?: string;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -92,9 +300,12 @@ export function NodeObservability({
|
|||||||
: '当前没有可清理的健康事件日志',
|
: '当前没有可清理的健康事件日志',
|
||||||
);
|
);
|
||||||
setCleanupOpen(false);
|
setCleanupOpen(false);
|
||||||
await queryClient.invalidateQueries({
|
await Promise.all([
|
||||||
queryKey: ['openflare', 'node-observability', nodeId],
|
queryClient.invalidateQueries({
|
||||||
});
|
queryKey: ['openflare', 'node-observability', nodeId],
|
||||||
|
}),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['openflare', 'dashboard', 'overview'] }),
|
||||||
|
]);
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(getErrorMessage(error)),
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
});
|
});
|
||||||
@@ -121,6 +332,21 @@ export function NodeObservability({
|
|||||||
}
|
}
|
||||||
}, [activeHealthEvents, healthEventFilter, observability?.health_events, resolvedHealthEvents]);
|
}, [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(
|
const memoryUsageRatio = formatUsageRatio(
|
||||||
latestMetric?.memory_used_bytes,
|
latestMetric?.memory_used_bytes,
|
||||||
latestMetric?.memory_total_bytes,
|
latestMetric?.memory_total_bytes,
|
||||||
@@ -129,6 +355,8 @@ export function NodeObservability({
|
|||||||
latestMetric?.storage_used_bytes,
|
latestMetric?.storage_used_bytes,
|
||||||
latestMetric?.storage_total_bytes,
|
latestMetric?.storage_total_bytes,
|
||||||
);
|
);
|
||||||
|
const trends = observability?.trends;
|
||||||
|
const nodeName = node?.name ?? observability?.node_id ?? String(nodeId);
|
||||||
|
|
||||||
if (observabilityQuery.isLoading) {
|
if (observabilityQuery.isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -151,97 +379,202 @@ export function NodeObservability({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (variant === 'compact') {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="grid gap-4 md:grid-cols-3">
|
||||||
|
<SummaryStat
|
||||||
|
label="运行诊断"
|
||||||
|
value={
|
||||||
|
activeHealthEvents.length
|
||||||
|
? `${activeHealthEvents.length} 个活动异常`
|
||||||
|
: '运行稳定'
|
||||||
|
}
|
||||||
|
hint={
|
||||||
|
latestHealthEvent
|
||||||
|
? `${getHealthEventLabel(latestHealthEvent)} · ${latestHealthEvent.message || '等待处理'}`
|
||||||
|
: '当前没有活动中的健康事件'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SummaryStat
|
||||||
|
label="系统核心"
|
||||||
|
value={profile?.hostname || '—'}
|
||||||
|
hint={profile ? `${profile.os_name || '—'} · ${profile.architecture || '—'}` : '—'}
|
||||||
|
/>
|
||||||
|
<SummaryStat
|
||||||
|
label="在线时长"
|
||||||
|
value={formatUptime(profile?.uptime_seconds)}
|
||||||
|
hint="来自节点系统画像上报"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<Card className="border-dashed shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-semibold">系统画像</CardTitle>
|
||||||
|
<CardDescription>节点上报的主机与硬件信息</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{profile ? (
|
||||||
|
<SystemProfileCard profile={profile} nodeName={nodeName} />
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
节点已接入,但尚未上报完整系统画像。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-dashed shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-semibold">实时资源快照</CardTitle>
|
||||||
|
<CardDescription>最近一次 metrics 上报</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{latestMetric ? (
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<MetricBar
|
||||||
|
label="CPU"
|
||||||
|
value={formatPercent(latestMetric.cpu_usage_percent)}
|
||||||
|
progress={latestMetric.cpu_usage_percent}
|
||||||
|
hint={
|
||||||
|
isMeaningfulTime(latestMetric.captured_at)
|
||||||
|
? `快照 ${formatRelativeTime(latestMetric.captured_at)}`
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MetricBar
|
||||||
|
label="内存"
|
||||||
|
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
|
||||||
|
progress={memoryUsageRatio}
|
||||||
|
/>
|
||||||
|
<MetricBar
|
||||||
|
label="存储"
|
||||||
|
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
|
||||||
|
progress={storageUsageRatio}
|
||||||
|
/>
|
||||||
|
<MetricBar
|
||||||
|
label="连接数"
|
||||||
|
value={
|
||||||
|
latestMetric.openresty_connections
|
||||||
|
? String(latestMetric.openresty_connections)
|
||||||
|
: '—'
|
||||||
|
}
|
||||||
|
progress={null}
|
||||||
|
hint={connectionHint}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">节点已接入,但尚未上报资源快照。</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<HealthEventTimeline
|
||||||
|
events={filteredHealthEvents}
|
||||||
|
allEventsCount={observability?.health_events.length ?? 0}
|
||||||
|
healthEventFilter={healthEventFilter}
|
||||||
|
onFilterChange={setHealthEventFilter}
|
||||||
|
onCleanup={() => setCleanupOpen(true)}
|
||||||
|
cleanupPending={cleanupMutation.isPending}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>清理健康事件日志</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
该操作会删除此节点在控制端记录的所有健康诊断事件历史,不会影响后续新事件上报。
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel disabled={cleanupMutation.isPending}>取消</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
className="bg-destructive text-white hover:bg-destructive/90"
|
||||||
|
disabled={cleanupMutation.isPending}
|
||||||
|
onClick={() => cleanupMutation.mutate()}
|
||||||
|
>
|
||||||
|
{cleanupMutation.isPending ? '清理中...' : '确认清理'}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className="space-y-6">
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<Card className="border-dashed shadow-none">
|
<SummaryStat
|
||||||
<CardHeader className="pb-2">
|
label="运行诊断"
|
||||||
<CardDescription>运行诊断</CardDescription>
|
value={
|
||||||
<CardTitle className="text-base font-semibold">
|
activeHealthEvents.length
|
||||||
{activeHealthEvents.length ? `${activeHealthEvents.length} 个活动异常` : '运行稳定'}
|
? `${activeHealthEvents.length} 个活动异常`
|
||||||
</CardTitle>
|
: '运行稳定'
|
||||||
</CardHeader>
|
}
|
||||||
</Card>
|
hint={
|
||||||
<Card className="border-dashed shadow-none">
|
latestHealthEvent
|
||||||
<CardHeader className="pb-2">
|
? `${getHealthEventLabel(latestHealthEvent)} · ${latestHealthEvent.message || '等待处理'}`
|
||||||
<CardDescription>系统核心</CardDescription>
|
: '当前没有活动中的健康事件'
|
||||||
<CardTitle className="text-base font-semibold">
|
}
|
||||||
{profile?.hostname || '—'}
|
/>
|
||||||
</CardTitle>
|
<SummaryStat
|
||||||
</CardHeader>
|
label="当前窗口请求"
|
||||||
</Card>
|
value={trafficSummary ? trafficSummary.request_count.toLocaleString('zh-CN') : '—'}
|
||||||
<Card className="border-dashed shadow-none">
|
hint={
|
||||||
<CardHeader className="pb-2">
|
trafficSummary
|
||||||
<CardDescription>在线时长</CardDescription>
|
? `QPS ${trafficSummary.estimated_qps.toFixed(1)} · 错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
|
||||||
<CardTitle className="text-base font-semibold">
|
: '当前没有可展示的请求窗口摘要'
|
||||||
{formatUptime(profile?.uptime_seconds)}
|
}
|
||||||
</CardTitle>
|
/>
|
||||||
</CardHeader>
|
<SummaryStat
|
||||||
</Card>
|
label="容量压力"
|
||||||
|
value={healthSummary?.has_capacity_risk ? '需要关注' : '正常范围'}
|
||||||
|
hint={
|
||||||
|
latestMetric
|
||||||
|
? `CPU ${formatPercent(latestMetric.cpu_usage_percent)} · 存储 ${formatPercent(storageUsageRatio)}`
|
||||||
|
: '当前没有资源快照'
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<SummaryStat
|
||||||
|
label="来源信号"
|
||||||
|
value={topSourceCountry?.key ?? '—'}
|
||||||
|
hint={
|
||||||
|
topSourceCountry
|
||||||
|
? `${topSourceCountry.value.toLocaleString('zh-CN')} 次请求`
|
||||||
|
: '当前没有来源分布数据'
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-2">
|
<div className="grid gap-6 xl:grid-cols-3">
|
||||||
<Card className="border-dashed shadow-none">
|
<Card className="border-dashed shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base font-semibold">系统画像</CardTitle>
|
<CardTitle className="text-base font-semibold">系统信息</CardTitle>
|
||||||
<CardDescription>节点上报的主机与硬件信息</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3 text-sm">
|
<CardContent>
|
||||||
{profile ? (
|
{profile ? (
|
||||||
<>
|
<SystemProfileCard profile={profile} nodeName={nodeName} />
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">操作系统</span>
|
|
||||||
<span>
|
|
||||||
{profile.os_name || 'unknown'}
|
|
||||||
{profile.os_version ? ` ${profile.os_version}` : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">内核 / 架构</span>
|
|
||||||
<span>
|
|
||||||
{profile.kernel_version || 'unknown'} · {profile.architecture || 'unknown'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">CPU</span>
|
|
||||||
<span>
|
|
||||||
{profile.cpu_model || 'unknown'}({profile.cpu_cores || 0} 核)
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">总内存</span>
|
|
||||||
<span>{formatBytes(profile.total_memory_bytes)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">总存储</span>
|
|
||||||
<span>{formatBytes(profile.total_disk_bytes)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center justify-between gap-3">
|
|
||||||
<span className="text-muted-foreground">上报时间</span>
|
|
||||||
<span>
|
|
||||||
{isMeaningfulTime(profile.reported_at)
|
|
||||||
? formatDateTime(profile.reported_at)
|
|
||||||
: '—'}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="text-muted-foreground">节点已接入,但尚未上报完整系统画像。</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
节点已经接入,但还没有上报完整系统画像。
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className="border-dashed shadow-none">
|
<Card className="border-dashed shadow-none">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base font-semibold">实时资源快照</CardTitle>
|
<CardTitle className="text-base font-semibold">实时资源</CardTitle>
|
||||||
<CardDescription>最近一次 metrics 上报</CardDescription>
|
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{latestMetric ? (
|
{latestMetric ? (
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
<MetricBar
|
<MetricBar
|
||||||
label="CPU"
|
label="CPU"
|
||||||
value={`${latestMetric.cpu_usage_percent.toFixed(1)}%`}
|
value={formatPercent(latestMetric.cpu_usage_percent)}
|
||||||
progress={latestMetric.cpu_usage_percent}
|
progress={latestMetric.cpu_usage_percent}
|
||||||
hint={
|
hint={
|
||||||
isMeaningfulTime(latestMetric.captured_at)
|
isMeaningfulTime(latestMetric.captured_at)
|
||||||
@@ -260,7 +593,7 @@ export function NodeObservability({
|
|||||||
progress={storageUsageRatio}
|
progress={storageUsageRatio}
|
||||||
/>
|
/>
|
||||||
<MetricBar
|
<MetricBar
|
||||||
label="活动连接"
|
label="连接数"
|
||||||
value={
|
value={
|
||||||
latestMetric.openresty_connections
|
latestMetric.openresty_connections
|
||||||
? String(latestMetric.openresty_connections)
|
? String(latestMetric.openresty_connections)
|
||||||
@@ -271,94 +604,178 @@ export function NodeObservability({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">节点已接入,但尚未上报资源快照。</p>
|
<p className="text-sm text-muted-foreground">节点已经接入,但还没有上报资源快照。</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card className="border-dashed shadow-none">
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle className="text-base font-semibold">网络流量</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{latestMetric ? (
|
||||||
|
<>
|
||||||
|
{node ? (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<NodeStatusBadge
|
||||||
|
label={getNodeStatusLabel(node.status)}
|
||||||
|
tone={getNodeStatusTone(node.status)}
|
||||||
|
/>
|
||||||
|
<NodeStatusBadge
|
||||||
|
label={getOpenrestyStatusLabel(node.openresty_status)}
|
||||||
|
tone={getOpenrestyStatusTone(node.openresty_status)}
|
||||||
|
/>
|
||||||
|
<NodeStatusBadge
|
||||||
|
label={
|
||||||
|
activeHealthEvents.length
|
||||||
|
? `${activeHealthEvents.length} 个活动异常`
|
||||||
|
: '无活动异常'
|
||||||
|
}
|
||||||
|
tone={activeHealthEvents.length ? 'warning' : 'success'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-lg border px-3 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
|
OpenResty 吞吐
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>入站:{formatBytesPerSecond(latestMetric.openresty_rx_bytes, 60)}</p>
|
||||||
|
<p>出站:{formatBytesPerSecond(latestMetric.openresty_tx_bytes, 60)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border px-3 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
|
节点网络
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 space-y-2 text-sm text-muted-foreground">
|
||||||
|
<p>入站:{formatBytes(latestMetric.network_rx_bytes)}</p>
|
||||||
|
<p>出站:{formatBytes(latestMetric.network_tx_bytes)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
<div className="rounded-lg border px-3 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
|
最近窗口请求
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-2xl font-semibold">
|
||||||
|
{trafficSummary
|
||||||
|
? trafficSummary.request_count.toLocaleString('zh-CN')
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{trafficSummary
|
||||||
|
? `QPS ${trafficSummary.estimated_qps.toFixed(1)} · UV ${trafficSummary.unique_visitor_count}`
|
||||||
|
: '暂无窗口流量摘要'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border px-3 py-3">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
|
最近窗口错误
|
||||||
|
</p>
|
||||||
|
<p className="mt-3 text-2xl font-semibold">
|
||||||
|
{trafficSummary ? trafficSummary.error_count.toLocaleString('zh-CN') : '—'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{trafficSummary
|
||||||
|
? `错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
|
||||||
|
: '暂无错误率摘要'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
节点已经接入,但还没有上报网络流量相关快照。
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-dashed shadow-none">
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
<CardHeader className="flex-row items-center justify-between space-y-0">
|
<TrafficTrendChart
|
||||||
<div>
|
points={trends?.traffic_24h ?? []}
|
||||||
<CardTitle className="text-base font-semibold">健康事件时间线</CardTitle>
|
description="按小时聚合该节点的请求量和错误量。"
|
||||||
<CardDescription>保留活动与已恢复事件,用于运行异常诊断</CardDescription>
|
/>
|
||||||
</div>
|
<CapacityTrendChart
|
||||||
<Button
|
points={trends?.capacity_24h ?? []}
|
||||||
variant="outline"
|
description="观察该节点 CPU 与内存使用率在 24 小时内的变化。"
|
||||||
size="sm"
|
/>
|
||||||
className="h-7 text-xs text-destructive hover:text-destructive"
|
</div>
|
||||||
disabled={cleanupMutation.isPending || !observability?.health_events.length}
|
|
||||||
onClick={() => setCleanupOpen(true)}
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
>
|
<NetworkTrendChart points={trends?.network_24h ?? []} />
|
||||||
<Trash2 className="size-3.5 mr-1" />
|
<DiskIOTrendChart points={trends?.disk_io_24h ?? []} />
|
||||||
清理日志
|
</div>
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
|
||||||
<CardContent className="space-y-4">
|
<Card className="border-dashed shadow-none">
|
||||||
{observability?.health_events.length ? (
|
<CardHeader>
|
||||||
<>
|
<CardTitle className="text-base font-semibold">请求结构分布</CardTitle>
|
||||||
<div className="flex flex-wrap gap-2">
|
<CardDescription>
|
||||||
{(['all', 'active', 'resolved'] as const).map((filter) => (
|
聚合最近 24 小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。
|
||||||
<Button
|
</CardDescription>
|
||||||
key={filter}
|
</CardHeader>
|
||||||
type="button"
|
<CardContent>
|
||||||
variant={healthEventFilter === filter ? 'secondary' : 'outline'}
|
<div className="mb-6 grid gap-4 md:grid-cols-3">
|
||||||
size="sm"
|
<div className="rounded-lg border px-4 py-4">
|
||||||
className="h-7 text-xs rounded-full"
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">主状态码</p>
|
||||||
onClick={() => setHealthEventFilter(filter)}
|
<p className="mt-3 text-2xl font-semibold">{dominantStatusCode?.label ?? '—'}</p>
|
||||||
>
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
{filter === 'all' ? '全部' : filter === 'active' ? '活动中' : '已恢复'}
|
{dominantStatusCode ? `${dominantStatusCode.value} 次` : '暂无状态码分布'}
|
||||||
</Button>
|
</p>
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="rounded-lg border px-4 py-4">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">Top Domain</p>
|
||||||
|
<p className="mt-3 truncate text-2xl font-semibold">
|
||||||
|
{dominantDomain?.label ?? '—'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">
|
||||||
|
{dominantDomain ? `${dominantDomain.value} 次` : '暂无域名分布'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border px-4 py-4">
|
||||||
|
<p className="text-xs text-muted-foreground uppercase tracking-wide">已恢复事件</p>
|
||||||
|
<p className="mt-3 text-2xl font-semibold">{resolvedHealthEvents.length}</p>
|
||||||
|
<p className="mt-2 text-sm text-muted-foreground">最近 24 小时已恢复健康事件</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{filteredHealthEvents.slice(0, 8).map((event) => (
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
<div key={`${event.event_type}-${event.last_triggered_at}-${event.status}`} className="rounded-lg border px-4 py-3">
|
<div>
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<p className="mb-4 text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
<NodeStatusBadge
|
状态码分布
|
||||||
label={getHealthEventLabel(event)}
|
</p>
|
||||||
tone={getHealthEventTone(event)}
|
<DistributionList
|
||||||
/>
|
items={statusCodeDistribution}
|
||||||
<NodeStatusBadge
|
emptyMessage="暂无状态码分布"
|
||||||
label={event.status === 'active' ? '活动中' : '已恢复'}
|
/>
|
||||||
tone={event.status === 'active' ? 'warning' : 'success'}
|
</div>
|
||||||
/>
|
<div>
|
||||||
</div>
|
<p className="mb-4 text-xs text-muted-foreground uppercase tracking-wide">
|
||||||
<p className="mt-2 text-sm text-muted-foreground">
|
Top Domain
|
||||||
{event.message || '暂无详细消息'}
|
</p>
|
||||||
</p>
|
<DistributionList items={topDomains} emptyMessage="暂无域名分布" />
|
||||||
<div className="mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-3">
|
</div>
|
||||||
<p>
|
</div>
|
||||||
首次触发:
|
</CardContent>
|
||||||
{isMeaningfulTime(event.first_triggered_at)
|
</Card>
|
||||||
? ` ${formatDateTime(event.first_triggered_at)}`
|
|
||||||
: ' —'}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
最近触发:
|
|
||||||
{isMeaningfulTime(event.last_triggered_at)
|
|
||||||
? ` ${formatDateTime(event.last_triggered_at)}`
|
|
||||||
: ' —'}
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
恢复时间:
|
|
||||||
{isMeaningfulTime(event.resolved_at) && event.resolved_at
|
|
||||||
? ` ${formatDateTime(event.resolved_at)}`
|
|
||||||
: ' —'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{filteredHealthEvents.length === 0 ? (
|
<HealthEventTimeline
|
||||||
<p className="text-sm text-muted-foreground">当前筛选下没有健康事件。</p>
|
events={filteredHealthEvents}
|
||||||
) : null}
|
allEventsCount={observability?.health_events.length ?? 0}
|
||||||
</>
|
healthEventFilter={healthEventFilter}
|
||||||
) : (
|
onFilterChange={setHealthEventFilter}
|
||||||
<p className="text-sm text-muted-foreground">节点当前还没有上报健康事件记录。</p>
|
onCleanup={() => setCleanupOpen(true)}
|
||||||
)}
|
cleanupPending={cleanupMutation.isPending}
|
||||||
</CardContent>
|
/>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
|
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
@@ -380,6 +797,6 @@ export function NodeObservability({
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,10 @@
|
|||||||
import type {ApplyResult, NodeItem, NodeStatus, OpenrestyStatus} from '@/lib/services/openflare';
|
import type {
|
||||||
|
ApplyResult,
|
||||||
|
NodeItem,
|
||||||
|
NodeStatus,
|
||||||
|
NodeTrafficReport,
|
||||||
|
OpenrestyStatus,
|
||||||
|
} from '@/lib/services/openflare';
|
||||||
|
|
||||||
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
|
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
|
||||||
export const FLARED_WS_CONNECTED_LAST_SEEN = '__OPENFLARE_FLARED_WS_CONNECTED__';
|
export const FLARED_WS_CONNECTED_LAST_SEEN = '__OPENFLARE_FLARED_WS_CONNECTED__';
|
||||||
@@ -150,9 +156,12 @@ export function buildTunnelDockerInstallCommand(serverUrl: string, tunnelToken:
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function formatBytes(bytes?: number | null, decimals = 1) {
|
export function formatBytes(bytes?: number | null, decimals = 1) {
|
||||||
if (!bytes || !Number.isFinite(bytes) || bytes <= 0) {
|
if (bytes === undefined || bytes === null || !Number.isFinite(bytes)) {
|
||||||
return '—';
|
return '—';
|
||||||
}
|
}
|
||||||
|
if (bytes <= 0) {
|
||||||
|
return '0 B';
|
||||||
|
}
|
||||||
|
|
||||||
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||||
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
|
||||||
@@ -160,6 +169,65 @@ export function formatBytes(bytes?: number | null, decimals = 1) {
|
|||||||
return `${value.toFixed(decimals)} ${units[index]}`;
|
return `${value.toFixed(decimals)} ${units[index]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatPercent(value?: number | null) {
|
||||||
|
if (value === undefined || value === null || !Number.isFinite(value)) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return `${value.toFixed(1)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytesPerSecond(
|
||||||
|
value?: number | null,
|
||||||
|
windowSeconds = 1,
|
||||||
|
) {
|
||||||
|
if (value === undefined || value === null || !Number.isFinite(value)) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
if (windowSeconds <= 0) {
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
return `${formatBytes(value / windowSeconds)}/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseTrafficMap(value?: string | null) {
|
||||||
|
if (!value) {
|
||||||
|
return {} as Record<string, number>;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(value) as Record<string, number>;
|
||||||
|
return Object.entries(parsed).reduce<Record<string, number>>((result, [key, count]) => {
|
||||||
|
if (typeof count === 'number' && Number.isFinite(count)) {
|
||||||
|
result[key] = count;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
} catch {
|
||||||
|
return {} as Record<string, number>;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function aggregateTrafficBreakdown(
|
||||||
|
reports: NodeTrafficReport[] | undefined,
|
||||||
|
field: 'status_codes_json' | 'top_domains_json',
|
||||||
|
) {
|
||||||
|
const summary = new Map<string, number>();
|
||||||
|
for (const report of reports ?? []) {
|
||||||
|
const parsed = parseTrafficMap(report[field]);
|
||||||
|
for (const [key, value] of Object.entries(parsed)) {
|
||||||
|
summary.set(key, (summary.get(key) ?? 0) + value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(summary.entries())
|
||||||
|
.sort((left, right) => {
|
||||||
|
if (right[1] === left[1]) {
|
||||||
|
return left[0].localeCompare(right[0]);
|
||||||
|
}
|
||||||
|
return right[1] - left[1];
|
||||||
|
})
|
||||||
|
.slice(0, 6)
|
||||||
|
.map(([label, value]) => ({ label, value }));
|
||||||
|
}
|
||||||
|
|
||||||
export function formatUsageRatio(used?: number | null, total?: number | null) {
|
export function formatUsageRatio(used?: number | null, total?: number | null) {
|
||||||
if (!used || !total || total <= 0) {
|
if (!used || !total || total <= 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -347,7 +347,11 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
|
|||||||
|
|
||||||
<InstallCommand node={node} variant="relay" />
|
<InstallCommand node={node} variant="relay" />
|
||||||
|
|
||||||
<NodeObservability nodeId={node.id} connectionHint="中继承载活动连接数" />
|
<NodeObservability
|
||||||
|
nodeId={node.id}
|
||||||
|
variant="compact"
|
||||||
|
connectionHint="中继承载活动连接数"
|
||||||
|
/>
|
||||||
|
|
||||||
<NodeEditorDialog
|
<NodeEditorDialog
|
||||||
open={editorOpen}
|
open={editorOpen}
|
||||||
|
|||||||
@@ -322,7 +322,11 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
|
|||||||
|
|
||||||
<InstallCommand node={node} variant="tunnel" />
|
<InstallCommand node={node} variant="tunnel" />
|
||||||
|
|
||||||
<NodeObservability nodeId={node.id} connectionHint="隧道并发连接数" />
|
<NodeObservability
|
||||||
|
nodeId={node.id}
|
||||||
|
variant="compact"
|
||||||
|
connectionHint="隧道并发连接数"
|
||||||
|
/>
|
||||||
|
|
||||||
<NodeEditorDialog
|
<NodeEditorDialog
|
||||||
open={editorOpen}
|
open={editorOpen}
|
||||||
|
|||||||
@@ -38,7 +38,12 @@ export type {
|
|||||||
NodeBootstrapToken,
|
NodeBootstrapToken,
|
||||||
NodeItem,
|
NodeItem,
|
||||||
NodeMutationPayload,
|
NodeMutationPayload,
|
||||||
|
NodeHealthEvent,
|
||||||
NodeObservability,
|
NodeObservability,
|
||||||
|
NodeObservabilityAnalytics,
|
||||||
|
NodeObservabilityTrends,
|
||||||
|
NodeSystemProfile,
|
||||||
|
NodeTrafficReport,
|
||||||
NodeStatus,
|
NodeStatus,
|
||||||
NodeType,
|
NodeType,
|
||||||
OpenrestyStatus,
|
OpenrestyStatus,
|
||||||
|
|||||||
@@ -147,11 +147,65 @@ export interface NodeHealthEvent {
|
|||||||
resolved_at?: string | null;
|
resolved_at?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface NodeTrafficReport {
|
||||||
|
window_started_at: string;
|
||||||
|
window_ended_at: string;
|
||||||
|
request_count: number;
|
||||||
|
error_count: number;
|
||||||
|
unique_visitor_count: number;
|
||||||
|
status_codes_json: string;
|
||||||
|
top_domains_json: string;
|
||||||
|
source_countries_json: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeTrafficSummary {
|
||||||
|
window_started_at: string;
|
||||||
|
window_ended_at: string;
|
||||||
|
request_count: number;
|
||||||
|
unique_visitor_count: number;
|
||||||
|
error_count: number;
|
||||||
|
estimated_qps: number;
|
||||||
|
error_rate_percent: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeHealthSummary {
|
||||||
|
active_alerts: number;
|
||||||
|
critical_alerts: number;
|
||||||
|
warning_alerts: number;
|
||||||
|
info_alerts: number;
|
||||||
|
resolved_alerts: number;
|
||||||
|
has_capacity_risk: boolean;
|
||||||
|
has_traffic_risk: boolean;
|
||||||
|
has_runtime_risk: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeTrafficDistributions {
|
||||||
|
status_codes: DistributionItem[];
|
||||||
|
top_domains: DistributionItem[];
|
||||||
|
source_countries: DistributionItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeObservabilityAnalytics {
|
||||||
|
traffic: NodeTrafficSummary | null;
|
||||||
|
distributions: NodeTrafficDistributions;
|
||||||
|
health: NodeHealthSummary | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NodeObservabilityTrends {
|
||||||
|
traffic_24h: TrafficTrendPoint[];
|
||||||
|
capacity_24h: CapacityTrendPoint[];
|
||||||
|
network_24h: NetworkTrendPoint[];
|
||||||
|
disk_io_24h: DiskIOTrendPoint[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface NodeObservability {
|
export interface NodeObservability {
|
||||||
node_id: string;
|
node_id: string;
|
||||||
profile: NodeSystemProfile | null;
|
profile: NodeSystemProfile | null;
|
||||||
metric_snapshots: NodeMetricSnapshot[];
|
metric_snapshots: NodeMetricSnapshot[];
|
||||||
|
traffic_reports?: NodeTrafficReport[];
|
||||||
health_events: NodeHealthEvent[];
|
health_events: NodeHealthEvent[];
|
||||||
|
analytics?: NodeObservabilityAnalytics;
|
||||||
|
trends?: NodeObservabilityTrends;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ProxyRouteConfigSection =
|
export type ProxyRouteConfigSection =
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ sidebar: false
|
|||||||
- 补全前端迁移收尾:新增 `/openflare/about` 关于页与 `AboutService`;补全 `UpdateService` 升级流程(在线升级、手动上传、WebSocket 日志流)及顶栏版本升级入口。
|
- 补全前端迁移收尾:新增 `/openflare/about` 关于页与 `AboutService`;补全 `UpdateService` 升级流程(在线升级、手动上传、WebSocket 日志流)及顶栏版本升级入口。
|
||||||
- 前端 UI 打磨:节点/WAF/DNS/配置清理等 Dialog 补 RHF+Zod;代理规则详情与访问日志 Tab 补 error 态;`pnpm build:embed` 增至 47 静态页。
|
- 前端 UI 打磨:节点/WAF/DNS/配置清理等 Dialog 补 RHF+Zod;代理规则详情与访问日志 Tab 补 error 态;`pnpm build:embed` 增至 47 静态页。
|
||||||
- 修复开发模式下 `/api/*` 尾斜杠引发 Next.js 308 与 Gin 301 循环重定向:前端 Service 与 `api-client` 请求拦截器规范化路径、强制浏览器同源代理、Next `skipTrailingSlashRedirect`、后端 `RegisterCollection` 双路径注册;Access Token 管理员权限校验对齐 `token_admin`。
|
- 修复开发模式下 `/api/*` 尾斜杠引发 Next.js 308 与 Gin 301 循环重定向:前端 Service 与 `api-client` 请求拦截器规范化路径、强制浏览器同源代理、Next `skipTrailingSlashRedirect`、后端 `RegisterCollection` 双路径注册;Access Token 管理员权限校验对齐 `token_admin`。
|
||||||
|
- 补全 Wavelet 边缘节点详情数据看板:迁移运行诊断摘要、系统信息、实时资源、网络流量、24 小时请求/容量/网络/磁盘趋势、请求结构分布与健康事件时间线。
|
||||||
- OpenFlare 前端路由去除 `/openflare` 前缀,业务页面直接挂载于 `/`(如 `/nodes`、`/websites`);侧栏移除「首页」「我的文件」;旧路径 `/openflare/*`、`/home` 永久重定向至新路径。
|
- OpenFlare 前端路由去除 `/openflare` 前缀,业务页面直接挂载于 `/`(如 `/nodes`、`/websites`);侧栏移除「首页」「我的文件」;旧路径 `/openflare/*`、`/home` 永久重定向至新路径。
|
||||||
|
|
||||||
## [v2.3.4] - 2026-06-17
|
## [v2.3.4] - 2026-06-17
|
||||||
|
|||||||
Reference in New Issue
Block a user