This commit is contained in:
ryan
2026-07-13 15:08:40 +08:00
parent 08e8eea932
commit 1eff7878a1
441 changed files with 26491 additions and 18405 deletions
@@ -14,7 +14,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import {formatDateTime} from '@/lib/utils';
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel} from '@/lib/services/openflare';
import type {NodeAgentReleaseInfo, NodeItem, ReleaseChannel,} from '@/lib/services/openflare';
import {NodeService} from '@/lib/services/openflare';
import {NodeStatusBadge} from './node-status-badge';
@@ -31,7 +31,10 @@ export function AgentUpdateDialog({
node: NodeItem;
submitting: boolean;
onClose: () => void;
onConfirm: (release: NodeAgentReleaseInfo | null, channel: ReleaseChannel) => Promise<void>;
onConfirm: (
release: NodeAgentReleaseInfo | null,
channel: ReleaseChannel,
) => Promise<void>;
}) {
const [channel, setChannel] = useState<ReleaseChannel>('stable');
@@ -45,7 +48,7 @@ export function AgentUpdateDialog({
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-xl">
<DialogContent className='sm:max-w-xl'>
<DialogHeader>
<DialogTitle>Agent 升级</DialogTitle>
<DialogDescription>
@@ -53,21 +56,23 @@ export function AgentUpdateDialog({
</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 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">
<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">
<div className='rounded-lg border p-3'>
<p className='text-xs text-muted-foreground'>更新状态</p>
<div className='mt-1'>
<NodeStatusBadge
label={
node.update_requested
@@ -83,76 +88,82 @@ export function AgentUpdateDialog({
</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 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>
<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">
<div className='space-y-3 rounded-lg border p-4'>
<div className='flex flex-wrap items-center gap-2'>
<NodeStatusBadge
label={release.has_update ? '发现可升级版本' : '当前已是最新版本'}
label={
release.has_update ? '发现可升级版本' : '当前已是最新版本'
}
tone={release.has_update ? 'warning' : 'success'}
/>
{release.prerelease ? (
<NodeStatusBadge label="Preview 发布" tone="warning" />
<NodeStatusBadge label='Preview 发布' tone='warning' />
) : (
<NodeStatusBadge label="正式发布" tone="info" />
<NodeStatusBadge label='正式发布' tone='info' />
)}
</div>
<div className="grid gap-3 sm:grid-cols-2 text-sm">
<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>
<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">
<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">
<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"
target='_blank'
rel='noreferrer'
className='inline-flex items-center text-sm text-primary hover:underline'
>
查看发布详情
<ExternalLink className="size-3 ml-1" />
<ExternalLink className='size-3 ml-1' />
</a>
) : null}
</div>
) : null}
</div>
<DialogFooter className="gap-2 sm:gap-0">
<DialogFooter className='gap-2 sm:gap-0'>
<Button
type="button"
variant="outline"
type='button'
variant='outline'
disabled={submitting || releaseQuery.isFetching}
onClick={() => setChannel('stable')}
>
检查正式版
</Button>
<Button
type="button"
variant="outline"
type='button'
variant='outline'
disabled={submitting || releaseQuery.isFetching}
onClick={() => setChannel('preview')}
>
检查预览版
</Button>
<Button
type="button"
type='button'
disabled={
submitting ||
releaseQuery.isFetching ||
@@ -161,7 +172,11 @@ export function AgentUpdateDialog({
}
onClick={() => void onConfirm(release, channel)}
>
{submitting ? '下发中...' : channel === 'preview' ? '升级到预览版' : '升级到正式版'}
{submitting
? '下发中...'
: channel === 'preview'
? '升级到预览版'
: '升级到正式版'}
</Button>
</DialogFooter>
</DialogContent>
@@ -4,7 +4,7 @@ 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';
import {formatBytes, formatTrendHour,} from '../../components/dashboard/dashboard-utils';
export function DiskIOTrendChart({
points,
@@ -16,16 +16,18 @@ export function DiskIOTrendChart({
description?: string;
}) {
return (
<Card className="border-dashed shadow-none">
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
<CardDescription className="text-xs">{description}</CardDescription>
<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))}
summaryScope="total"
summaryHint="近 24 小时"
labels={points.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
summaryScope='total'
summaryHint='近 24 小时'
yAxisValueFormatter={formatBytes}
series={[
{
@@ -18,28 +18,30 @@ export function DistributionList({
if (sortedItems.length === 0) {
return (
<div className="flex min-h-[180px] items-center justify-center text-xs text-muted-foreground">
<div className='flex min-h-[180px] items-center justify-center text-xs text-muted-foreground'>
{emptyMessage}
</div>
);
}
return (
<div className="space-y-3">
<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">
<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" />
<Progress value={ratio} className='h-1.5' />
</div>
);
})}
</div>
);
}
}
@@ -4,17 +4,7 @@ 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 {Activity, Cpu, FileText, Fingerprint, Package, RefreshCw, RotateCcw, Trash2, Upload,} from 'lucide-react';
import {toast} from 'sonner';
import {
@@ -29,16 +19,12 @@ import {
} 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 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 {NodeErrorBanner, NodeInfoRow, NodeSectionCard,} from './node-detail-primitives';
import {NodeEditorDialog} from './node-editor-dialog';
import {NodeObservability} from './node-observability';
import {NodeStatusBadge} from './node-status-badge';
@@ -102,7 +88,10 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
}) =>
NodeService.requestAgentUpdate(node.id, {
channel: release?.channel ?? channel,
tag_name: release?.channel === 'preview' ? release.tag_name || undefined : undefined,
tag_name:
release?.channel === 'preview'
? release.tag_name || undefined
: undefined,
}),
onSuccess: async (updated) => {
toast.success(
@@ -127,114 +116,146 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
const handleRefresh = () => {
void Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'node-observability', node.id] }),
queryClient.invalidateQueries({
queryKey: ['openflare', 'node-observability', node.id],
}),
]);
};
const headerActions = (
<>
<Button variant="outline" size="sm" className="h-8" onClick={() => setEditorOpen(true)}>
<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
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"
variant='outline'
size='sm'
className='h-8'
disabled={forceSyncMutation.isPending}
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className="size-3.5 mr-1.5" />
<RotateCcw className='size-3.5 mr-1.5' />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
</Button>
<Button
variant="outline"
size="sm"
className="h-8"
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" />
<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"
variant='outline'
size='sm'
className='h-8 text-destructive hover:text-destructive'
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-3.5 mr-1.5" />
<Trash2 className='size-3.5 mr-1.5' />
删除
</Button>
</>
);
const overviewTab = (
<div className="space-y-6">
<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="运行状态">
<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 健康">
<NodeInfoRow label='OpenResty 健康'>
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
</NodeInfoRow>
<NodeInfoRow label="最近心跳">
<NodeInfoRow label='最近心跳'>
{isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
</NodeInfoRow>
<NodeInfoRow label="IP 地址">
<NodeInfoRow label='IP 地址'>
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
</NodeInfoRow>
<NodeInfoRow label="地图点位">{node.geo_name || '未配置'}</NodeInfoRow>
<NodeInfoRow label='地图点位'>
{node.geo_name || '未配置'}
</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard
title="配置同步"
description="版本追平与应用结果摘要"
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" />
<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="最近应用">
<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="最近应用时间">
<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>
<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>
@@ -242,33 +263,45 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
);
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>
<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 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>
<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">
<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">
<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">
<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" />
<div className='flex items-center gap-2 text-sm text-amber-700 dark:text-amber-300'>
<Activity className='size-4' />
已等待节点在下一次心跳后执行 OpenResty 重启。
</div>
) : null}
@@ -281,10 +314,13 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<>
<NodeDetailShell
title={node.name}
typeLabel="Edge"
typeTone="info"
typeLabel='Edge'
typeTone='info'
statusBadges={[
{ label: getNodeStatusLabel(node.status), tone: getNodeStatusTone(node.status) },
{
label: getNodeStatusLabel(node.status),
tone: getNodeStatusTone(node.status),
},
{
label: getOpenrestyStatusLabel(node.openresty_status),
tone: getOpenrestyStatusTone(node.openresty_status),
@@ -294,7 +330,11 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
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: node.current_version || '未应用',
icon: Package,
},
{
label: '最近心跳',
value: isMeaningfulTime(node.last_seen_at)
@@ -308,12 +348,12 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<NodeObservability
nodeId={node.id}
node={node}
variant="edge"
connectionHint="OpenResty 当前连接"
variant='edge'
connectionHint='OpenResty 当前连接'
/>
}
manage={manageTab}
defaultTab="overview"
defaultTab='overview'
/>
<NodeEditorDialog
@@ -341,13 +381,16 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<AlertDialogHeader>
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
<AlertDialogDescription>
确认删除节点「{node.name}」吗?删除后该节点需要重新创建并重新接入。
确认删除节点「{node.name}
」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
@@ -358,4 +401,4 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
</AlertDialog>
</>
);
}
}
@@ -1,15 +1,21 @@
'use client';
import {useEffect, useMemo, useState} from 'react';
import {useQuery} from '@tanstack/react-query';
import {Copy} from 'lucide-react';
import {toast} from 'sonner';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
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, StatusService} from '@/lib/services/openflare';
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, StatusService } from '@/lib/services/openflare';
import {
buildRelayDockerInstallCommand,
@@ -86,8 +92,16 @@ export function InstallCommand({
return '';
}
return variant === 'relay'
? buildRelayDockerInstallCommand(normalizedServerUrl, node.access_token, serverVersion)
: buildTunnelDockerInstallCommand(normalizedServerUrl, node.access_token, serverVersion);
? buildRelayDockerInstallCommand(
normalizedServerUrl,
node.access_token,
serverVersion,
)
: buildTunnelDockerInstallCommand(
normalizedServerUrl,
node.access_token,
serverVersion,
);
}, [normalizedServerUrl, node.access_token, variant, serverVersion]);
const handleCopy = async (value: string, message: string) => {
@@ -100,77 +114,91 @@ export function InstallCommand({
};
return (
<Card className="border-dashed shadow-none">
<CardHeader className="flex-row items-start justify-between space-y-0 gap-4">
<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>
<CardTitle className='text-base font-semibold'>
{meta.title}
</CardTitle>
<CardDescription>{meta.description}</CardDescription>
</div>
<div className="flex flex-wrap gap-2">
<div className='flex flex-wrap gap-2'>
{scriptCommand ? (
<Button
variant="secondary"
size="sm"
className="h-7 text-xs"
onClick={() => void handleCopy(scriptCommand, '部署脚本命令已复制')}
variant='secondary'
size='sm'
className='h-7 text-xs'
onClick={() =>
void handleCopy(scriptCommand, '部署脚本命令已复制')
}
>
<Copy className="size-3.5 mr-1" />
<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 部署命令已复制')}
variant='secondary'
size='sm'
className='h-7 text-xs'
onClick={() =>
void handleCopy(dockerCommand, 'Docker 部署命令已复制')
}
>
<Copy className="size-3.5 mr-1" />
<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>
<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 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>
<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"
placeholder='https://openflare.example.com'
/>
<p className="text-xs text-muted-foreground">
<p className='text-xs text-muted-foreground'>
部署脚本和容器将使用此地址连接 OpenFlare 控制端。
</p>
</div>
{!node.access_token ? (
<p className="text-sm text-muted-foreground">节点尚未生成接入 Token,请稍后刷新。</p>
<p className='text-sm text-muted-foreground'>
节点尚未生成接入 Token,请稍后刷新。
</p>
) : !normalizedServerUrl ? (
<p className="text-sm text-muted-foreground">请填写有效的 Server URL 以生成部署命令。</p>
<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">
<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">
<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>
@@ -179,4 +207,4 @@ export function InstallCommand({
</CardContent>
</Card>
);
}
}
@@ -1,10 +1,19 @@
'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 { TrendChart } from '@/components/data/trend-chart';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import type { NetworkTrendPoint } from '@/lib/services/openflare';
import {formatBytes, formatTrendHour} from '../../components/dashboard/dashboard-utils';
import {
formatBytes,
formatTrendHour,
} from '../../components/dashboard/dashboard-utils';
export function NetworkTrendChart({
points,
@@ -16,16 +25,18 @@ export function NetworkTrendChart({
description?: string;
}) {
return (
<Card className="border-dashed shadow-none">
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
<CardDescription className="text-xs">{description}</CardDescription>
<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))}
summaryScope="total"
summaryHint="近 24 小时"
labels={points.map((point) =>
formatTrendHour(point.bucket_started_at),
)}
summaryScope='total'
summaryHint='近 24 小时'
yAxisValueFormatter={formatBytes}
series={[
{
@@ -1,10 +1,16 @@
'use client';
import type {ReactNode} from 'react';
import type {LucideIcon} from 'lucide-react';
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';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { cn } from '@/lib/utils';
export function NodeInfoRow({
label,
@@ -16,9 +22,14 @@ export function NodeInfoRow({
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
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>
);
}
@@ -33,12 +44,14 @@ export function NodeKpiCard({
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}
<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>
<p className='mt-2 text-sm font-semibold tracking-tight break-all'>
{value}
</p>
</div>
);
}
@@ -58,10 +71,17 @@ export function NodeSectionCard({
}) {
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}
<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>
@@ -72,8 +92,8 @@ export function NodeSectionCard({
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">
<div className='rounded-xl border border-destructive/25 bg-destructive/5 px-4 py-3 text-sm text-destructive'>
{message}
</div>
);
}
}
@@ -1,18 +1,18 @@
'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 { 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 { 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';
import { NodeKpiCard } from './node-detail-primitives';
import { NodeStatusBadge } from './node-status-badge';
import type { StatusTone } from './node-utils';
export type NodeDetailTabId = 'overview' | 'dashboard' | 'manage';
@@ -89,23 +89,30 @@ export function NodeDetailShell({
);
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" />
<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>
<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">
<div className='flex flex-wrap items-center gap-2'>
{statusBadges.map((badge) => (
<NodeStatusBadge
key={badge.label}
@@ -117,11 +124,11 @@ export function NodeDetailShell({
</div>
</div>
<div className="flex flex-wrap items-center gap-2">{actions}</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">
<div className='grid gap-3 sm:grid-cols-2 xl:grid-cols-4'>
{kpis.map((kpi) => (
<NodeKpiCard
key={kpi.label}
@@ -142,10 +149,13 @@ export function NodeDetailShell({
setActiveTab(value);
}
}}
className="w-full gap-0"
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">
<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}
@@ -161,13 +171,13 @@ export function NodeDetailShell({
</TabsList>
</div>
<TabsContent value="overview" className="mt-6 outline-none">
<TabsContent value='overview' className='mt-6 outline-none'>
{activeTab === 'overview' ? overview : null}
</TabsContent>
<TabsContent value="dashboard" className="mt-6 outline-none">
<TabsContent value='dashboard' className='mt-6 outline-none'>
{activeTab === 'dashboard' ? dashboard : null}
</TabsContent>
<TabsContent value="manage" className="mt-6 outline-none">
<TabsContent value='manage' className='mt-6 outline-none'>
{activeTab === 'manage' ? manage : null}
</TabsContent>
</Tabs>
@@ -1,11 +1,11 @@
'use client';
import {useEffect} from 'react';
import {zodResolver} from '@hookform/resolvers/zod';
import {useForm} from 'react-hook-form';
import {z} from 'zod';
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 { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
@@ -14,11 +14,21 @@ import {
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';
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({
@@ -111,12 +121,14 @@ function toPayload(form: NodeFormValues): NodeMutationPayload {
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,
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') {
@@ -165,7 +177,7 @@ export function NodeEditorDialog({
return (
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className="sm:max-w-lg">
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{node ? '编辑节点' : '新增节点'}</DialogTitle>
<DialogDescription>
@@ -173,8 +185,11 @@ export function NodeEditorDialog({
</DialogDescription>
</DialogHeader>
<form onSubmit={(event) => void handleSubmit(event)} className="space-y-4">
<div className="space-y-2">
<form
onSubmit={(event) => void handleSubmit(event)}
className='space-y-4'
>
<div className='space-y-2'>
<Label>节点类型</Label>
<Select
value={nodeType}
@@ -187,45 +202,47 @@ export function NodeEditorDialog({
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="edge_node">Edge 节点</SelectItem>
<SelectItem value="tunnel_relay">Relay 节点</SelectItem>
<SelectItem value="tunnel_client">Tunnel 节点</SelectItem>
<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>
<div className='space-y-2'>
<Label htmlFor='node-name'>节点名称</Label>
<Input
id="node-name"
placeholder="例如 edge-hk-01"
id='node-name'
placeholder='例如 edge-hk-01'
{...form.register('name')}
/>
{form.formState.errors.name ? (
<p className="text-xs text-destructive">
<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>
<div className='space-y-2'>
<Label htmlFor='node-ip'>节点 IP</Label>
<Input
id="node-ip"
placeholder="可选,接入后自动上报"
id='node-ip'
placeholder='可选,接入后自动上报'
{...form.register('ip')}
/>
{form.formState.errors.ip ? (
<p className="text-xs text-destructive">
<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 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>
<p className='text-sm font-medium'>锁定 IP</p>
<p className='text-xs text-muted-foreground'>
启用后管理端不再自动覆盖 IP
</p>
</div>
<Switch
checked={ipManualOverride}
@@ -235,10 +252,12 @@ export function NodeEditorDialog({
/>
</div>
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<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>
<p className='text-sm font-medium'>自动更新 Agent</p>
<p className='text-xs text-muted-foreground'>
启用后节点将自动拉取正式版更新
</p>
</div>
<Switch
checked={form.watch('auto_update_enabled')}
@@ -249,27 +268,27 @@ export function NodeEditorDialog({
</div>
{nodeType === 'tunnel_relay' ? (
<div className="grid gap-3 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="relay-bind-port">绑定端口</Label>
<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"
id='relay-bind-port'
{...form.register('relay_bind_port')}
/>
{form.formState.errors.relay_bind_port ? (
<p className="text-xs text-destructive">
<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>
<div className='space-y-2'>
<Label htmlFor='relay-vhost-port'>VHost 端口</Label>
<Input
id="relay-vhost-port"
id='relay-vhost-port'
{...form.register('relay_vhost_http_port')}
/>
{form.formState.errors.relay_vhost_http_port ? (
<p className="text-xs text-destructive">
<p className='text-xs text-destructive'>
{form.formState.errors.relay_vhost_http_port.message}
</p>
) : null}
@@ -277,10 +296,10 @@ export function NodeEditorDialog({
</div>
) : null}
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<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>
<p className='text-sm font-medium'>手动地图点位</p>
<p className='text-xs text-muted-foreground'>用于总览地图展示</p>
</div>
<Switch
checked={geoManualOverride}
@@ -291,27 +310,32 @@ export function NodeEditorDialog({
</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 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 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 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
type='button'
variant='outline'
onClick={onClose}
disabled={submitting}
>
取消
</Button>
<Button type="submit" disabled={submitting}>
<Button type='submit' disabled={submitting}>
{submitting ? '保存中...' : node ? '保存修改' : '新增节点'}
</Button>
</DialogFooter>
@@ -319,4 +343,4 @@ export function NodeEditorDialog({
</DialogContent>
</Dialog>
);
}
}
@@ -1,9 +1,9 @@
'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 { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import {
AlertDialog,
@@ -15,18 +15,28 @@ import {
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 { 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 { 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,
@@ -61,18 +71,22 @@ function MetricBar({
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 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}
<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>
<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-2 overflow-hidden rounded-full bg-muted'>
<div
className="h-full rounded-full bg-primary transition-[width]"
className='h-full rounded-full bg-primary transition-[width]'
style={{ width: `${progress}%` }}
/>
</div>
@@ -91,11 +105,11 @@ function SummaryStat({
hint: string;
}) {
return (
<Card className="border-dashed shadow-none">
<CardHeader className="pb-2">
<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>
<CardTitle className='text-base font-semibold'>{value}</CardTitle>
<p className='text-sm text-muted-foreground'>{hint}</p>
</CardHeader>
</Card>
);
@@ -109,48 +123,71 @@ function SystemProfileCard({
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 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>
<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">
<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 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>
<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 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>
<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>
<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>
<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">
<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)
: '—'}
@@ -177,27 +214,31 @@ function HealthEventTimeline({
cleanupPending: boolean;
}) {
return (
<Card className="border-dashed shadow-none">
<CardHeader className="flex-row items-center justify-between space-y-0">
<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>
<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"
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" />
<Trash2 className='size-3.5 mr-1' />
清理日志
</Button>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className='space-y-4'>
{allEventsCount > 0 ? (
<>
<div className="flex flex-wrap gap-2">
<div className='flex flex-wrap gap-2'>
{(
[
['all', '全部事件'],
@@ -207,10 +248,12 @@ function HealthEventTimeline({
).map(([filter, label]) => (
<Button
key={filter}
type="button"
variant={healthEventFilter === filter ? 'secondary' : 'outline'}
size="sm"
className="h-7 text-xs rounded-full"
type='button'
variant={
healthEventFilter === filter ? 'secondary' : 'outline'
}
size='sm'
className='h-7 text-xs rounded-full'
onClick={() => onFilterChange(filter)}
>
{label}
@@ -219,15 +262,17 @@ function HealthEventTimeline({
</div>
{events.length === 0 ? (
<p className="text-sm text-muted-foreground">当前筛选下没有健康事件。</p>
<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"
className='rounded-lg border px-4 py-3'
>
<div className="flex flex-wrap items-center gap-2">
<div className='flex flex-wrap items-center gap-2'>
<NodeStatusBadge
label={getHealthEventLabel(event)}
tone={getHealthEventTone(event)}
@@ -237,10 +282,10 @@ function HealthEventTimeline({
tone={event.status === 'active' ? 'warning' : 'success'}
/>
</div>
<p className="mt-2 text-sm text-muted-foreground">
<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">
<div className='mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-3'>
<p>
首次触发:
{isMeaningfulTime(event.first_triggered_at)
@@ -264,7 +309,9 @@ function HealthEventTimeline({
))}
</>
) : (
<p className="text-sm text-muted-foreground">节点当前还没有上报健康事件记录。</p>
<p className='text-sm text-muted-foreground'>
节点当前还没有上报健康事件记录。
</p>
)}
</CardContent>
</Card>
@@ -283,12 +330,14 @@ export function NodeObservability({
connectionHint?: string;
}) {
const queryClient = useQueryClient();
const [healthEventFilter, setHealthEventFilter] = useState<HealthEventFilter>('all');
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 }),
queryFn: () =>
NodeService.getObservability(nodeId, { hours: 24, limit: 48 }),
refetchInterval: 30000,
});
@@ -305,7 +354,9 @@ export function NodeObservability({
queryClient.invalidateQueries({
queryKey: ['openflare', 'node-observability', nodeId],
}),
queryClient.invalidateQueries({ queryKey: ['openflare', 'dashboard', 'overview'] }),
queryClient.invalidateQueries({
queryKey: ['openflare', 'dashboard', 'overview'],
}),
]);
},
onError: (error) => toast.error(getErrorMessage(error)),
@@ -315,11 +366,17 @@ export function NodeObservability({
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.filter(
(event) => event.status === 'active',
) ?? [],
[observability?.health_events],
);
const resolvedHealthEvents = useMemo(
() => observability?.health_events.filter((event) => event.status === 'resolved') ?? [],
() =>
observability?.health_events.filter(
(event) => event.status === 'resolved',
) ?? [],
[observability?.health_events],
);
const filteredHealthEvents = useMemo(() => {
@@ -331,14 +388,27 @@ export function NodeObservability({
default:
return observability?.health_events ?? [];
}
}, [activeHealthEvents, healthEventFilter, observability?.health_events, resolvedHealthEvents]);
}, [
activeHealthEvents,
healthEventFilter,
observability?.health_events,
resolvedHealthEvents,
]);
const statusCodeDistribution = useMemo(
() => aggregateTrafficBreakdown(observability?.traffic_reports, 'status_codes_json'),
() =>
aggregateTrafficBreakdown(
observability?.traffic_reports,
'status_codes_json',
),
[observability?.traffic_reports],
);
const topDomains = useMemo(
() => aggregateTrafficBreakdown(observability?.traffic_reports, 'top_domains_json'),
() =>
aggregateTrafficBreakdown(
observability?.traffic_reports,
'top_domains_json',
),
[observability?.traffic_reports],
);
const trafficSummary = observability?.analytics?.traffic ?? null;
@@ -361,9 +431,9 @@ export function NodeObservability({
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" />
<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>
@@ -372,9 +442,11 @@ export function NodeObservability({
if (observabilityQuery.isError) {
return (
<Card className="border-dashed shadow-none">
<CardContent className="py-6">
<p className="text-sm text-destructive">{getErrorMessage(observabilityQuery.error)}</p>
<Card className='border-dashed shadow-none'>
<CardContent className='py-6'>
<p className='text-sm text-destructive'>
{getErrorMessage(observabilityQuery.error)}
</p>
</CardContent>
</Card>
);
@@ -382,10 +454,10 @@ export function NodeObservability({
if (variant === 'compact') {
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-3">
<div className='space-y-6'>
<div className='grid gap-4 md:grid-cols-3'>
<SummaryStat
label="运行诊断"
label='运行诊断'
value={
activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常`
@@ -398,44 +470,52 @@ export function NodeObservability({
}
/>
<SummaryStat
label="系统核心"
label='系统核心'
value={profile?.hostname || '—'}
hint={profile ? `${profile.os_name || '—'} · ${profile.architecture || '—'}` : '—'}
hint={
profile
? `${profile.os_name || '—'} · ${profile.architecture || '—'}`
: '—'
}
/>
<SummaryStat
label="在线时长"
label='在线时长'
value={formatUptime(profile?.uptime_seconds)}
hint="来自节点系统画像上报"
hint='来自节点系统画像上报'
/>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<Card className="border-dashed shadow-none">
<div className='grid gap-6 xl:grid-cols-2'>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className="text-base font-semibold">系统画像</CardTitle>
<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 className='text-sm text-muted-foreground'>
节点已接入,但尚未上报完整系统画像。
</p>
)}
</CardContent>
</Card>
<Card className="border-dashed shadow-none">
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className="text-base font-semibold">实时资源快照</CardTitle>
<CardTitle className='text-base font-semibold'>
实时资源快照
</CardTitle>
<CardDescription>最近一次 metrics 上报</CardDescription>
</CardHeader>
<CardContent>
{latestMetric ? (
<div className="grid gap-3 sm:grid-cols-2">
<div className='grid gap-3 sm:grid-cols-2'>
<MetricBar
label="CPU"
label='CPU'
value={formatPercent(latestMetric.cpu_usage_percent)}
progress={latestMetric.cpu_usage_percent}
hint={
@@ -445,24 +525,28 @@ export function NodeObservability({
}
/>
<MetricBar
label="内存"
label='内存'
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
progress={memoryUsageRatio}
/>
<MetricBar
label="存储"
label='存储'
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
progress={storageUsageRatio}
/>
<MetricBar
label="连接数"
value={formatMetricCount(latestMetric.openresty_connections)}
label='连接数'
value={formatMetricCount(
latestMetric.openresty_connections,
)}
progress={null}
hint={connectionHint}
/>
</div>
) : (
<p className="text-sm text-muted-foreground">节点已接入,但尚未上报资源快照。</p>
<p className='text-sm text-muted-foreground'>
节点已接入,但尚未上报资源快照。
</p>
)}
</CardContent>
</Card>
@@ -486,9 +570,11 @@ export function NodeObservability({
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleanupMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={cleanupMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className='bg-destructive text-white hover:bg-destructive/90'
disabled={cleanupMutation.isPending}
onClick={() => cleanupMutation.mutate()}
>
@@ -502,10 +588,10 @@ export function NodeObservability({
}
return (
<div className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className='space-y-6'>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<SummaryStat
label="运行诊断"
label='运行诊断'
value={
activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常`
@@ -518,7 +604,7 @@ export function NodeObservability({
}
/>
<SummaryStat
label="请求/分钟"
label='请求/分钟'
value={formatMetricCount(trafficSummary?.request_count)}
hint={
trafficSummary
@@ -527,7 +613,7 @@ export function NodeObservability({
}
/>
<SummaryStat
label="容量压力"
label='容量压力'
value={healthSummary?.has_capacity_risk ? '需要关注' : '正常范围'}
hint={
latestMetric
@@ -536,7 +622,7 @@ export function NodeObservability({
}
/>
<SummaryStat
label="来源信号"
label='来源信号'
value={topSourceCountry?.key ?? '—'}
hint={
topSourceCountry
@@ -546,31 +632,31 @@ export function NodeObservability({
/>
</div>
<div className="grid gap-6 xl:grid-cols-3">
<Card className="border-dashed shadow-none">
<div className='grid gap-6 xl:grid-cols-3'>
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className="text-base font-semibold">系统信息</CardTitle>
<CardTitle className='text-base font-semibold'>系统信息</CardTitle>
</CardHeader>
<CardContent>
{profile ? (
<SystemProfileCard profile={profile} nodeName={nodeName} />
) : (
<p className="text-sm text-muted-foreground">
<p className='text-sm text-muted-foreground'>
节点已经接入,但还没有上报完整系统画像。
</p>
)}
</CardContent>
</Card>
<Card className="border-dashed shadow-none">
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className="text-base font-semibold">实时资源</CardTitle>
<CardTitle className='text-base font-semibold'>实时资源</CardTitle>
</CardHeader>
<CardContent>
{latestMetric ? (
<div className="grid gap-3 sm:grid-cols-2">
<div className='grid gap-3 sm:grid-cols-2'>
<MetricBar
label="CPU"
label='CPU'
value={formatPercent(latestMetric.cpu_usage_percent)}
progress={latestMetric.cpu_usage_percent}
hint={
@@ -580,37 +666,39 @@ export function NodeObservability({
}
/>
<MetricBar
label="内存"
label='内存'
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
progress={memoryUsageRatio}
/>
<MetricBar
label="存储"
label='存储'
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
progress={storageUsageRatio}
/>
<MetricBar
label="连接数"
label='连接数'
value={formatMetricCount(latestMetric.openresty_connections)}
progress={null}
hint={connectionHint}
/>
</div>
) : (
<p className="text-sm text-muted-foreground">节点已经接入,但还没有上报资源快照。</p>
<p className='text-sm text-muted-foreground'>
节点已经接入,但还没有上报资源快照。
</p>
)}
</CardContent>
</Card>
<Card className="border-dashed shadow-none">
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className="text-base font-semibold">网络流量</CardTitle>
<CardTitle className='text-base font-semibold'>网络流量</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className='space-y-4'>
{latestMetric ? (
<>
{node ? (
<div className="flex flex-wrap gap-2">
<div className='flex flex-wrap gap-2'>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
@@ -630,49 +718,61 @@ export function NodeObservability({
</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">
<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 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">
<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">
<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">
<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">
<p className='mt-3 text-2xl font-semibold'>
{formatMetricCount(trafficSummary?.request_count)}
</p>
<p className="mt-2 text-sm text-muted-foreground">
<p className='mt-2 text-sm text-muted-foreground'>
{trafficSummary
? `近 60 秒 · 窗口UV ${formatMetricCount(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">
<div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
近 60 秒错误
</p>
<p className="mt-3 text-2xl font-semibold">
<p className='mt-3 text-2xl font-semibold'>
{formatMetricCount(trafficSummary?.error_count)}
</p>
<p className="mt-2 text-sm text-muted-foreground">
<p className='mt-2 text-sm text-muted-foreground'>
{trafficSummary
? `错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
: '暂无错误率摘要'}
@@ -681,7 +781,7 @@ export function NodeObservability({
</div>
</>
) : (
<p className="text-sm text-muted-foreground">
<p className='text-sm text-muted-foreground'>
节点已经接入,但还没有上报网络流量相关快照。
</p>
)}
@@ -689,70 +789,92 @@ export function NodeObservability({
</Card>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<div className='grid gap-6 xl:grid-cols-2'>
<TrafficTrendChart
points={trends?.traffic_24h ?? []}
description="按小时聚合该节点的请求量和错误量。"
description='按小时聚合该节点的请求量和错误量。'
/>
<CapacityTrendChart
points={trends?.capacity_24h ?? []}
description="观察该节点 CPU 与内存使用率在 24 小时内的变化。"
description='观察该节点 CPU 与内存使用率在 24 小时内的变化。'
/>
</div>
<div className="grid gap-6 xl:grid-cols-2">
<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">
<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>
<CardTitle className='text-base font-semibold'>
请求结构分布
</CardTitle>
<CardDescription>
聚合最近 24 小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。
聚合最近 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} 次` : '暂无状态码分布'}
<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">
<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 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 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 className='grid gap-6 xl:grid-cols-2'>
<div>
<p className="mb-4 text-xs text-muted-foreground uppercase tracking-wide">
<p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'>
状态码分布
</p>
<DistributionList
items={statusCodeDistribution}
emptyMessage="暂无状态码分布"
emptyMessage='暂无状态码分布'
/>
</div>
<div>
<p className="mb-4 text-xs text-muted-foreground uppercase tracking-wide">
<p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'>
Top Domain
</p>
<DistributionList items={topDomains} emptyMessage="暂无域名分布" />
<DistributionList
items={topDomains}
emptyMessage='暂无域名分布'
/>
</div>
</div>
</CardContent>
@@ -777,9 +899,11 @@ export function NodeObservability({
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleanupMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={cleanupMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className='bg-destructive text-white hover:bg-destructive/90'
disabled={cleanupMutation.isPending}
onClick={() => cleanupMutation.mutate()}
>
@@ -790,4 +914,4 @@ export function NodeObservability({
</AlertDialog>
</div>
);
}
}
@@ -1,7 +1,7 @@
import {Badge} from '@/components/ui/badge';
import {cn} from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import type {StatusTone} from './node-utils';
import type { StatusTone } from './node-utils';
const toneClassName: Record<StatusTone, string> = {
success: 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600',
@@ -21,8 +21,12 @@ export function NodeStatusBadge({
}) {
return (
<Badge
variant="outline"
className={cn('text-[10px] rounded-full py-0 px-2 font-medium', toneClassName[tone], className)}
variant='outline'
className={cn(
'text-[10px] rounded-full py-0 px-2 font-medium',
toneClassName[tone],
className,
)}
>
{label}
</Badge>
@@ -1,9 +1,9 @@
'use client';
import Link from 'next/link';
import {useSearchParams} from 'next/navigation';
import { useSearchParams } from 'next/navigation';
import {cn} from '@/lib/utils';
import { cn } from '@/lib/utils';
export type NodeFilter = 'all' | 'edge' | 'relay' | 'tunnel';
@@ -16,7 +16,12 @@ const filters: Array<{ key: NodeFilter; label: string; href: string }> = [
export function getNodeFilter(searchParams: URLSearchParams): NodeFilter {
const current = searchParams.get('filter')?.trim().toLowerCase() ?? '';
if (current === 'relay' || current === 'tunnel' || current === 'edge' || current === 'all') {
if (
current === 'relay' ||
current === 'tunnel' ||
current === 'edge' ||
current === 'all'
) {
return current;
}
return 'all';
@@ -58,7 +63,7 @@ export function NodeTypeFilter() {
const activeFilter = getNodeFilter(searchParams);
return (
<div className="flex flex-wrap gap-2">
<div className='flex flex-wrap gap-2'>
{filters.map((item) => (
<Link
key={item.key}
@@ -1,15 +1,26 @@
import type {ApplyResult, NodeItem, NodeStatus, NodeTrafficReport, OpenrestyStatus,} from '@/lib/services/openflare';
import type {
ApplyResult,
NodeItem,
NodeStatus,
NodeTrafficReport,
OpenrestyStatus,
} from '@/lib/services/openflare';
export const WS_CONNECTED_LAST_SEEN = '__OPENFLARE_WS_CONNECTED__';
export const FLARED_WS_CONNECTED_LAST_SEEN = '__OPENFLARE_FLARED_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;
return (
value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN
);
}
export function isMeaningfulTime(value: string | null | undefined): value is string {
export function isMeaningfulTime(
value: string | null | undefined,
): value is string {
return (
Boolean(value) &&
!isWSConnectedLastSeen(value) &&
@@ -75,7 +86,9 @@ export function getOpenrestyStatusLabel(status: OpenrestyStatus) {
return '未知';
}
export function getRelayStatusTone(status: string | null | undefined): StatusTone {
export function getRelayStatusTone(
status: string | null | undefined,
): StatusTone {
if (status === 'healthy') return 'success';
if (status === 'unhealthy') return 'danger';
return 'warning';
@@ -112,7 +125,12 @@ export function getImageTag(version?: string): string {
return 'latest';
}
const v = version.toLowerCase();
if (v === 'dev' || v.includes('alpha') || v.includes('beta') || v.includes('rc')) {
if (
v === 'dev' ||
v.includes('alpha') ||
v.includes('beta') ||
v.includes('rc')
) {
return 'beta';
}
if (v.startsWith('v')) {
@@ -121,7 +139,10 @@ export function getImageTag(version?: string): string {
return 'latest';
}
export function buildRelayInstallCommand(serverUrl: string, discoveryToken: string) {
export function buildRelayInstallCommand(
serverUrl: string,
discoveryToken: string,
) {
return [
`curl -fsSL ${relayInstallerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
@@ -129,7 +150,11 @@ export function buildRelayInstallCommand(serverUrl: string, discoveryToken: stri
].join('\n');
}
export function buildRelayDockerInstallCommand(serverUrl: string, discoveryToken: string, version?: string) {
export function buildRelayDockerInstallCommand(
serverUrl: string,
discoveryToken: string,
version?: string,
) {
const tag = getImageTag(version);
const image = `ghcr.io/rain-kl/openflare-relay:${tag}`;
@@ -143,7 +168,10 @@ export function buildRelayDockerInstallCommand(serverUrl: string, discoveryToken
].join('\n');
}
export function buildTunnelInstallCommand(serverUrl: string, tunnelToken: string) {
export function buildTunnelInstallCommand(
serverUrl: string,
tunnelToken: string,
) {
return [
`curl -fsSL ${flaredInstallerScriptUrl} | bash -s -- \\`,
` --server-url ${serverUrl} \\`,
@@ -151,7 +179,11 @@ export function buildTunnelInstallCommand(serverUrl: string, tunnelToken: string
].join('\n');
}
export function buildTunnelDockerInstallCommand(serverUrl: string, tunnelToken: string, version?: string) {
export function buildTunnelDockerInstallCommand(
serverUrl: string,
tunnelToken: string,
version?: string,
) {
const tag = getImageTag(version);
const image = `ghcr.io/rain-kl/openflared:${tag}`;
@@ -165,7 +197,6 @@ export function buildTunnelDockerInstallCommand(serverUrl: string, tunnelToken:
].join('\n');
}
export function formatBytes(bytes?: number | null, decimals = 1) {
if (bytes === undefined || bytes === null || !Number.isFinite(bytes)) {
return '—';
@@ -175,7 +206,10 @@ export function formatBytes(bytes?: number | null, decimals = 1) {
}
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const index = Math.min(
Math.floor(Math.log(bytes) / Math.log(1024)),
units.length - 1,
);
const value = bytes / 1024 ** index;
return `${value.toFixed(decimals)} ${units[index]}`;
}
@@ -194,10 +228,7 @@ export function formatMetricCount(value?: number | null) {
return value.toLocaleString('zh-CN');
}
export function formatBytesPerSecond(
value?: number | null,
windowSeconds = 1,
) {
export function formatBytesPerSecond(value?: number | null, windowSeconds = 1) {
if (value === undefined || value === null || !Number.isFinite(value)) {
return '—';
}
@@ -213,12 +244,15 @@ export function parseTrafficMap(value?: string | null) {
}
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;
}, {});
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>;
}
@@ -271,9 +305,10 @@ export function formatUptime(seconds?: number | null) {
return `${minutes} 分钟`;
}
export function getHealthEventTone(
event: { status: string; severity: string },
): StatusTone {
export function getHealthEventTone(event: {
status: string;
severity: string;
}): StatusTone {
if (event.status === 'resolved') {
return 'success';
}
@@ -1,14 +1,21 @@
'use client';
import Link from 'next/link';
import {Pencil, Trash2} from 'lucide-react';
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 { 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 { NodeStatusBadge } from './node-status-badge';
import {
formatRelativeTime,
getApplyLabel,
@@ -36,48 +43,51 @@ export function NodesTable({
onDelete: (node: NodeItem) => void;
}) {
return (
<div className="border border-dashed rounded-lg overflow-hidden">
<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 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" />
<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">
<p className='text-xs text-muted-foreground'>
IP:{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
</p>
<p className="text-xs text-muted-foreground">
<p className='text-xs text-muted-foreground'>
位置:{node.geo_name || '未配置地图点位'}
</p>
</div>
</TableCell>
<TableCell className="py-3">
<TableCell className='py-3'>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
tone={getNodeStatusTone(node.status)}
/>
</TableCell>
<TableCell className="py-3 text-muted-foreground">
<TableCell className='py-3 text-muted-foreground'>
{node.version || 'unknown'}
</TableCell>
<TableCell className="py-3">
<TableCell className='py-3'>
{node.node_type === 'tunnel_relay' ? (
<NodeStatusBadge
label={getRelayStatusLabel(node.relay_status)}
@@ -95,13 +105,13 @@ export function NodesTable({
/>
)}
</TableCell>
<TableCell className="py-3 text-muted-foreground">
<TableCell className='py-3 text-muted-foreground'>
{node.current_version ||
(node.node_type === 'tunnel_relay' ? '实时配置' : '未应用')}
</TableCell>
<TableCell className="py-3">
<TableCell className='py-3'>
{node.node_type === 'tunnel_relay' ? (
<span className="text-sm text-muted-foreground">—</span>
<span className='text-sm text-muted-foreground'>—</span>
) : (
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
@@ -109,35 +119,40 @@ export function NodesTable({
/>
)}
</TableCell>
<TableCell className="py-3 text-muted-foreground">
<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>
<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"
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() => onEdit(node)}
>
<Pencil className="size-3 mr-1" />
<Pencil className='size-3 mr-1' />
编辑
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs text-destructive hover:text-destructive"
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" />
<Trash2 className='size-3 mr-1' />
删除
</Button>
</div>
@@ -1,9 +1,9 @@
'use client';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useEffect, useMemo, useState} from 'react';
import { useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import {
Activity,
Copy,
@@ -18,7 +18,7 @@ import {
Trash2,
Upload,
} from 'lucide-react';
import {toast} from 'sonner';
import { toast } from 'sonner';
import {
AlertDialog,
@@ -30,23 +30,31 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {Button} from '@/components/ui/button';
import {Input} from '@/components/ui/input';
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 { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
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 services from '@/lib/services';
import type {SystemConfig} from '@/lib/services/admin';
import type { SystemConfig } from '@/lib/services/admin';
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 { 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,
@@ -85,7 +93,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
const configs = useMemo(
() => systemConfigMap(systemConfigsQuery.data ?? []),
[systemConfigsQuery.data]
[systemConfigsQuery.data],
);
useEffect(() => {
@@ -101,16 +109,22 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
throw new Error('端口号必须在 1 ~ 65535 范围内');
}
const portCfg = configs['relay_frps_web_ui_port'];
await services.adminSystemConfig.updateSystemConfig('relay_frps_web_ui_port', {
value: String(portNum),
description: portCfg?.description || 'FRPS 内置 Web 管理界面端口',
});
await services.adminSystemConfig.updateSystemConfig(
'relay_frps_web_ui_port',
{
value: String(portNum),
description: portCfg?.description || 'FRPS 内置 Web 管理界面端口',
},
);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['admin', 'system-configs'] });
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('FRPS WebUI 端口配置已更新');
},
onError: (error) => toast.error(error instanceof Error ? error.message : '更新端口失败'),
onError: (error) =>
toast.error(error instanceof Error ? error.message : '更新端口失败'),
});
const saveMutation = useMutation({
@@ -166,7 +180,10 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
}) =>
NodeService.requestAgentUpdate(node.id, {
channel: release?.channel ?? channel,
tag_name: release?.channel === 'preview' ? release.tag_name || undefined : undefined,
tag_name:
release?.channel === 'preview'
? release.tag_name || undefined
: undefined,
}),
onSuccess: async (updated) => {
toast.success(
@@ -191,14 +208,20 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
const handleRefresh = () => {
void Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'node-observability', node.id] }),
queryClient.invalidateQueries({
queryKey: ['openflare', 'node-observability', node.id],
}),
]);
};
const webServerPortNum = parseInt(configs['relay_frps_web_ui_port']?.value || '', 10);
const resolvedPort = !isNaN(webServerPortNum) && webServerPortNum > 0
? webServerPortNum
: (node.relay_bind_port || 7000) + 500;
const webServerPortNum = parseInt(
configs['relay_frps_web_ui_port']?.value || '',
10,
);
const resolvedPort =
!isNaN(webServerPortNum) && webServerPortNum > 0
? webServerPortNum
: (node.relay_bind_port || 7000) + 500;
const webUiUrl =
node.relay_web_server_enabled && node.relay_bind_port
@@ -207,69 +230,88 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
const headerActions = (
<>
<Button variant="outline" size="sm" className="h-8" onClick={() => setEditorOpen(true)}>
<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
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"
variant='outline'
size='sm'
className='h-8'
disabled={forceSyncMutation.isPending}
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className="size-3.5 mr-1.5" />
<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" />
<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"
variant='outline'
size='sm'
className='h-8 text-destructive hover:text-destructive'
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-3.5 mr-1.5" />
<Trash2 className='size-3.5 mr-1.5' />
删除
</Button>
</>
);
const overviewTab = (
<div className="space-y-6">
<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="运行状态">
<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="中继健康">
<NodeInfoRow label='中继健康'>
<NodeStatusBadge
label={getRelayStatusLabel(node.relay_status)}
tone={getRelayStatusTone(node.relay_status)}
/>
</NodeInfoRow>
<NodeInfoRow label="最近心跳">
<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 label='活动连接 / 代理数'>
{node.relay_frps_connections ?? '—'} /{' '}
{node.relay_frps_proxy_count ?? '—'}
</NodeInfoRow>
<NodeInfoRow label="IP 地址">
<NodeInfoRow label='IP 地址'>
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
</NodeInfoRow>
@@ -277,31 +319,43 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
</NodeSectionCard>
<NodeSectionCard
title="网络端口"
description="frps 控制面与 HTTP 虚拟主机配置"
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" />
<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>
<div className='divide-y'>
<NodeInfoRow label='绑定控制端口'>
{node.relay_bind_port || '—'}
</NodeInfoRow>
<NodeInfoRow label="最近应用">
<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>
<NodeInfoRow label='Relay 版本'>
{node.version || 'unknown'}
</NodeInfoRow>
<NodeInfoRow label='frps 核心版本'>
{node.ext_version || 'unknown'}
</NodeInfoRow>
</div>
</NodeSectionCard>
</div>
@@ -309,13 +363,16 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
);
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">
<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">
<p className='text-xs text-muted-foreground'>
开启后,节点将启动 frps 的内置管理服务。
</p>
</div>
@@ -326,80 +383,93 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
/>
</div>
<div className="rounded-xl border p-4 space-y-3">
<Label htmlFor="web_server_port" className="text-xs font-semibold">Web 管理界面端口</Label>
<div className="flex max-w-sm items-center gap-2">
<div className='rounded-xl border p-4 space-y-3'>
<Label htmlFor='web_server_port' className='text-xs font-semibold'>
Web 管理界面端口
</Label>
<div className='flex max-w-sm items-center gap-2'>
<Input
id="web_server_port"
type="number"
id='web_server_port'
type='number'
min={1}
max={65535}
value={webServerPort}
onChange={(e) => setWebServerPort(e.target.value)}
placeholder="例如: 17500"
className="bg-card text-xs h-9"
disabled={savePortMutation.isPending || systemConfigsQuery.isLoading}
placeholder='例如: 17500'
className='bg-card text-xs h-9'
disabled={
savePortMutation.isPending || systemConfigsQuery.isLoading
}
/>
<Button
size="sm"
className="h-9"
size='sm'
className='h-9'
onClick={() => savePortMutation.mutate(webServerPort)}
disabled={savePortMutation.isPending || systemConfigsQuery.isLoading}
disabled={
savePortMutation.isPending || systemConfigsQuery.isLoading
}
>
{savePortMutation.isPending ? (
<Loader2 className="size-3.5 animate-spin" />
<Loader2 className='size-3.5 animate-spin' />
) : (
'保存'
)}
</Button>
</div>
<p className="text-[11px] text-muted-foreground">
当前实际监听端口:{resolvedPort} (修改后在下次节点心跳同步时生效,默认为绑定端口 + 500)。
<p className='text-[11px] text-muted-foreground'>
当前实际监听端口:{resolvedPort}{' '}
(修改后在下次节点心跳同步时生效,默认为绑定端口 + 500)。
</p>
</div>
{webUiUrl ? (
<div className="flex flex-wrap items-center gap-3 text-sm">
<div className='flex flex-wrap items-center gap-3 text-sm'>
<a
href={webUiUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center font-medium text-primary hover:underline"
target='_blank'
rel='noopener noreferrer'
className='inline-flex items-center font-medium text-primary hover:underline'
>
打开 FRPS WebUI
<ExternalLink className="size-3.5 ml-1.5" />
<ExternalLink className='size-3.5 ml-1.5' />
</a>
<span className="text-xs text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded border">
<span className='text-xs text-muted-foreground font-mono bg-muted px-2 py-0.5 rounded border'>
{webUiUrl}
</span>
<Button
variant="ghost"
size="icon"
className="size-7 text-muted-foreground hover:text-foreground"
variant='ghost'
size='icon'
className='size-7 text-muted-foreground hover:text-foreground'
onClick={() => {
void navigator.clipboard.writeText(webUiUrl);
toast.success('链接已复制到剪贴板');
}}
title="复制链接"
title='复制链接'
>
<Copy className="size-3.5" />
<Copy className='size-3.5' />
</Button>
</div>
) : (
<p className="text-sm text-muted-foreground">WebUI 已禁用或未配置绑定端口。</p>
<p className='text-sm text-muted-foreground'>
WebUI 已禁用或未配置绑定端口。
</p>
)}
</div>
</NodeSectionCard>
<InstallCommand node={node} variant="relay" />
<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>
<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>
<NodeInfoRow label="创建时间">{formatDateTime(node.created_at)}</NodeInfoRow>
<NodeInfoRow label="更新时间">{formatDateTime(node.updated_at)}</NodeInfoRow>
</div>
</NodeSectionCard>
</div>
@@ -409,10 +479,13 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<>
<NodeDetailShell
title={node.name}
typeLabel="Relay"
typeTone="warning"
typeLabel='Relay'
typeTone='warning'
statusBadges={[
{ label: getNodeStatusLabel(node.status), tone: getNodeStatusTone(node.status) },
{
label: getNodeStatusLabel(node.status),
tone: getNodeStatusTone(node.status),
},
{
label: getRelayStatusLabel(node.relay_status),
tone: getRelayStatusTone(node.relay_status),
@@ -421,8 +494,16 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
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: 'Relay 版本',
value: node.version || 'unknown',
icon: Server,
},
{
label: 'frps 核心',
value: node.ext_version || 'unknown',
icon: Network,
},
{
label: '最近心跳',
value: isWSConnectedLastSeen(node.last_seen_at)
@@ -437,12 +518,12 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
dashboard={
<NodeObservability
nodeId={node.id}
variant="compact"
connectionHint="中继承载活动连接数"
variant='compact'
connectionHint='中继承载活动连接数'
/>
}
manage={manageTab}
defaultTab="overview"
defaultTab='overview'
/>
<NodeEditorDialog
@@ -470,13 +551,16 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<AlertDialogHeader>
<AlertDialogTitle>确认删除中继节点</AlertDialogTitle>
<AlertDialogDescription>
确认删除中继节点「{node.name}」吗?删除后该节点需要重新创建并重新接入。
确认删除中继节点「{node.name}
」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
@@ -1,9 +1,9 @@
'use client';
import Link from 'next/link';
import {useRouter} from 'next/navigation';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useState} from 'react';
import { useRouter } from 'next/navigation';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import {
Activity,
FileText,
@@ -15,7 +15,7 @@ import {
Trash2,
Upload,
} from 'lucide-react';
import {toast} from 'sonner';
import { toast } from 'sonner';
import {
AlertDialog,
@@ -27,22 +27,26 @@ import {
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 { 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 { 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 { NodeEditorDialog } from './node-editor-dialog';
import { NodeObservability } from './node-observability';
import { NodeStatusBadge } from './node-status-badge';
import {
formatRelativeTime,
getApplyLabel,
@@ -95,7 +99,10 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
}) =>
NodeService.requestAgentUpdate(node.id, {
channel: release?.channel ?? channel,
tag_name: release?.channel === 'preview' ? release.tag_name || undefined : undefined,
tag_name:
release?.channel === 'preview'
? release.tag_name || undefined
: undefined,
}),
onSuccess: async (updated) => {
toast.success(
@@ -120,108 +127,136 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
const handleRefresh = () => {
void Promise.all([
queryClient.invalidateQueries({ queryKey: nodesQueryKey }),
queryClient.invalidateQueries({ queryKey: ['openflare', 'node-observability', node.id] }),
queryClient.invalidateQueries({
queryKey: ['openflare', 'node-observability', node.id],
}),
]);
};
const headerActions = (
<>
<Button variant="outline" size="sm" className="h-8" onClick={() => setEditorOpen(true)}>
<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
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"
variant='outline'
size='sm'
className='h-8'
disabled={forceSyncMutation.isPending}
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className="size-3.5 mr-1.5" />
<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" />
<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"
variant='outline'
size='sm'
className='h-8 text-destructive hover:text-destructive'
onClick={() => setDeleteOpen(true)}
>
<Trash2 className="size-3.5 mr-1.5" />
<Trash2 className='size-3.5 mr-1.5' />
删除
</Button>
</>
);
const overviewTab = (
<div className="space-y-6">
<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="运行状态">
<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 状态">
<NodeInfoRow label='flared 状态'>
<NodeStatusBadge
label={getFlaredStatusLabel(node)}
tone={getFlaredStatusTone(node)}
/>
</NodeInfoRow>
<NodeInfoRow label="最近心跳">
<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 地址">
<NodeInfoRow label='IP 地址'>
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
</NodeInfoRow>
<NodeInfoRow label="自动更新">{node.auto_update_enabled ? '已启用' : '手动'}</NodeInfoRow>
<NodeInfoRow label='自动更新'>
{node.auto_update_enabled ? '已启用' : '手动'}
</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard
title="配置同步"
description="隧道客户端配置追平状态"
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" />
<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="最近应用">
<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="最近应用时间">
<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>
<NodeInfoRow label='同步文件数'>
{node.latest_support_file_count} 个
</NodeInfoRow>
) : null}
<NodeInfoRow label="升级状态">
<NodeInfoRow label='升级状态'>
<NodeStatusBadge
label={
node.update_requested
@@ -240,21 +275,27 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
);
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>
<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 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>
<NodeInfoRow label="创建时间">{formatDateTime(node.created_at)}</NodeInfoRow>
<NodeInfoRow label="更新时间">{formatDateTime(node.updated_at)}</NodeInfoRow>
</div>
</NodeSectionCard>
<InstallCommand node={node} variant="tunnel" />
<InstallCommand node={node} variant='tunnel' />
</div>
);
@@ -262,17 +303,31 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<>
<NodeDetailShell
title={node.name}
typeLabel="Tunnel"
typeTone="info"
typeLabel='Tunnel'
typeTone='info'
statusBadges={[
{ label: getNodeStatusLabel(node.status), tone: getNodeStatusTone(node.status) },
{ label: getFlaredStatusLabel(node), tone: getFlaredStatusTone(node) },
{
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: 'openflared',
value: node.version || 'unknown',
icon: Package,
},
{
label: '当前配置',
value: node.current_version || '未应用',
icon: KeyRound,
},
{
label: '最近心跳',
value: isWSConnectedLastSeen(node.last_seen_at)
@@ -287,12 +342,12 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
dashboard={
<NodeObservability
nodeId={node.id}
variant="compact"
connectionHint="隧道并发连接数"
variant='compact'
connectionHint='隧道并发连接数'
/>
}
manage={manageTab}
defaultTab="overview"
defaultTab='overview'
/>
<NodeEditorDialog
@@ -320,13 +375,16 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<AlertDialogHeader>
<AlertDialogTitle>确认删除隧道节点</AlertDialogTitle>
<AlertDialogDescription>
确认删除隧道节点「{node.name}」吗?删除后该节点需要重新创建并重新接入。
确认删除隧道节点「{node.name}
」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
@@ -337,4 +395,4 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
</AlertDialog>
</>
);
}
}
@@ -1,21 +1,21 @@
'use client';
import Link from 'next/link';
import {useMemo} from 'react';
import {useSearchParams} from 'next/navigation';
import {useQuery} from '@tanstack/react-query';
import {ArrowLeft, Server} from 'lucide-react';
import { useMemo } from 'react';
import { useSearchParams } from 'next/navigation';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, Server } from 'lucide-react';
import {Button} from '@/components/ui/button';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import {NodeService} from '@/lib/services/openflare';
import { Button } from '@/components/ui/button';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import { NodeService } from '@/lib/services/openflare';
import {EdgeNodeDetail} from '../components/edge-node-detail';
import {RelayNodeDetail} from '../components/relay-node-detail';
import {TunnelNodeDetail} from '../components/tunnel-node-detail';
import {getErrorMessage} from '../components/node-utils';
import { EdgeNodeDetail } from '../components/edge-node-detail';
import { RelayNodeDetail } from '../components/relay-node-detail';
import { TunnelNodeDetail } from '../components/tunnel-node-detail';
import { getErrorMessage } from '../components/node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
@@ -31,15 +31,17 @@ export function NodeDetailPageClient() {
const node = useMemo(() => {
if (!nodeId) return null;
return (nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null;
return (
(nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null
);
}, [nodeId, nodesQuery.data]);
if (!nodeId) {
return (
<div className="py-6 px-1">
<div className='py-6 px-1'>
<EmptyStateWithBorder
icon={Server}
description="缺少节点 ID,请从节点列表进入详情页。"
description='缺少节点 ID,请从节点列表进入详情页。'
/>
</div>
);
@@ -47,20 +49,20 @@ export function NodeDetailPageClient() {
if (nodesQuery.isLoading) {
return (
<div className="py-6 px-1">
<LoadingStateWithBorder icon={Server} description="加载节点详情中..." />
<div className='py-6 px-1'>
<LoadingStateWithBorder icon={Server} description='加载节点详情中...' />
</div>
);
}
if (nodesQuery.isError) {
return (
<div className="py-6 px-1">
<div className="p-8 border border-dashed rounded-lg">
<div className='py-6 px-1'>
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
message={getErrorMessage(nodesQuery.error)}
onRetry={() => void nodesQuery.refetch()}
className="justify-center"
className='justify-center'
/>
</div>
</div>
@@ -69,16 +71,16 @@ export function NodeDetailPageClient() {
if (!node) {
return (
<div className="py-6 px-1 space-y-4">
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
<Link href="/nodes">
<ArrowLeft className="size-4 mr-1" />
<div className='py-6 px-1 space-y-4'>
<Button variant='ghost' size='sm' className='h-8 px-2' asChild>
<Link href='/nodes'>
<ArrowLeft className='size-4 mr-1' />
返回节点列表
</Link>
</Button>
<EmptyStateWithBorder
icon={Server}
description="节点不存在,可能已被删除或 ID 无效。"
description='节点不存在,可能已被删除或 ID 无效。'
/>
</div>
);
+8 -8
View File
@@ -1,15 +1,15 @@
import {Suspense} from 'react';
import { Suspense } from 'react';
import {Skeleton} from '@/components/ui/skeleton';
import { Skeleton } from '@/components/ui/skeleton';
import {NodeDetailPageClient} from './page-client';
import { NodeDetailPageClient } from './page-client';
function NodeDetailPageFallback() {
return (
<div className="py-6 px-1 space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-xl" />
<Skeleton className="h-64 w-full" />
<div className='py-6 px-1 space-y-6'>
<Skeleton className='h-8 w-48' />
<Skeleton className='h-10 w-full max-w-xl' />
<Skeleton className='h-64 w-full' />
</div>
);
}
@@ -20,4 +20,4 @@ export default function NodeDetailPage() {
<NodeDetailPageClient />
</Suspense>
);
}
}
+84 -47
View File
@@ -1,11 +1,11 @@
'use client';
import Link from 'next/link';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import {useSearchParams} from 'next/navigation';
import {Loader2, Plus, RefreshCw, Server} from 'lucide-react';
import {toast} from 'sonner';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { Loader2, Plus, RefreshCw, Server } from 'lucide-react';
import { toast } from 'sonner';
import {
AlertDialog,
@@ -17,18 +17,29 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import type {NodeItem, NodeMutationPayload} from '@/lib/services/openflare';
import {NodeService} from '@/lib/services/openflare';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { EmptyStateWithBorder } from '@/components/layout/empty';
import { ErrorInline } from '@/components/layout/error';
import { LoadingStateWithBorder } from '@/components/layout/loading';
import type { NodeItem, NodeMutationPayload } from '@/lib/services/openflare';
import { NodeService } from '@/lib/services/openflare';
import {NodeEditorDialog} from './components/node-editor-dialog';
import {filterNodesByType, getFilterDescription, getNodeFilter, NodeTypeFilter,} from './components/node-type-filter';
import {NodesTable} from './components/nodes-table';
import {getErrorMessage} from './components/node-utils';
import { NodeEditorDialog } from './components/node-editor-dialog';
import {
filterNodesByType,
getFilterDescription,
getNodeFilter,
NodeTypeFilter,
} from './components/node-type-filter';
import { NodesTable } from './components/nodes-table';
import { getErrorMessage } from './components/node-utils';
const nodesQueryKey = ['openflare', 'nodes'];
@@ -101,68 +112,86 @@ export function NodesPageClient() {
};
return (
<div className="py-6 px-1 space-y-6">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Server className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">节点管理</h1>
<div className='py-6 px-1 space-y-6'>
<div className='flex items-center justify-between gap-3'>
<div className='flex items-center gap-2'>
<Server className='size-5 text-primary' />
<h1 className='text-2xl font-semibold tracking-tight'>节点管理</h1>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href="/apply-logs">应用记录</Link>
<div className='flex items-center gap-2'>
<Button variant='outline' size='sm' className='h-7 text-xs' asChild>
<Link href='/apply-logs'>应用记录</Link>
</Button>
<Button variant="secondary" size="sm" className="h-7 text-xs" onClick={handleCreate}>
<Plus className="size-3.5 mr-1" />
<Button
variant='secondary'
size='sm'
className='h-7 text-xs'
onClick={handleCreate}
>
<Plus className='size-3.5 mr-1' />
新增节点
</Button>
</div>
</div>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<div className="flex items-center justify-between gap-3">
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<div className='flex items-center justify-between gap-3'>
<div>
<CardTitle className="text-base font-semibold">节点列表</CardTitle>
<CardDescription>{getFilterDescription(nodeFilter)}</CardDescription>
<CardTitle className='text-base font-semibold'>
节点列表
</CardTitle>
<CardDescription>
{getFilterDescription(nodeFilter)}
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={handleRefresh}
disabled={nodesQuery.isFetching}
>
{nodesQuery.isFetching ? (
<Loader2 className="size-3.5 mr-1 animate-spin" />
<Loader2 className='size-3.5 mr-1 animate-spin' />
) : (
<RefreshCw className="size-3.5 mr-1" />
<RefreshCw className='size-3.5 mr-1' />
)}
立即刷新
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardContent className='space-y-4'>
<NodeTypeFilter />
{nodesQuery.isLoading ? (
<LoadingStateWithBorder icon={Server} description="加载节点列表中..." />
<LoadingStateWithBorder
icon={Server}
description='加载节点列表中...'
/>
) : nodesQuery.isError ? (
<div className="p-8 border border-dashed rounded-lg">
<div className='p-8 border border-dashed rounded-lg'>
<ErrorInline
message={getErrorMessage(nodesQuery.error)}
onRetry={handleRefresh}
className="justify-center"
className='justify-center'
/>
</div>
) : filteredNodes.length === 0 ? (
<EmptyStateWithBorder
icon={Server}
description={nodes.length === 0 ? '暂无节点,请先创建一个节点。' : '当前筛选无结果'}
description={
nodes.length === 0
? '暂无节点,请先创建一个节点。'
: '当前筛选无结果'
}
/>
) : (
<NodesTable
nodes={filteredNodes}
deletingId={deleteMutation.isPending ? deleteTarget?.id ?? null : null}
deletingId={
deleteMutation.isPending ? (deleteTarget?.id ?? null) : null
}
onEdit={handleEdit}
onDelete={setDeleteTarget}
/>
@@ -183,20 +212,28 @@ export function NodesPageClient() {
}}
/>
<AlertDialog open={Boolean(deleteTarget)} onOpenChange={(open) => !open && setDeleteTarget(null)}>
<AlertDialog
open={Boolean(deleteTarget)}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
<AlertDialogDescription>
确认删除节点「{deleteTarget?.name}」吗?删除后该节点需要重新创建并重新接入。
确认删除节点「{deleteTarget?.name}
」吗?删除后该节点需要重新创建并重新接入。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>取消</AlertDialogCancel>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-white hover:bg-destructive/90"
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
onClick={() =>
deleteTarget && deleteMutation.mutate(deleteTarget.id)
}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
</AlertDialogAction>
+11 -11
View File
@@ -1,20 +1,20 @@
import {Suspense} from 'react';
import { Suspense } from 'react';
import {Skeleton} from '@/components/ui/skeleton';
import { Skeleton } from '@/components/ui/skeleton';
import {NodesPageClient} from './page-client';
import { NodesPageClient } from './page-client';
function NodesPageFallback() {
return (
<div className="py-6 px-1 space-y-6">
<div className="flex items-center justify-between gap-3">
<Skeleton className="h-8 w-40" />
<div className="flex gap-2">
<Skeleton className="h-7 w-20" />
<Skeleton className="h-7 w-24" />
<div className='py-6 px-1 space-y-6'>
<div className='flex items-center justify-between gap-3'>
<Skeleton className='h-8 w-40' />
<div className='flex gap-2'>
<Skeleton className='h-7 w-20' />
<Skeleton className='h-7 w-24' />
</div>
</div>
<Skeleton className="h-96 w-full" />
<Skeleton className='h-96 w-full' />
</div>
);
}
@@ -25,4 +25,4 @@ export default function NodesPage() {
<NodesPageClient />
</Suspense>
);
}
}