refactor(repo): consolidate openflare-server to root and move subprojects to internal/apps

- Merge all files inside openflare-server to the repository root directory.
- Relocate agent, relay, and flared subprojects from internal/ to internal/apps/.
- Combine docker-compose files and update build context paths to root.
- Update GitHub workflows and Dockerfiles to refer to new directories and package names.
- Rewrite Go package imports across all files.
- Resolve database renew test race condition and clean up docs.
This commit is contained in:
ryan
2026-06-19 14:23:29 +08:00
parent 19d476ed7f
commit 63cd906cfc
1064 changed files with 366 additions and 1397 deletions
@@ -0,0 +1,170 @@
'use client';
import {useState} from 'react';
import {useQuery} from '@tanstack/react-query';
import {ExternalLink, Loader2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {formatDateTime} from '@/lib/utils';
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare';
import {NodeService} from '@/lib/services/openflare';
import {NodeStatusBadge} from './node-status-badge';
import {formatRelativeTime, getErrorMessage} from './node-utils';
export function AgentUpdateDialog({
open,
node,
submitting,
onClose,
onConfirm,
}: {
open: boolean;
node: NodeItem;
submitting: boolean;
onClose: () => void;
onConfirm: (release: NodeAgentReleaseInfo | null, channel: ReleaseChannel) => Promise<void>;
}) {
const [channel, setChannel] = useState<ReleaseChannel>('stable');
const releaseQuery = useQuery({
queryKey: ['openflare', 'node-agent-release', node.id, channel],
queryFn: () => NodeService.getAgentRelease(node.id, channel),
enabled: open,
});
const release = releaseQuery.data ?? null;
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogHeader>
<DialogTitle>Agent 升级</DialogTitle>
<DialogDescription>
检查 GitHub 发布并向节点下发升级指令,节点将在下一次心跳后执行。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-3 sm:grid-cols-3">
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">当前版本</p>
<p className="mt-1 text-sm font-medium">{node.version || 'unknown'}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">检查通道</p>
<p className="mt-1 text-sm font-medium">
{channel === 'preview' ? '预览版' : '正式版'}
</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-xs text-muted-foreground">更新状态</p>
<div className="mt-1">
<NodeStatusBadge
label={
node.update_requested
? node.update_channel === 'preview'
? '等待预览更新'
: '等待更新'
: '未下发'
}
tone={node.update_requested ? 'warning' : 'info'}
/>
</div>
</div>
</div>
{releaseQuery.isFetching ? (
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
<Loader2 className="size-4 mr-2 animate-spin" />
检查发布版本中...
</div>
) : releaseQuery.isError ? (
<p className="text-sm text-destructive">{getErrorMessage(releaseQuery.error)}</p>
) : release ? (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex flex-wrap items-center gap-2">
<NodeStatusBadge
label={release.has_update ? '发现可升级版本' : '当前已是最新版本'}
tone={release.has_update ? 'warning' : 'success'}
/>
{release.prerelease ? (
<NodeStatusBadge label="Preview 发布" tone="warning" />
) : (
<NodeStatusBadge label="正式发布" tone="info" />
)}
</div>
<div className="grid gap-3 sm:grid-cols-2 text-sm">
<div>
<p className="text-xs text-muted-foreground">目标版本</p>
<p className="mt-1 font-medium">{release.tag_name || '未找到'}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">发布时间</p>
<p className="mt-1">
{release.published_at
? `${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
: '—'}
</p>
</div>
</div>
<p className="text-sm text-muted-foreground whitespace-pre-wrap">
{release.body || '暂无更新说明'}
</p>
{release.html_url ? (
<a
href={release.html_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center text-sm text-primary hover:underline"
>
查看发布详情
<ExternalLink className="size-3 ml-1" />
</a>
) : null}
</div>
) : null}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
disabled={submitting || releaseQuery.isFetching}
onClick={() => setChannel('stable')}
>
检查正式版
</Button>
<Button
type="button"
variant="outline"
disabled={submitting || releaseQuery.isFetching}
onClick={() => setChannel('preview')}
>
检查预览版
</Button>
<Button
type="button"
disabled={
submitting ||
releaseQuery.isFetching ||
!release?.has_update ||
node.update_requested
}
onClick={() => void onConfirm(release, channel)}
>
{submitting ? '下发中...' : channel === 'preview' ? '升级到预览版' : '升级到正式版'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,54 @@
'use client';
import {TrendChart} from '@/components/data/trend-chart';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type {DiskIOTrendPoint} from '@/lib/services/openflare';
import {formatBytes, formatTrendHour} from '../../components/dashboard/dashboard-utils';
export function DiskIOTrendChart({
points,
title = '24 小时磁盘 IO 趋势',
description = '观察磁盘读写变化,辅助判断日志放大、缓存抖动或磁盘压力。',
}: {
points: DiskIOTrendPoint[];
title?: string;
description?: string;
}) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
<CardDescription className="text-xs">{description}</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
labels={points.map((point) => formatTrendHour(point.bucket_started_at))}
yAxisValueFormatter={formatBytes}
series={[
{
label: '磁盘读',
color: '#a78bfa',
fillColor: 'rgba(167, 139, 250, 0.14)',
variant: 'area',
values: points.map((point) => point.disk_read_bytes),
valueFormatter: formatBytes,
},
{
label: '磁盘写',
color: '#fb7185',
values: points.map((point) => point.disk_write_bytes),
valueFormatter: formatBytes,
},
]}
/>
</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>
);
}
@@ -0,0 +1,361 @@
'use client';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useState} from 'react';
import {
Activity,
Cpu,
FileText,
Fingerprint,
Package,
RefreshCw,
RotateCcw,
Trash2,
Upload,
} 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 {formatDateTime} from '@/lib/utils';
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare';
import {NodeService} from '@/lib/services/openflare';
import {AgentUpdateDialog} from './agent-update-dialog';
import {NodeDetailShell} from './node-detail-shell';
import {
NodeErrorBanner,
NodeInfoRow,
NodeSectionCard,
} from './node-detail-primitives';
import {NodeEditorDialog} from './node-editor-dialog';
import {NodeObservability} from './node-observability';
import {NodeStatusBadge} from './node-status-badge';
import {
formatRelativeTime,
getApplyLabel,
getApplyTone,
getErrorMessage,
getNodeStatusLabel,
getNodeStatusTone,
getOpenrestyStatusLabel,
getOpenrestyStatusTone,
isMeaningfulTime,
} from './node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function EdgeNodeDetail({ node }: { node: NodeItem }) {
const router = useRouter();
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [upgradeOpen, setUpgradeOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const saveMutation = useMutation({
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload),
onSuccess: async () => {
toast.success('节点已更新');
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => {
toast.success(`已向节点 ${updated.name} 下发强制同步指令`);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const restartMutation = useMutation({
mutationFn: () => NodeService.requestOpenrestyRestart(node.id),
onSuccess: async (updated) => {
toast.success(`已向节点 ${updated.name} 下发 OpenResty 重启指令`);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const upgradeMutation = useMutation({
mutationFn: ({
release,
channel,
}: {
release: NodeAgentReleaseInfo | null;
channel: ReleaseChannel;
}) =>
NodeService.requestAgentUpdate(node.id, {
channel: release?.channel ?? channel,
tag_name: release?.channel === 'preview' ? release.tag_name || undefined : undefined,
}),
onSuccess: async (updated) => {
toast.success(
`已向节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`,
);
setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => {
toast.success('节点已删除');
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes');
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const handleRefresh = () => {
void Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'node-observability', node.id] }),
]);
};
const headerActions = (
<>
<Button variant="outline" size="sm" className="h-8" onClick={() => setEditorOpen(true)}>
编辑
</Button>
<Button variant="outline" size="sm" className="h-8" onClick={handleRefresh}>
<RefreshCw className="size-3.5 mr-1.5" />
刷新
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
disabled={forceSyncMutation.isPending}
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className="size-3.5 mr-1.5" />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
disabled={restartMutation.isPending}
onClick={() => restartMutation.mutate()}
>
{restartMutation.isPending ? '下发中...' : '重启 OpenResty'}
</Button>
<Button variant="secondary" size="sm" className="h-8" onClick={() => setUpgradeOpen(true)}>
<Upload className="size-3.5 mr-1.5" />
{node.update_requested ? '查看升级' : '升级 Agent'}
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-3.5 mr-1.5" />
删除
</Button>
</>
);
const overviewTab = (
<div className="space-y-6">
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
<div className="grid gap-6 xl:grid-cols-2">
<NodeSectionCard title="运行状态" description="节点在线情况与 OpenResty 健康">
<div className="divide-y">
<NodeInfoRow label="运行状态">
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
/>
</NodeInfoRow>
<NodeInfoRow label="OpenResty 健康">
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
</NodeInfoRow>
<NodeInfoRow label="最近心跳">
{isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
</NodeInfoRow>
<NodeInfoRow label="IP 地址">
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
</NodeInfoRow>
<NodeInfoRow label="地图点位">{node.geo_name || '未配置'}</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard
title="配置同步"
description="版本追平与应用结果摘要"
action={
<Button variant="outline" size="sm" className="h-8" asChild>
<Link href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}>
<FileText className="size-3.5 mr-1.5" />
应用记录
</Link>
</Button>
}
>
<div className="divide-y">
<NodeInfoRow label="当前配置版本">{node.current_version || '未应用'}</NodeInfoRow>
<NodeInfoRow label="最近应用">
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
tone={getApplyTone(node.latest_apply_result)}
/>
</NodeInfoRow>
<NodeInfoRow label="最近应用时间">
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无'}
</NodeInfoRow>
<NodeInfoRow label="Agent 版本">{node.version || 'unknown'}</NodeInfoRow>
<NodeInfoRow label="Nginx 版本">{node.ext_version || 'unknown'}</NodeInfoRow>
<NodeInfoRow label="自动更新">{node.auto_update_enabled ? '已启用' : '手动'}</NodeInfoRow>
</div>
</NodeSectionCard>
</div>
</div>
);
const manageTab = (
<div className="space-y-6">
<NodeSectionCard title="节点标识" description="用于 Agent 接入与鉴别的核心字段">
<div className="divide-y">
<NodeInfoRow label="节点 ID">
<span className="font-mono text-xs break-all">{node.node_id}</span>
</NodeInfoRow>
<NodeInfoRow label="Agent Token">
<span className="font-mono text-xs break-all">{node.access_token || '暂无'}</span>
</NodeInfoRow>
<NodeInfoRow label="创建时间">{formatDateTime(node.created_at)}</NodeInfoRow>
<NodeInfoRow label="更新时间">{formatDateTime(node.updated_at)}</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard title="运行消息" description="OpenResty 与 Agent 上报的状态说明">
<div className="space-y-4 text-sm">
<div>
<p className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
OpenResty 状态消息
</p>
<p className="rounded-lg border bg-muted/30 px-3 py-3 leading-6 break-words whitespace-pre-wrap">
{node.openresty_message || '当前未上报额外错误。'}
</p>
</div>
{node.restart_openresty_requested ? (
<div className="flex items-center gap-2 text-sm text-amber-700 dark:text-amber-300">
<Activity className="size-4" />
已等待节点在下一次心跳后执行 OpenResty 重启。
</div>
) : null}
</div>
</NodeSectionCard>
</div>
);
return (
<>
<NodeDetailShell
title={node.name}
typeLabel="Edge"
typeTone="info"
statusBadges={[
{ label: getNodeStatusLabel(node.status), tone: getNodeStatusTone(node.status) },
{
label: getOpenrestyStatusLabel(node.openresty_status),
tone: getOpenrestyStatusTone(node.openresty_status),
},
]}
actions={headerActions}
kpis={[
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
{ label: 'Agent 版本', value: node.version || 'unknown', icon: Cpu },
{ label: '当前配置', value: node.current_version || '未应用', icon: Package },
{
label: '最近心跳',
value: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at)
: '暂无',
icon: Activity,
},
]}
overview={overviewTab}
dashboard={
<NodeObservability
nodeId={node.id}
node={node}
variant="edge"
connectionHint="OpenResty 当前连接"
/>
}
manage={manageTab}
defaultTab="overview"
/>
<NodeEditorDialog
open={editorOpen}
node={node}
submitting={saveMutation.isPending}
onClose={() => setEditorOpen(false)}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
}}
/>
<AgentUpdateDialog
open={upgradeOpen}
node={node}
submitting={upgradeMutation.isPending}
onClose={() => setUpgradeOpen(false)}
onConfirm={async (release, channel) => {
await upgradeMutation.mutateAsync({ release, channel });
}}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
<AlertDialogDescription>
确认删除节点「{node.name}」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,173 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {Copy} from 'lucide-react';
import {toast} from 'sonner';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import type {NodeItem} from '@/lib/services/openflare';
import {
buildRelayDockerInstallCommand,
buildRelayInstallCommand,
buildTunnelDockerInstallCommand,
buildTunnelInstallCommand,
getServerUrl,
} from './node-utils';
type InstallVariant = 'relay' | 'tunnel';
const variantMeta: Record<
InstallVariant,
{
title: string;
description: string;
tokenLabel: string;
scriptLabel: string;
dockerLabel: string;
}
> = {
relay: {
title: '中继部署命令',
description: '使用 Discovery Token 将 frps 中继节点接入控制端。',
tokenLabel: 'Discovery Token',
scriptLabel: '一键脚本部署 (Linux / macOS)',
dockerLabel: 'Docker 容器部署',
},
tunnel: {
title: '隧道部署命令',
description: '使用 Tunnel Token 将 openflared 客户端接入控制端。',
tokenLabel: 'Tunnel Token',
scriptLabel: '一键脚本部署 (Linux / macOS)',
dockerLabel: 'Docker 容器部署',
},
};
export function InstallCommand({
node,
variant,
}: {
node: NodeItem;
variant: InstallVariant;
}) {
const [serverUrl, setServerUrl] = useState('');
const meta = variantMeta[variant];
useEffect(() => {
if (typeof window !== 'undefined' && !serverUrl) {
setServerUrl(window.location.origin);
}
}, [serverUrl]);
const normalizedServerUrl = getServerUrl(serverUrl);
const scriptCommand = useMemo(() => {
if (!normalizedServerUrl || !node.access_token) {
return '';
}
return variant === 'relay'
? buildRelayInstallCommand(normalizedServerUrl, node.access_token)
: buildTunnelInstallCommand(normalizedServerUrl, node.access_token);
}, [normalizedServerUrl, node.access_token, variant]);
const dockerCommand = useMemo(() => {
if (!normalizedServerUrl || !node.access_token) {
return '';
}
return variant === 'relay'
? buildRelayDockerInstallCommand(normalizedServerUrl, node.access_token)
: buildTunnelDockerInstallCommand(normalizedServerUrl, node.access_token);
}, [normalizedServerUrl, node.access_token, variant]);
const handleCopy = async (value: string, message: string) => {
try {
await navigator.clipboard.writeText(value);
toast.success(message);
} catch {
toast.error('复制失败,请手动选择命令文本。');
}
};
return (
<Card className="border-dashed shadow-none">
<CardHeader className="flex-row items-start justify-between space-y-0 gap-4">
<div>
<CardTitle className="text-base font-semibold">{meta.title}</CardTitle>
<CardDescription>{meta.description}</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
{scriptCommand ? (
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
onClick={() => void handleCopy(scriptCommand, '部署脚本命令已复制')}
>
<Copy className="size-3.5 mr-1" />
复制脚本
</Button>
) : null}
{dockerCommand ? (
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
onClick={() => void handleCopy(dockerCommand, 'Docker 部署命令已复制')}
>
<Copy className="size-3.5 mr-1" />
复制 Docker
</Button>
) : null}
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-lg border px-3 py-3">
<p className="text-xs text-muted-foreground">Node ID</p>
<p className="mt-1 text-sm break-all font-medium">{node.node_id}</p>
</div>
<div className="rounded-lg border px-3 py-3">
<p className="text-xs text-muted-foreground">{meta.tokenLabel}</p>
<p className="mt-1 text-sm break-all font-medium">{node.access_token || '暂无'}</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor={`server-url-${variant}`}>Server URL(控制端地址)</Label>
<Input
id={`server-url-${variant}`}
value={serverUrl}
onChange={(event) => setServerUrl(event.target.value)}
placeholder="https://openflare.example.com"
/>
<p className="text-xs text-muted-foreground">
部署脚本和容器将使用此地址连接 OpenFlare 控制端。
</p>
</div>
{!node.access_token ? (
<p className="text-sm text-muted-foreground">节点尚未生成接入 Token,请稍后刷新。</p>
) : !normalizedServerUrl ? (
<p className="text-sm text-muted-foreground">请填写有效的 Server URL 以生成部署命令。</p>
) : (
<>
<div className="space-y-2">
<p className="text-sm font-medium">{meta.scriptLabel}</p>
<pre className="overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap">
{scriptCommand}
</pre>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">{meta.dockerLabel}</p>
<pre className="overflow-x-auto rounded-lg border bg-muted/40 p-3 text-xs whitespace-pre-wrap">
{dockerCommand}
</pre>
</div>
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,57 @@
'use client';
import {TrendChart} from '@/components/data/trend-chart';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type {NetworkTrendPoint} from '@/lib/services/openflare';
import {
formatBytesPerSecond,
formatTrendHour,
} from '../../components/dashboard/dashboard-utils';
export function NetworkTrendChart({
points,
title = '24 小时网络趋势',
description = '观察 OpenResty 入站/出站吞吐的变化,辅助识别回源压力、突发流量或出口异常。',
}: {
points: NetworkTrendPoint[];
title?: string;
description?: string;
}) {
return (
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
<CardDescription className="text-xs">{description}</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
labels={points.map((point) => formatTrendHour(point.bucket_started_at))}
yAxisValueFormatter={(value) => formatBytesPerSecond(value, 3600)}
series={[
{
label: 'OpenResty 入站',
color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area',
values: points.map((point) => point.openresty_rx_bytes),
valueFormatter: (value) => formatBytesPerSecond(value, 3600),
},
{
label: 'OpenResty 出站',
color: '#38bdf8',
values: points.map((point) => point.openresty_tx_bytes),
valueFormatter: (value) => formatBytesPerSecond(value, 3600),
},
]}
/>
</CardContent>
</Card>
);
}
@@ -0,0 +1,79 @@
'use client';
import type {ReactNode} from 'react';
import type {LucideIcon} from 'lucide-react';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {cn} from '@/lib/utils';
export function NodeInfoRow({
label,
children,
className,
}: {
label: string;
children: ReactNode;
className?: string;
}) {
return (
<div className={cn('flex items-start justify-between gap-4 py-2.5 text-sm', className)}>
<span className="shrink-0 text-muted-foreground">{label}</span>
<div className="min-w-0 text-right font-medium">{children}</div>
</div>
);
}
export function NodeKpiCard({
label,
value,
icon: Icon,
}: {
label: string;
value: ReactNode;
icon?: LucideIcon;
}) {
return (
<div className="rounded-xl border bg-card/60 px-4 py-3.5 backdrop-blur-sm">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{Icon ? <Icon className="size-3.5 opacity-70" /> : null}
<span>{label}</span>
</div>
<p className="mt-2 text-sm font-semibold tracking-tight break-all">{value}</p>
</div>
);
}
export function NodeSectionCard({
title,
description,
action,
children,
className,
}: {
title: string;
description?: string;
action?: React.ReactNode;
children: ReactNode;
className?: string;
}) {
return (
<Card className={cn('border shadow-none', className)}>
<CardHeader className={cn('pb-3', action ? 'flex-row items-start justify-between space-y-0' : '')}>
<div className="space-y-1">
<CardTitle className="text-base font-semibold">{title}</CardTitle>
{description ? <CardDescription>{description}</CardDescription> : null}
</div>
{action}
</CardHeader>
<CardContent>{children}</CardContent>
</Card>
);
}
export function NodeErrorBanner({ message }: { message: string }) {
return (
<div className="rounded-xl border border-destructive/25 bg-destructive/5 px-4 py-3 text-sm text-destructive">
{message}
</div>
);
}
@@ -0,0 +1,178 @@
'use client';
import Link from 'next/link';
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
import {type ReactNode, useCallback, useMemo} from 'react';
import type {LucideIcon} from 'lucide-react';
import {ArrowLeft} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
import {cn} from '@/lib/utils';
import {NodeKpiCard} from './node-detail-primitives';
import {NodeStatusBadge} from './node-status-badge';
import type {StatusTone} from './node-utils';
export type NodeDetailTabId = 'overview' | 'dashboard' | 'manage';
export type NodeDetailTabConfig = {
id: NodeDetailTabId;
label: string;
icon?: LucideIcon;
};
export type NodeDetailKpi = {
label: string;
value: ReactNode;
icon?: LucideIcon;
};
const TAB_CONFIGS: NodeDetailTabConfig[] = [
{
id: 'overview',
label: '概览',
},
{
id: 'dashboard',
label: '数据看板',
},
{
id: 'manage',
label: '配置与部署',
},
];
function isValidTab(value: string | null): value is NodeDetailTabId {
return value === 'overview' || value === 'dashboard' || value === 'manage';
}
export function NodeDetailShell({
title,
typeLabel,
typeTone = 'info',
statusBadges,
actions,
kpis,
overview,
dashboard,
manage,
defaultTab = 'overview',
}: {
title: string;
typeLabel: string;
typeTone?: StatusTone;
statusBadges: Array<{ label: string; tone: StatusTone }>;
actions: ReactNode;
kpis: NodeDetailKpi[];
overview: ReactNode;
dashboard: ReactNode;
manage: ReactNode;
defaultTab?: NodeDetailTabId;
}) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const activeTab = useMemo(() => {
const tab = searchParams.get('tab');
return isValidTab(tab) ? tab : defaultTab;
}, [defaultTab, searchParams]);
const setActiveTab = useCallback(
(tab: NodeDetailTabId) => {
const params = new URLSearchParams(searchParams.toString());
params.set('tab', tab);
router.replace(`${pathname}?${params.toString()}`, { scroll: false });
},
[pathname, router, searchParams],
);
const activeTabMeta = TAB_CONFIGS.find((tab) => tab.id === activeTab) ?? TAB_CONFIGS[0];
return (
<div className="py-6 px-1 space-y-6">
<section className="overflow-hidden rounded-2xl border bg-gradient-to-br from-card via-card to-muted/30">
<div className="flex flex-col gap-5 p-5 md:p-6">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="flex min-w-0 items-start gap-3">
<Button variant="ghost" size="sm" className="mt-0.5 h-8 w-8 shrink-0 p-0" asChild>
<Link href="/nodes" aria-label="返回节点列表">
<ArrowLeft className="size-4" />
</Link>
</Button>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
<NodeStatusBadge label={typeLabel} tone={typeTone} />
</div>
<div className="flex flex-wrap items-center gap-2">
{statusBadges.map((badge) => (
<NodeStatusBadge
key={badge.label}
label={badge.label}
tone={badge.tone}
/>
))}
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-2">{actions}</div>
</div>
{kpis.length > 0 ? (
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
{kpis.map((kpi) => (
<NodeKpiCard
key={kpi.label}
label={kpi.label}
value={kpi.value}
icon={kpi.icon}
/>
))}
</div>
) : null}
</div>
</section>
<Tabs
value={activeTab}
onValueChange={(value) => {
if (isValidTab(value)) {
setActiveTab(value);
}
}}
className="w-full gap-0"
>
<div className="space-y-3 pb-1">
<TabsList variant="line" className="h-auto w-full justify-start gap-6 bg-transparent p-0">
{TAB_CONFIGS.map((tab) => (
<TabsTrigger
key={tab.id}
value={tab.id}
className={cn(
'h-auto flex-none rounded-none px-0 pb-3 pt-1 text-sm font-semibold',
'data-[state=active]:text-foreground data-[state=inactive]:text-muted-foreground',
)}
>
{tab.label}
</TabsTrigger>
))}
</TabsList>
</div>
<TabsContent value="overview" className="mt-6 outline-none">
{activeTab === 'overview' ? overview : null}
</TabsContent>
<TabsContent value="dashboard" className="mt-6 outline-none">
{activeTab === 'dashboard' ? dashboard : null}
</TabsContent>
<TabsContent value="manage" className="mt-6 outline-none">
{activeTab === 'manage' ? manage : null}
</TabsContent>
</Tabs>
</div>
);
}
@@ -0,0 +1,322 @@
'use client';
import {useEffect} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
import {Button} from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {Input} from '@/components/ui/input';
import {Label} from '@/components/ui/label';
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
import {Switch} from '@/components/ui/switch';
import type {NodeItem, NodeMutationPayload, NodeType} from '@/lib/services/openflare';
const nodeSchema = z
.object({
node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
name: z.string().trim().min(1, '请输入节点名称').max(255),
ip: z.string(),
ip_manual_override: z.boolean(),
auto_update_enabled: z.boolean(),
geo_manual_override: z.boolean(),
geo_name: z.string(),
geo_latitude: z.string(),
geo_longitude: z.string(),
relay_bind_port: z.string(),
relay_vhost_http_port: z.string(),
})
.superRefine((value, context) => {
if (value.ip_manual_override && !value.ip.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['ip'],
message: '锁定 IP 时必须填写节点 IP',
});
}
if (value.node_type === 'tunnel_relay') {
const bindPort = Number(value.relay_bind_port);
const vhostPort = Number(value.relay_vhost_http_port);
if (!Number.isFinite(bindPort) || bindPort <= 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_bind_port'],
message: '请输入有效的绑定端口',
});
}
if (!Number.isFinite(vhostPort) || vhostPort <= 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_vhost_http_port'],
message: '请输入有效的 VHost 端口',
});
}
}
});
type NodeFormValues = z.infer<typeof nodeSchema>;
const defaultForm: NodeFormValues = {
node_type: 'edge_node',
name: '',
ip: '',
ip_manual_override: false,
auto_update_enabled: false,
geo_manual_override: false,
geo_name: '',
geo_latitude: '',
geo_longitude: '',
relay_bind_port: '7000',
relay_vhost_http_port: '8080',
};
function buildFormValues(node?: NodeItem | null): NodeFormValues {
if (!node) return defaultForm;
return {
node_type: node.node_type,
name: node.name,
ip: node.ip,
ip_manual_override: node.ip_manual_override,
auto_update_enabled: node.auto_update_enabled,
geo_manual_override: node.geo_manual_override,
geo_name: node.geo_name,
geo_latitude:
node.geo_latitude === undefined || node.geo_latitude === null
? ''
: String(node.geo_latitude),
geo_longitude:
node.geo_longitude === undefined || node.geo_longitude === null
? ''
: String(node.geo_longitude),
relay_bind_port: String(node.relay_bind_port || 7000),
relay_vhost_http_port: String(node.relay_vhost_http_port || 8080),
};
}
function toPayload(form: NodeFormValues): NodeMutationPayload {
const base: NodeMutationPayload = {
node_type: form.node_type,
name: form.name.trim(),
ip: form.ip.trim(),
ip_manual_override: form.ip_manual_override,
auto_update_enabled: form.auto_update_enabled,
geo_manual_override: form.geo_manual_override,
geo_name: form.geo_manual_override ? form.geo_name.trim() : '',
geo_latitude: form.geo_manual_override && form.geo_latitude
? Number(form.geo_latitude)
: null,
geo_longitude: form.geo_manual_override && form.geo_longitude
? Number(form.geo_longitude)
: null,
};
if (form.node_type === 'tunnel_relay') {
return {
...base,
relay_bind_port: Number(form.relay_bind_port),
relay_vhost_http_port: Number(form.relay_vhost_http_port),
relay_web_server_enabled: true,
};
}
return base;
}
export function NodeEditorDialog({
open,
node,
submitting,
onClose,
onSubmit,
}: {
open: boolean;
node: NodeItem | null;
submitting: boolean;
onClose: () => void;
onSubmit: (payload: NodeMutationPayload) => Promise<void>;
}) {
const form = useForm<NodeFormValues>({
resolver: zodResolver(nodeSchema),
defaultValues: defaultForm,
});
useEffect(() => {
if (open) {
form.reset(buildFormValues(node));
}
}, [form, open, node]);
const nodeType = form.watch('node_type');
const ipManualOverride = form.watch('ip_manual_override');
const geoManualOverride = form.watch('geo_manual_override');
const handleSubmit = form.handleSubmit(async (values) => {
await onSubmit(toPayload(values));
});
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{node ? '编辑节点' : '新增节点'}</DialogTitle>
<DialogDescription>
预创建节点后可在详情页查看专属 Token 与部署命令。
</DialogDescription>
</DialogHeader>
<form onSubmit={(event) => void handleSubmit(event)} className="space-y-4">
<div className="space-y-2">
<Label>节点类型</Label>
<Select
value={nodeType}
disabled={Boolean(node)}
onValueChange={(value) =>
form.setValue('node_type', value as NodeType)
}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="edge_node">Edge 节点</SelectItem>
<SelectItem value="tunnel_relay">Relay 节点</SelectItem>
<SelectItem value="tunnel_client">Tunnel 节点</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="node-name">节点名称</Label>
<Input
id="node-name"
placeholder="例如 edge-hk-01"
{...form.register('name')}
/>
{form.formState.errors.name ? (
<p className="text-xs text-destructive">
{form.formState.errors.name.message}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="node-ip">节点 IP</Label>
<Input
id="node-ip"
placeholder="可选,接入后自动上报"
{...form.register('ip')}
/>
{form.formState.errors.ip ? (
<p className="text-xs text-destructive">
{form.formState.errors.ip.message}
</p>
) : null}
</div>
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">锁定 IP</p>
<p className="text-xs text-muted-foreground">启用后管理端不再自动覆盖 IP</p>
</div>
<Switch
checked={ipManualOverride}
onCheckedChange={(checked) =>
form.setValue('ip_manual_override', checked)
}
/>
</div>
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">自动更新 Agent</p>
<p className="text-xs text-muted-foreground">启用后节点将自动拉取正式版更新</p>
</div>
<Switch
checked={form.watch('auto_update_enabled')}
onCheckedChange={(checked) =>
form.setValue('auto_update_enabled', checked)
}
/>
</div>
{nodeType === 'tunnel_relay' ? (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="relay-bind-port">绑定端口</Label>
<Input
id="relay-bind-port"
{...form.register('relay_bind_port')}
/>
{form.formState.errors.relay_bind_port ? (
<p className="text-xs text-destructive">
{form.formState.errors.relay_bind_port.message}
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="relay-vhost-port">VHost 端口</Label>
<Input
id="relay-vhost-port"
{...form.register('relay_vhost_http_port')}
/>
{form.formState.errors.relay_vhost_http_port ? (
<p className="text-xs text-destructive">
{form.formState.errors.relay_vhost_http_port.message}
</p>
) : null}
</div>
</div>
) : null}
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">手动地图点位</p>
<p className="text-xs text-muted-foreground">用于总览地图展示</p>
</div>
<Switch
checked={geoManualOverride}
onCheckedChange={(checked) =>
form.setValue('geo_manual_override', checked)
}
/>
</div>
{geoManualOverride ? (
<div className="grid gap-3 sm:grid-cols-3">
<div className="space-y-2 sm:col-span-3">
<Label htmlFor="geo-name">位置名称</Label>
<Input id="geo-name" {...form.register('geo_name')} />
</div>
<div className="space-y-2">
<Label htmlFor="geo-lat">纬度</Label>
<Input id="geo-lat" {...form.register('geo_latitude')} />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="geo-lng">经度</Label>
<Input id="geo-lng" {...form.register('geo_longitude')} />
</div>
</div>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} disabled={submitting}>
取消
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? '保存中...' : node ? '保存修改' : '新增节点'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,802 @@
'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,
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 (
<div className="space-y-2 rounded-lg border px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs text-muted-foreground uppercase tracking-wide">{label}</p>
{hint ? <p className="mt-1 text-xs text-muted-foreground">{hint}</p> : null}
</div>
<p className="text-sm font-medium">{value}</p>
</div>
{progress !== null && progress !== undefined ? (
<div className="h-2 overflow-hidden rounded-full bg-muted">
<div
className="h-full rounded-full bg-primary transition-[width]"
style={{ width: `${progress}%` }}
/>
</div>
) : null}
</div>
);
}
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({
nodeId,
node,
variant = 'edge',
connectionHint = 'OpenResty 当前连接',
}: {
nodeId: number;
node?: NodeItem;
variant?: NodeObservabilityVariant;
connectionHint?: string;
}) {
const queryClient = useQueryClient();
const [healthEventFilter, setHealthEventFilter] = useState<HealthEventFilter>('all');
const [cleanupOpen, setCleanupOpen] = useState(false);
const observabilityQuery = useQuery({
queryKey: ['openflare', 'node-observability', nodeId],
queryFn: () => NodeService.getObservability(nodeId, { hours: 24, limit: 48 }),
refetchInterval: 10000,
});
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 (
<Card className="border-dashed shadow-none">
<CardContent className="flex items-center justify-center py-10 text-sm text-muted-foreground">
<Loader2 className="size-4 mr-2 animate-spin" />
加载运行观测数据中...
</CardContent>
</Card>
);
}
if (observabilityQuery.isError) {
return (
<Card className="border-dashed shadow-none">
<CardContent className="py-6">
<p className="text-sm text-destructive">{getErrorMessage(observabilityQuery.error)}</p>
</CardContent>
</Card>
);
}
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 (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<SummaryStat
label="运行诊断"
value={
activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常`
: '运行稳定'
}
hint={
latestHealthEvent
? `${getHealthEventLabel(latestHealthEvent)} · ${latestHealthEvent.message || '等待处理'}`
: '当前没有活动中的健康事件'
}
/>
<SummaryStat
label="当前窗口请求"
value={trafficSummary ? trafficSummary.request_count.toLocaleString('zh-CN') : '—'}
hint={
trafficSummary
? `QPS ${trafficSummary.estimated_qps.toFixed(1)} · 错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
: '当前没有可展示的请求窗口摘要'
}
/>
<SummaryStat
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 className="grid gap-6 xl:grid-cols-3">
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-base font-semibold">系统信息</CardTitle>
</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>
</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>
<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>
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<TrafficTrendChart
points={trends?.traffic_24h ?? []}
description="按小时聚合该节点的请求量和错误量。"
/>
<CapacityTrendChart
points={trends?.capacity_24h ?? []}
description="观察该节点 CPU 与内存使用率在 24 小时内的变化。"
/>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<NetworkTrendChart points={trends?.network_24h ?? []} />
<DiskIOTrendChart points={trends?.disk_io_24h ?? []} />
</div>
<div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<Card className="border-dashed shadow-none">
<CardHeader>
<CardTitle className="text-base font-semibold">请求结构分布</CardTitle>
<CardDescription>
聚合最近 24 小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。
</CardDescription>
</CardHeader>
<CardContent>
<div className="mb-6 grid gap-4 md:grid-cols-3">
<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">{dominantStatusCode?.label ?? '—'}</p>
<p className="mt-2 text-sm text-muted-foreground">
{dominantStatusCode ? `${dominantStatusCode.value} 次` : '暂无状态码分布'}
</p>
</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>
<div className="grid gap-6 xl:grid-cols-2">
<div>
<p className="mb-4 text-xs text-muted-foreground uppercase tracking-wide">
状态码分布
</p>
<DistributionList
items={statusCodeDistribution}
emptyMessage="暂无状态码分布"
/>
</div>
<div>
<p className="mb-4 text-xs text-muted-foreground uppercase tracking-wide">
Top Domain
</p>
<DistributionList items={topDomains} emptyMessage="暂无域名分布" />
</div>
</div>
</CardContent>
</Card>
<HealthEventTimeline
events={filteredHealthEvents}
allEventsCount={observability?.health_events.length ?? 0}
healthEventFilter={healthEventFilter}
onFilterChange={setHealthEventFilter}
onCleanup={() => setCleanupOpen(true)}
cleanupPending={cleanupMutation.isPending}
/>
</div>
<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>
);
}
@@ -0,0 +1,30 @@
import {Badge} from '@/components/ui/badge';
import {cn} from '@/lib/utils';
import type {StatusTone} from './node-utils';
const toneClassName: Record<StatusTone, string> = {
success: 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600',
warning: 'bg-amber-500/10 border-amber-500/20 text-amber-600',
danger: 'bg-destructive/10 border-destructive/20 text-destructive',
info: 'bg-blue-500/10 border-blue-500/20 text-blue-600',
};
export function NodeStatusBadge({
label,
tone,
className,
}: {
label: string;
tone: StatusTone;
className?: string;
}) {
return (
<Badge
variant="outline"
className={cn('text-[10px] rounded-full py-0 px-2 font-medium', toneClassName[tone], className)}
>
{label}
</Badge>
);
}
@@ -0,0 +1,78 @@
'use client';
import Link from 'next/link';
import {useSearchParams} from 'next/navigation';
import {cn} from '@/lib/utils';
export type NodeFilter = 'all' | 'edge' | 'relay' | 'tunnel';
const filters: Array<{ key: NodeFilter; label: string; href: string }> = [
{ key: 'all', label: '全部节点', href: '/nodes' },
{ key: 'edge', label: 'Edge', href: '/nodes?filter=edge' },
{ key: 'relay', label: 'Relay', href: '/nodes?filter=relay' },
{ key: 'tunnel', label: 'Tunnel', href: '/nodes?filter=tunnel' },
];
export function getNodeFilter(searchParams: URLSearchParams): NodeFilter {
const current = searchParams.get('filter')?.trim().toLowerCase() ?? '';
if (current === 'relay' || current === 'tunnel' || current === 'edge' || current === 'all') {
return current;
}
return 'all';
}
export function filterNodesByType<T extends { node_type: string }>(
nodes: T[],
filter: NodeFilter,
): T[] {
switch (filter) {
case 'relay':
return nodes.filter((node) => node.node_type === 'tunnel_relay');
case 'tunnel':
return nodes.filter((node) => node.node_type === 'tunnel_client');
case 'edge':
return nodes.filter((node) => node.node_type === 'edge_node');
case 'all':
default:
return nodes;
}
}
export function getFilterDescription(filter: NodeFilter) {
switch (filter) {
case 'relay':
return '当前仅展示 Relay 节点。';
case 'tunnel':
return '当前仅展示 Tunnel 节点。';
case 'edge':
return '当前仅展示 Edge 节点。';
case 'all':
default:
return '当前展示全部节点。';
}
}
export function NodeTypeFilter() {
const searchParams = useSearchParams();
const activeFilter = getNodeFilter(searchParams);
return (
<div className="flex flex-wrap gap-2">
{filters.map((item) => (
<Link
key={item.key}
href={item.href}
className={cn(
'inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition',
activeFilter === item.key
? 'border-foreground/30 bg-accent text-foreground'
: 'border-border text-muted-foreground hover:bg-muted/50',
)}
>
{item.label}
</Link>
))}
</div>
);
}
@@ -0,0 +1,296 @@
import type {
ApplyResult,
NodeItem,
NodeStatus,
NodeTrafficReport,
OpenrestyStatus,
} from '@/lib/services/openflare';
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
export const FLARED_WS_CONNECTED_LAST_SEEN = '__OPENFLARE_FLARED_WS_CONNECTED__';
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
export function isWSConnectedLastSeen(value: string | null | undefined) {
return value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN;
}
export function isMeaningfulTime(value: string | null | undefined): value is string {
return (
Boolean(value) &&
!isWSConnectedLastSeen(value) &&
!String(value).startsWith('0001-01-01')
);
}
export function formatRelativeTime(value: string) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60_000);
if (diffMinutes < 1) return '刚刚';
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours} 小时前`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays} 天前`;
return `${Math.floor(diffDays / 30)} 个月前`;
}
export function getNodeStatusTone(status: NodeStatus): StatusTone {
if (status === 'online') return 'success';
if (status === 'pending') return 'warning';
return 'danger';
}
export function getNodeStatusLabel(status: NodeStatus) {
if (status === 'online') return '在线';
if (status === 'pending') return '待接入';
return '离线';
}
export function getApplyTone(result: ApplyResult): StatusTone {
if (result === 'success') return 'success';
if (result === 'warning') return 'warning';
if (result === 'failed') return 'danger';
return 'warning';
}
export function getApplyLabel(result: ApplyResult) {
if (result === 'success') return '成功';
if (result === 'warning') return '警告';
if (result === 'failed') return '失败';
return '暂无';
}
export function getOpenrestyStatusTone(status: OpenrestyStatus): StatusTone {
if (status === 'healthy') return 'success';
if (status === 'unhealthy') return 'danger';
return 'warning';
}
export function getOpenrestyStatusLabel(status: OpenrestyStatus) {
if (status === 'healthy') return '健康';
if (status === 'unhealthy') return '异常';
return '未知';
}
export function getRelayStatusTone(status: string | null | undefined): StatusTone {
if (status === 'healthy') return 'success';
if (status === 'unhealthy') return 'danger';
return 'warning';
}
export function getRelayStatusLabel(status: string | null | undefined) {
if (status === 'healthy') return '健康';
if (status === 'unhealthy') return '异常';
return '未知';
}
export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
if (nodeType === 'tunnel_relay') return 'Relay';
if (nodeType === 'tunnel_client') return 'Tunnel';
return 'Edge';
}
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
}
export function getServerUrl(value: string) {
return value.trim().replace(/\/+$/, '');
}
const relayInstallerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-relay.sh';
const flaredInstallerScriptUrl =
'https://raw.githubusercontent.com/Rain-kl/OpenFlare/main/scripts/install-flared.sh';
export function buildRelayInstallCommand(serverUrl: string, discoveryToken: string) {
return [
`curl -fsSL ${relayInstallerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --discovery-token ${discoveryToken}`,
].join('\n');
}
export function buildRelayDockerInstallCommand(serverUrl: string, discoveryToken: string) {
const image = 'ghcr.io/rain-kl/openflare-relay:latest';
return [
`docker pull ${image}`,
`docker rm -f openflare-relay 2>/dev/null || true`,
`docker run -d --name openflare-relay --net host --restart unless-stopped \\`,
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
` -e OPENFLARE_DISCOVERY_TOKEN=${discoveryToken} \\`,
` ${image}`,
].join('\n');
}
export function buildTunnelInstallCommand(serverUrl: string, tunnelToken: string) {
return [
`curl -fsSL ${flaredInstallerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
` --tunnel-token ${tunnelToken}`,
].join('\n');
}
export function buildTunnelDockerInstallCommand(serverUrl: string, tunnelToken: string) {
const image = 'ghcr.io/rain-kl/openflared:latest';
return [
`docker pull ${image}`,
`docker rm -f openflared 2>/dev/null || true`,
`docker run -d --name openflared --restart unless-stopped \\`,
` -e OPENFLARE_SERVER_URL=${serverUrl} \\`,
` -e OPENFLARE_TUNNEL_TOKEN=${tunnelToken} \\`,
` ${image}`,
].join('\n');
}
export function formatBytes(bytes?: number | null, decimals = 1) {
if (bytes === undefined || bytes === null || !Number.isFinite(bytes)) {
return '—';
}
if (bytes <= 0) {
return '0 B';
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const value = bytes / 1024 ** 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) {
if (!used || !total || total <= 0) {
return null;
}
return Math.max(0, Math.min(100, (used / total) * 100));
}
export function formatUptime(seconds?: number | null) {
if (!seconds || seconds <= 0) {
return '—';
}
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days} 天 ${hours} 小时`;
}
if (hours > 0) {
return `${hours} 小时 ${minutes} 分钟`;
}
return `${minutes} 分钟`;
}
export function getHealthEventTone(
event: { status: string; severity: string },
): StatusTone {
if (event.status === 'resolved') {
return 'success';
}
if (event.severity === 'critical') {
return 'danger';
}
if (event.severity === 'warning') {
return 'warning';
}
return 'info';
}
export function getHealthEventLabel(event: { event_type: string }) {
return event.event_type.replaceAll('_', ' ');
}
export function getFlaredStatusLabel(node: NodeItem) {
if (isWSConnectedLastSeen(node.last_seen_at)) {
return 'WS 已连接';
}
if (node.status === 'online') {
return '运行中';
}
if (node.status === 'pending') {
return '待接入';
}
return '离线';
}
export function getFlaredStatusTone(node: NodeItem): StatusTone {
if (isWSConnectedLastSeen(node.last_seen_at) || node.status === 'online') {
return 'success';
}
if (node.status === 'pending') {
return 'warning';
}
return 'danger';
}
@@ -0,0 +1,151 @@
'use client';
import Link from 'next/link';
import {Pencil, Trash2} from 'lucide-react';
import {Button} from '@/components/ui/button';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import {formatDateTime} from '@/lib/utils';
import type {NodeItem} from '@/lib/services/openflare';
import {NodeStatusBadge} from './node-status-badge';
import {
formatRelativeTime,
getApplyLabel,
getApplyTone,
getNodeStatusLabel,
getNodeStatusTone,
getNodeTypeLabel,
getOpenrestyStatusLabel,
getOpenrestyStatusTone,
getRelayStatusLabel,
getRelayStatusTone,
isMeaningfulTime,
isWSConnectedLastSeen,
} from './node-utils';
export function NodesTable({
nodes,
deletingId,
onEdit,
onDelete,
}: {
nodes: NodeItem[];
deletingId: number | null;
onEdit: (node: NodeItem) => void;
onDelete: (node: NodeItem) => void;
}) {
return (
<div className="border border-dashed rounded-lg overflow-hidden">
<Table>
<TableHeader>
<TableRow className="border-dashed hover:bg-transparent">
<TableHead className="py-2 h-8">节点</TableHead>
<TableHead className="py-2 h-8">状态</TableHead>
<TableHead className="py-2 h-8">Version</TableHead>
<TableHead className="py-2 h-8">运行健康</TableHead>
<TableHead className="py-2 h-8">当前版本</TableHead>
<TableHead className="py-2 h-8">最近应用</TableHead>
<TableHead className="py-2 h-8">最近心跳</TableHead>
<TableHead className="py-2 h-8 text-right">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{nodes.map((node) => (
<TableRow key={node.id} className="border-dashed align-top">
<TableCell className="py-3">
<div className="space-y-1">
<div className="flex items-center gap-2">
<p className="font-medium">{node.name}</p>
<NodeStatusBadge label={getNodeTypeLabel(node.node_type)} tone="info" />
</div>
<p className="text-xs text-muted-foreground">
IP:{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
</p>
<p className="text-xs text-muted-foreground">
位置:{node.geo_name || '未配置地图点位'}
</p>
</div>
</TableCell>
<TableCell className="py-3">
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
/>
</TableCell>
<TableCell className="py-3 text-muted-foreground">
{node.version || 'unknown'}
</TableCell>
<TableCell className="py-3">
{node.node_type === 'tunnel_relay' ? (
<NodeStatusBadge
label={getRelayStatusLabel(node.relay_status)}
tone={getRelayStatusTone(node.relay_status)}
/>
) : node.node_type === 'tunnel_client' ? (
<NodeStatusBadge
label={node.status === 'online' ? '运行中' : '未知'}
tone={node.status === 'online' ? 'success' : 'warning'}
/>
) : (
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
)}
</TableCell>
<TableCell className="py-3 text-muted-foreground">
{node.current_version ||
(node.node_type === 'tunnel_relay' ? '实时配置' : '未应用')}
</TableCell>
<TableCell className="py-3">
{node.node_type === 'tunnel_relay' ? (
<span className="text-sm text-muted-foreground">—</span>
) : (
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
tone={getApplyTone(node.latest_apply_result)}
/>
)}
</TableCell>
<TableCell className="py-3 text-muted-foreground">
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
</TableCell>
<TableCell className="py-3">
<div className="flex flex-wrap justify-end gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href={`/nodes/detail?id=${node.id}`}>详情</Link>
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => onEdit(node)}
>
<Pencil className="size-3 mr-1" />
编辑
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
disabled={deletingId === node.id}
onClick={() => onDelete(node)}
>
<Trash2 className="size-3 mr-1" />
删除
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
);
}
@@ -0,0 +1,392 @@
'use client';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useState} from 'react';
import {
Activity,
ExternalLink,
FileText,
Fingerprint,
Network,
RefreshCw,
RotateCcw,
Server,
Trash2,
Upload,
} 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 {Label} from '@/components/ui/label';
import {Switch} from '@/components/ui/switch';
import {formatDateTime} from '@/lib/utils';
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare';
import {NodeService} from '@/lib/services/openflare';
import {AgentUpdateDialog} from './agent-update-dialog';
import {InstallCommand} from './install-command';
import {NodeDetailShell} from './node-detail-shell';
import {
NodeErrorBanner,
NodeInfoRow,
NodeSectionCard,
} from './node-detail-primitives';
import {NodeEditorDialog} from './node-editor-dialog';
import {NodeObservability} from './node-observability';
import {NodeStatusBadge} from './node-status-badge';
import {
formatRelativeTime,
getApplyLabel,
getApplyTone,
getErrorMessage,
getNodeStatusLabel,
getNodeStatusTone,
getRelayStatusLabel,
getRelayStatusTone,
isMeaningfulTime,
isWSConnectedLastSeen,
} from './node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function RelayNodeDetail({ node }: { node: NodeItem }) {
const router = useRouter();
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [upgradeOpen, setUpgradeOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const saveMutation = useMutation({
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload),
onSuccess: async () => {
toast.success('中继节点已更新');
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const webServerMutation = useMutation({
mutationFn: (enabled: boolean) =>
NodeService.updateNode(node.id, {
node_type: node.node_type,
name: node.name,
ip: node.ip,
ip_manual_override: node.ip_manual_override,
auto_update_enabled: node.auto_update_enabled,
geo_name: node.geo_name,
geo_latitude: node.geo_latitude ?? null,
geo_longitude: node.geo_longitude ?? null,
geo_manual_override: node.geo_manual_override,
relay_bind_port: node.relay_bind_port,
relay_vhost_http_port: node.relay_vhost_http_port,
relay_web_server_enabled: enabled,
}),
onSuccess: async () => {
toast.success('FRPS WebUI 设置已更新');
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => {
toast.success(`已向中继节点 ${updated.name} 下发强制同步指令`);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const upgradeMutation = useMutation({
mutationFn: ({
release,
channel,
}: {
release: NodeAgentReleaseInfo | null;
channel: ReleaseChannel;
}) =>
NodeService.requestAgentUpdate(node.id, {
channel: release?.channel ?? channel,
tag_name: release?.channel === 'preview' ? release.tag_name || undefined : undefined,
}),
onSuccess: async (updated) => {
toast.success(
`已向中继节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`,
);
setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => {
toast.success('中继节点已删除');
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes');
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const handleRefresh = () => {
void Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'node-observability', node.id] }),
]);
};
const webUiUrl =
node.relay_web_server_enabled && node.relay_bind_port
? `http://${node.ip || '127.0.0.1'}:${node.relay_bind_port + 500}`
: null;
const headerActions = (
<>
<Button variant="outline" size="sm" className="h-8" onClick={() => setEditorOpen(true)}>
编辑
</Button>
<Button variant="outline" size="sm" className="h-8" onClick={handleRefresh}>
<RefreshCw className="size-3.5 mr-1.5" />
刷新
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
disabled={forceSyncMutation.isPending}
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className="size-3.5 mr-1.5" />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
</Button>
<Button variant="secondary" size="sm" className="h-8" onClick={() => setUpgradeOpen(true)}>
<Upload className="size-3.5 mr-1.5" />
{node.update_requested ? '查看升级' : '升级 Relay'}
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-3.5 mr-1.5" />
删除
</Button>
</>
);
const overviewTab = (
<div className="space-y-6">
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
<div className="grid gap-6 xl:grid-cols-2">
<NodeSectionCard title="中继运行状态" description="frps 在线情况与负载摘要">
<div className="divide-y">
<NodeInfoRow label="运行状态">
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
/>
</NodeInfoRow>
<NodeInfoRow label="中继健康">
<NodeStatusBadge
label={getRelayStatusLabel(node.relay_status)}
tone={getRelayStatusTone(node.relay_status)}
/>
</NodeInfoRow>
<NodeInfoRow label="最近心跳">
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
</NodeInfoRow>
<NodeInfoRow label="活动连接 / 代理数">
{node.relay_frps_connections ?? '—'} / {node.relay_frps_proxy_count ?? '—'}
</NodeInfoRow>
<NodeInfoRow label="IP 地址">
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard
title="网络端口"
description="frps 控制面与 HTTP 虚拟主机配置"
action={
<Button variant="outline" size="sm" className="h-8" asChild>
<Link href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}>
<FileText className="size-3.5 mr-1.5" />
应用记录
</Link>
</Button>
}
>
<div className="divide-y">
<NodeInfoRow label="绑定控制端口">{node.relay_bind_port || '—'}</NodeInfoRow>
<NodeInfoRow label="VHost HTTP 端口">{node.relay_vhost_http_port || '—'}</NodeInfoRow>
<NodeInfoRow label="Agent 接入地址">
<span className="break-all">{node.relay_agent_access_addr || '—'}</span>
</NodeInfoRow>
<NodeInfoRow label="最近应用">
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
tone={getApplyTone(node.latest_apply_result)}
/>
</NodeInfoRow>
<NodeInfoRow label="Relay 版本">{node.version || 'unknown'}</NodeInfoRow>
<NodeInfoRow label="frps 核心版本">{node.ext_version || 'unknown'}</NodeInfoRow>
</div>
</NodeSectionCard>
</div>
</div>
);
const manageTab = (
<div className="space-y-6">
<NodeSectionCard title="FRPS WebUI" description="控制 frps 内置 Web 管理界面是否启用">
<div className="space-y-4">
<div className="flex items-center justify-between rounded-xl border px-4 py-4">
<div className="space-y-1">
<Label>启用 Web 管理界面</Label>
<p className="text-xs text-muted-foreground">
默认监听绑定端口 + 500,例如 {node.relay_bind_port || 7000} →{' '}
{(node.relay_bind_port || 7000) + 500}
</p>
</div>
<Switch
checked={node.relay_web_server_enabled}
disabled={webServerMutation.isPending}
onCheckedChange={(checked) => webServerMutation.mutate(checked)}
/>
</div>
{webUiUrl ? (
<a
href={webUiUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center text-sm font-medium text-primary hover:underline"
>
打开 FRPS WebUI
<ExternalLink className="size-3.5 ml-1.5" />
</a>
) : (
<p className="text-sm text-muted-foreground">WebUI 已禁用或未配置绑定端口。</p>
)}
</div>
</NodeSectionCard>
<InstallCommand node={node} variant="relay" />
<NodeSectionCard title="节点元数据">
<div className="divide-y">
<NodeInfoRow label="节点 ID">
<span className="font-mono text-xs break-all">{node.node_id}</span>
</NodeInfoRow>
<NodeInfoRow label="创建时间">{formatDateTime(node.created_at)}</NodeInfoRow>
<NodeInfoRow label="更新时间">{formatDateTime(node.updated_at)}</NodeInfoRow>
</div>
</NodeSectionCard>
</div>
);
return (
<>
<NodeDetailShell
title={node.name}
typeLabel="Relay"
typeTone="warning"
statusBadges={[
{ label: getNodeStatusLabel(node.status), tone: getNodeStatusTone(node.status) },
{
label: getRelayStatusLabel(node.relay_status),
tone: getRelayStatusTone(node.relay_status),
},
]}
actions={headerActions}
kpis={[
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
{ label: 'Relay 版本', value: node.version || 'unknown', icon: Server },
{ label: 'frps 核心', value: node.ext_version || 'unknown', icon: Network },
{
label: '最近心跳',
value: isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at)
: '暂无',
icon: Activity,
},
]}
overview={overviewTab}
dashboard={
<NodeObservability
nodeId={node.id}
variant="compact"
connectionHint="中继承载活动连接数"
/>
}
manage={manageTab}
defaultTab="overview"
/>
<NodeEditorDialog
open={editorOpen}
node={node}
submitting={saveMutation.isPending}
onClose={() => setEditorOpen(false)}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
}}
/>
<AgentUpdateDialog
open={upgradeOpen}
node={node}
submitting={upgradeMutation.isPending}
onClose={() => setUpgradeOpen(false)}
onConfirm={async (release, channel) => {
await upgradeMutation.mutateAsync({ release, channel });
}}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除中继节点</AlertDialogTitle>
<AlertDialogDescription>
确认删除中继节点「{node.name}」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}
@@ -0,0 +1,340 @@
'use client';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useState} from 'react';
import {
Activity,
FileText,
Fingerprint,
KeyRound,
Package,
RefreshCw,
RotateCcw,
Trash2,
Upload,
} 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 {formatDateTime} from '@/lib/utils';
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare';
import {NodeService} from '@/lib/services/openflare';
import {AgentUpdateDialog} from './agent-update-dialog';
import {InstallCommand} from './install-command';
import {NodeDetailShell} from './node-detail-shell';
import {
NodeErrorBanner,
NodeInfoRow,
NodeSectionCard,
} from './node-detail-primitives';
import {NodeEditorDialog} from './node-editor-dialog';
import {NodeObservability} from './node-observability';
import {NodeStatusBadge} from './node-status-badge';
import {
formatRelativeTime,
getApplyLabel,
getApplyTone,
getErrorMessage,
getFlaredStatusLabel,
getFlaredStatusTone,
getNodeStatusLabel,
getNodeStatusTone,
isMeaningfulTime,
isWSConnectedLastSeen,
} from './node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
export function TunnelNodeDetail({ node }: { node: NodeItem }) {
const router = useRouter();
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
const [upgradeOpen, setUpgradeOpen] = useState(false);
const [deleteOpen, setDeleteOpen] = useState(false);
const saveMutation = useMutation({
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload),
onSuccess: async () => {
toast.success('隧道节点已更新');
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => {
toast.success(`已向隧道节点 ${updated.name} 下发强制同步指令`);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const upgradeMutation = useMutation({
mutationFn: ({
release,
channel,
}: {
release: NodeAgentReleaseInfo | null;
channel: ReleaseChannel;
}) =>
NodeService.requestAgentUpdate(node.id, {
channel: release?.channel ?? channel,
tag_name: release?.channel === 'preview' ? release.tag_name || undefined : undefined,
}),
onSuccess: async (updated) => {
toast.success(
`已向隧道节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`,
);
setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => {
toast.success('隧道节点已删除');
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes');
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const handleRefresh = () => {
void Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'node-observability', node.id] }),
]);
};
const headerActions = (
<>
<Button variant="outline" size="sm" className="h-8" onClick={() => setEditorOpen(true)}>
编辑
</Button>
<Button variant="outline" size="sm" className="h-8" onClick={handleRefresh}>
<RefreshCw className="size-3.5 mr-1.5" />
刷新
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
disabled={forceSyncMutation.isPending}
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className="size-3.5 mr-1.5" />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
</Button>
<Button variant="secondary" size="sm" className="h-8" onClick={() => setUpgradeOpen(true)}>
<Upload className="size-3.5 mr-1.5" />
{node.update_requested ? '查看升级' : '升级 openflared'}
</Button>
<Button
variant="outline"
size="sm"
className="h-8 text-destructive hover:text-destructive"
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-3.5 mr-1.5" />
删除
</Button>
</>
);
const overviewTab = (
<div className="space-y-6">
{node.last_error ? <NodeErrorBanner message={node.last_error} /> : null}
<div className="grid gap-6 xl:grid-cols-2">
<NodeSectionCard title="隧道运行状态" description="openflared 连接与在线情况">
<div className="divide-y">
<NodeInfoRow label="运行状态">
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
/>
</NodeInfoRow>
<NodeInfoRow label="flared 状态">
<NodeStatusBadge
label={getFlaredStatusLabel(node)}
tone={getFlaredStatusTone(node)}
/>
</NodeInfoRow>
<NodeInfoRow label="最近心跳">
{isWSConnectedLastSeen(node.last_seen_at)
? 'WebSocket 长连接已建立'
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
</NodeInfoRow>
<NodeInfoRow label="IP 地址">
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
</NodeInfoRow>
<NodeInfoRow label="自动更新">{node.auto_update_enabled ? '已启用' : '手动'}</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard
title="配置同步"
description="隧道客户端配置追平状态"
action={
<Button variant="outline" size="sm" className="h-8" asChild>
<Link href={`/apply-logs?node_id=${encodeURIComponent(node.node_id)}`}>
<FileText className="size-3.5 mr-1.5" />
应用记录
</Link>
</Button>
}
>
<div className="divide-y">
<NodeInfoRow label="当前配置版本">{node.current_version || '未应用'}</NodeInfoRow>
<NodeInfoRow label="最近应用">
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
tone={getApplyTone(node.latest_apply_result)}
/>
</NodeInfoRow>
<NodeInfoRow label="最近应用时间">
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无'}
</NodeInfoRow>
{node.latest_apply_checksum ? (
<NodeInfoRow label="同步文件数">{node.latest_support_file_count} 个</NodeInfoRow>
) : null}
<NodeInfoRow label="升级状态">
<NodeStatusBadge
label={
node.update_requested
? node.update_channel === 'preview'
? '等待预览更新'
: '等待更新'
: '未下发'
}
tone={node.update_requested ? 'warning' : 'info'}
/>
</NodeInfoRow>
</div>
</NodeSectionCard>
</div>
</div>
);
const manageTab = (
<div className="space-y-6">
<NodeSectionCard title="接入凭证" description="隧道客户端接入所需 Token">
<div className="divide-y">
<NodeInfoRow label="Tunnel Token">
<span className="font-mono text-xs break-all">{node.access_token || '暂无'}</span>
</NodeInfoRow>
<NodeInfoRow label="节点 ID">
<span className="font-mono text-xs break-all">{node.node_id}</span>
</NodeInfoRow>
<NodeInfoRow label="创建时间">{formatDateTime(node.created_at)}</NodeInfoRow>
<NodeInfoRow label="更新时间">{formatDateTime(node.updated_at)}</NodeInfoRow>
</div>
</NodeSectionCard>
<InstallCommand node={node} variant="tunnel" />
</div>
);
return (
<>
<NodeDetailShell
title={node.name}
typeLabel="Tunnel"
typeTone="info"
statusBadges={[
{ label: getNodeStatusLabel(node.status), tone: getNodeStatusTone(node.status) },
{ label: getFlaredStatusLabel(node), tone: getFlaredStatusTone(node) },
]}
actions={headerActions}
kpis={[
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
{ label: 'openflared', value: node.version || 'unknown', icon: Package },
{ label: '当前配置', value: node.current_version || '未应用', icon: KeyRound },
{
label: '最近心跳',
value: isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at)
: '暂无',
icon: Activity,
},
]}
overview={overviewTab}
dashboard={
<NodeObservability
nodeId={node.id}
variant="compact"
connectionHint="隧道并发连接数"
/>
}
manage={manageTab}
defaultTab="overview"
/>
<NodeEditorDialog
open={editorOpen}
node={node}
submitting={saveMutation.isPending}
onClose={() => setEditorOpen(false)}
onSubmit={async (payload) => {
await saveMutation.mutateAsync(payload);
}}
/>
<AgentUpdateDialog
open={upgradeOpen}
node={node}
submitting={upgradeMutation.isPending}
onClose={() => setUpgradeOpen(false)}
onConfirm={async (release, channel) => {
await upgradeMutation.mutateAsync({ release, channel });
}}
/>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除隧道节点</AlertDialogTitle>
<AlertDialogDescription>
确认删除隧道节点「{node.name}」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}