feat(frontend): 控制台 next-intl 中英双语

接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
ryan
2026-08-16 16:49:16 +08:00
parent 64fbaa7ef1
commit 5a8722ff07
219 changed files with 21437 additions and 5212 deletions
@@ -3,6 +3,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { ExternalLink, Loader2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
@@ -40,6 +41,7 @@ export function AgentUpdateDialog({
channel: ReleaseChannel,
) => Promise<void>;
}) {
const t = useTranslations('nodes');
const [channel, setChannel] = useState<ReleaseChannel>('stable');
const releaseQuery = useQuery({
@@ -54,36 +56,42 @@ export function AgentUpdateDialog({
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className='sm:max-w-xl'>
<DialogHeader>
<DialogTitle>Agent 升级</DialogTitle>
<DialogDescription>
检查 GitHub 发布并向节点下发升级指令,节点将在下一次心跳后执行。
</DialogDescription>
<DialogTitle>{t('update.title')}</DialogTitle>
<DialogDescription>{t('update.description')}</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='text-xs text-muted-foreground'>
{t('update.currentVersion')}
</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='text-xs text-muted-foreground'>
{t('update.channel')}
</p>
<p className='mt-1 text-sm font-medium'>
{channel === 'preview' ? '预览版' : '正式版'}
{channel === 'preview'
? t('channel.preview')
: t('channel.stable')}
</p>
</div>
<div className='rounded-lg border p-3'>
<p className='text-xs text-muted-foreground'>更新状态</p>
<p className='text-xs text-muted-foreground'>
{t('update.updateStatus')}
</p>
<div className='mt-1'>
<NodeStatusBadge
label={
node.update_requested
? node.update_channel === 'preview'
? '等待预览更新'
: '等待更新'
: '未下发'
? t('update.waitingPreview')
: t('update.waitingUpdate')
: t('update.notRequested')
}
tone={node.update_requested ? 'warning' : 'info'}
/>
@@ -94,45 +102,57 @@ export function AgentUpdateDialog({
{releaseQuery.isFetching ? (
<div className='flex items-center justify-center py-8 text-sm text-muted-foreground'>
<Loader2 className='size-4 mr-2 animate-spin' />
检查发布版本中...
{t('update.checking')}
</div>
) : releaseQuery.isError ? (
<p className='text-sm text-destructive'>
{getErrorMessage(releaseQuery.error)}
{getErrorMessage(releaseQuery.error, t('requestFailed'))}
</p>
) : release ? (
<div className='space-y-3 rounded-lg border p-4'>
<div className='flex flex-wrap items-center gap-2'>
<NodeStatusBadge
label={
release.has_update ? '发现可升级版本' : '当前已是最新版本'
release.has_update
? t('update.hasUpdate')
: t('update.upToDate')
}
tone={release.has_update ? 'warning' : 'success'}
/>
{release.prerelease ? (
<NodeStatusBadge label='Preview 发布' tone='warning' />
<NodeStatusBadge
label={t('update.previewRelease')}
tone='warning'
/>
) : (
<NodeStatusBadge label='正式发布' tone='info' />
<NodeStatusBadge
label={t('update.stableRelease')}
tone='info'
/>
)}
</div>
<div className='grid gap-3 sm:grid-cols-2 text-sm'>
<div>
<p className='text-xs text-muted-foreground'>目标版本</p>
<p className='text-xs text-muted-foreground'>
{t('update.targetVersion')}
</p>
<p className='mt-1 font-medium'>
{release.tag_name || '未找到'}
{release.tag_name || t('update.notFound')}
</p>
</div>
<div>
<p className='text-xs text-muted-foreground'>发布时间</p>
<p className='text-xs text-muted-foreground'>
{t('update.publishedAt')}
</p>
<p className='mt-1'>
{release.published_at
? `${formatRelativeTime(release.published_at)} · ${formatDateTime(release.published_at)}`
? `${formatRelativeTime(release.published_at, t)} · ${formatDateTime(release.published_at)}`
: '—'}
</p>
</div>
</div>
<p className='text-sm text-muted-foreground whitespace-pre-wrap'>
{release.body || '暂无更新说明'}
{release.body || t('update.noNotes')}
</p>
{release.html_url ? (
<a
@@ -141,7 +161,7 @@ export function AgentUpdateDialog({
rel='noreferrer'
className='inline-flex items-center text-sm text-primary hover:underline'
>
查看发布详情
{t('update.viewRelease')}
<ExternalLink className='size-3 ml-1' />
</a>
) : null}
@@ -156,7 +176,7 @@ export function AgentUpdateDialog({
disabled={submitting || releaseQuery.isFetching}
onClick={() => setChannel('stable')}
>
检查正式版
{t('update.checkStable')}
</Button>
<Button
type='button'
@@ -164,7 +184,7 @@ export function AgentUpdateDialog({
disabled={submitting || releaseQuery.isFetching}
onClick={() => setChannel('preview')}
>
检查预览版
{t('update.checkPreview')}
</Button>
<Button
type='button'
@@ -177,10 +197,10 @@ export function AgentUpdateDialog({
onClick={() => void onConfirm(release, channel)}
>
{submitting
? '下发中...'
? t('dispatching')
: channel === 'preview'
? '升级到预览版'
: '升级到正式版'}
? t('update.upgradePreview')
: t('update.upgradeStable')}
</Button>
</DialogFooter>
</DialogContent>
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import {
Card,
@@ -26,18 +28,23 @@ function formatDiskRate(bytesPerSecond: number) {
export function DiskIOTrendChart({
points,
title = '24 小时磁盘 IO 趋势',
description = '按小时展示磁盘读写速率(B/s),辅助判断日志放大、缓存抖动或磁盘压力。',
title,
description,
}: {
points: DiskIOTrendPoint[];
title?: string;
description?: string;
}) {
const t = useTranslations('nodes.diskIo');
return (
<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 ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
@@ -45,11 +52,11 @@ export function DiskIOTrendChart({
formatTrendHour(point.bucket_started_at),
)}
summaryScope='average'
summaryHint='近 24 小时 · 平均速率'
summaryHint={t('summaryHint')}
yAxisValueFormatter={formatDiskRate}
series={[
{
label: '磁盘读',
label: t('read'),
color: '#a78bfa',
fillColor: 'rgba(167, 139, 250, 0.14)',
variant: 'area',
@@ -59,7 +66,7 @@ export function DiskIOTrendChart({
valueFormatter: formatDiskRate,
},
{
label: '磁盘写',
label: t('write'),
color: '#fb7185',
values: points.map((point) =>
diskBytesToRate(point.disk_write_bytes),
@@ -1,16 +1,20 @@
'use client';
import { useTranslations } from 'next-intl';
import { Progress } from '@/components/ui/progress';
import { formatCompactNumber } from '../../components/dashboard/dashboard-utils';
export function DistributionList({
items,
emptyMessage = '暂无分布数据',
emptyMessage,
}: {
items: Array<{ label: string; value: number }>;
emptyMessage?: string;
}) {
const t = useTranslations('nodes.obs');
const resolvedEmpty = emptyMessage ?? t('distEmpty');
const sortedItems = [...items]
.sort((left, right) => right.value - left.value)
.slice(0, 8);
@@ -19,7 +23,7 @@ export function DistributionList({
if (sortedItems.length === 0) {
return (
<div className='flex min-h-[180px] items-center justify-center text-xs text-muted-foreground'>
{emptyMessage}
{resolvedEmpty}
</div>
);
}
@@ -32,7 +36,7 @@ export function DistributionList({
<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 || '未知'}
{item.label || t('unknownLabel')}
</span>
<span className='shrink-0 font-mono tabular-nums text-muted-foreground'>
{formatCompactNumber(item.value)}
@@ -15,6 +15,7 @@ import {
Trash2,
Upload,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
@@ -62,6 +63,8 @@ import {
const nodesQueryKey = ['openflare', 'nodes'];
export function EdgeNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter();
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
@@ -72,29 +75,29 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload),
onSuccess: async () => {
toast.success('节点已更新');
toast.success(t('edge.toastUpdated'));
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => {
toast.success(`已向节点 ${updated.name} 下发强制同步指令`);
toast.success(t('edge.toastForceSync', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const restartMutation = useMutation({
mutationFn: () => NodeService.requestOpenrestyRestart(node.id),
onSuccess: async (updated) => {
toast.success(`已向节点 ${updated.name} 下发 OpenResty 重启指令`);
toast.success(t('edge.toastRestart', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const upgradeMutation = useMutation({
@@ -114,22 +117,28 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
}),
onSuccess: async (updated) => {
toast.success(
`已向节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`,
t('edge.toastUpgrade', {
name: updated.name,
channel:
updated.update_channel === 'preview'
? t('channel.preview')
: t('channel.stable'),
}),
);
setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => {
toast.success('节点已删除');
toast.success(t('edge.toastDeleted'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes');
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const handleRefresh = () => {
@@ -149,7 +158,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
className='h-8'
onClick={() => setEditorOpen(true)}
>
编辑
{t('edit')}
</Button>
<Button
variant='outline'
@@ -158,7 +167,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
onClick={handleRefresh}
>
<RefreshCw className='size-3.5 mr-1.5' />
刷新
{t('refresh')}
</Button>
<Button
variant='outline'
@@ -168,7 +177,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className='size-3.5 mr-1.5' />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
{forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
</Button>
<Button
variant='outline'
@@ -177,7 +186,9 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
disabled={restartMutation.isPending}
onClick={() => restartMutation.mutate()}
>
{restartMutation.isPending ? '下发中...' : '重启 OpenResty'}
{restartMutation.isPending
? t('dispatching')
: t('edge.restartOpenresty')}
</Button>
<Button
variant='secondary'
@@ -186,7 +197,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setUpgradeOpen(true)}
>
<Upload className='size-3.5 mr-1.5' />
{node.update_requested ? '查看升级' : '升级 Agent'}
{node.update_requested ? t('viewUpgrade') : t('edge.upgrade')}
</Button>
<Button
variant='outline'
@@ -195,7 +206,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setDeleteOpen(true)}
>
<Trash2 className='size-3.5 mr-1.5' />
删除
{t('delete')}
</Button>
</>
);
@@ -206,74 +217,74 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<div className='grid gap-6 xl:grid-cols-2'>
<NodeSectionCard
title='运行状态'
description='节点在线情况与 OpenResty 健康'
title={t('edge.runStatus')}
description={t('edge.runStatusDesc')}
>
<div className='divide-y'>
<NodeInfoRow label='运行状态'>
<NodeInfoRow label={t('edge.runStatus')}>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
</NodeInfoRow>
<NodeInfoRow label='OpenResty 健康'>
<NodeInfoRow label={t('edge.openrestyHealth')}>
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
label={getOpenrestyStatusLabel(node.openresty_status, t)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
</NodeInfoRow>
<NodeInfoRow label='最近心跳'>
<NodeInfoRow label={t('edge.lastSeen')}>
{isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
: t('na')}
</NodeInfoRow>
<NodeInfoRow label='IP 地址'>
<NodeInfoRow label={t('edge.ip')}>
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
{node.ip_manual_override ? t('lockedSuffix') : ''}
</NodeInfoRow>
<NodeInfoRow label='地图点位'>
{node.geo_name || '未配置'}
<NodeInfoRow label={t('edge.geo')}>
{node.geo_name || t('notConfigured')}
</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard
title='配置同步'
description='版本追平与应用结果摘要'
title={t('edge.syncTitle')}
description={t('edge.syncDesc')}
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' />
应用记录
{t('applyLogs')}
</Link>
</Button>
}
>
<div className='divide-y'>
<NodeInfoRow label='当前配置版本'>
{node.current_version || '未应用'}
<NodeInfoRow label={t('edge.currentVersion')}>
{node.current_version || t('notApplied')}
</NodeInfoRow>
<NodeInfoRow label='最近应用'>
<NodeInfoRow label={t('edge.latestApply')}>
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)}
/>
</NodeInfoRow>
<NodeInfoRow label='最近应用时间'>
<NodeInfoRow label={t('edge.latestApplyAt')}>
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无'}
? `${formatRelativeTime(node.latest_apply_at, t)} · ${formatDateTime(node.latest_apply_at)}`
: t('na')}
</NodeInfoRow>
<NodeInfoRow label='Agent 版本'>
<NodeInfoRow label={t('edge.agentVersion')}>
{node.version || 'unknown'}
</NodeInfoRow>
<NodeInfoRow label='Nginx 版本'>
<NodeInfoRow label={t('edge.nginxVersion')}>
{node.ext_version || 'unknown'}
</NodeInfoRow>
<NodeInfoRow label='自动更新'>
{node.auto_update_enabled ? '已启用' : '手动'}
<NodeInfoRow label={t('edge.autoUpdate')}>
{node.auto_update_enabled ? t('enabled') : t('manual')}
</NodeInfoRow>
</div>
</NodeSectionCard>
@@ -282,22 +293,22 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<InstallCommand node={node} variant='edge' />
<NodeSectionCard
title='运行消息'
description='OpenResty 与 Agent 上报的状态说明'
title={t('edge.runtimeMessages')}
description={t('edge.runtimeMessagesDesc')}
>
<div className='space-y-4 text-sm'>
<div>
<p className='mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground'>
OpenResty 状态消息
{t('edge.openrestyMessage')}
</p>
<p className='rounded-lg border bg-muted/30 px-3 py-3 leading-6 break-words whitespace-pre-wrap'>
{node.openresty_message || '当前未上报额外错误。'}
{node.openresty_message || t('edge.noExtraError')}
</p>
</div>
{node.restart_openresty_requested ? (
<div className='flex items-center gap-2 text-sm text-amber-700 dark:text-amber-300'>
<Activity className='size-4' />
已等待节点在下一次心跳后执行 OpenResty 重启。
{t('edge.restartPending')}
</div>
) : null}
</div>
@@ -313,28 +324,32 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
typeTone='info'
statusBadges={[
{
label: getNodeStatusLabel(node.status),
label: getNodeStatusLabel(node.status, t),
tone: getNodeStatusTone(node.status),
},
{
label: getOpenrestyStatusLabel(node.openresty_status),
label: getOpenrestyStatusLabel(node.openresty_status, t),
tone: getOpenrestyStatusTone(node.openresty_status),
},
]}
actions={headerActions}
kpis={[
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
{ label: 'Agent 版本', value: node.version || 'unknown', icon: Cpu },
{ label: t('kpiNodeId'), value: node.node_id, icon: Fingerprint },
{
label: '当前配置',
value: node.current_version || '未应用',
label: t('edge.kpiAgent'),
value: node.version || 'unknown',
icon: Cpu,
},
{
label: t('kpiCurrentConfig'),
value: node.current_version || t('notApplied'),
icon: Package,
},
{
label: '最近心跳',
label: t('kpiLastSeen'),
value: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at)
: '暂无',
? formatRelativeTime(node.last_seen_at, t)
: t('na'),
icon: Activity,
},
]}
@@ -343,7 +358,7 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
nodeId={node.id}
node={node}
variant='edge'
connectionHint='OpenResty 当前连接'
connectionHint={t('edge.connectionHint')}
/>
}
manage={manageTab}
@@ -373,22 +388,21 @@ export function EdgeNodeDetail({ node }: { node: NodeItem }) {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除节点「{node.name}
」吗?删除后该节点需要重新创建并重新接入。
{t('deleteDesc', { name: node.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -3,6 +3,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Copy } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -28,38 +29,6 @@ import {
type InstallVariant = 'edge' | 'relay' | 'tunnel';
const variantMeta: Record<
InstallVariant,
{
title: string;
description: string;
tokenLabel: string;
scriptLabel?: string;
dockerLabel: string;
}
> = {
edge: {
title: '边缘节点部署',
description: '使用 Agent Token 将边缘节点接入控制端。',
tokenLabel: 'Agent Token',
dockerLabel: 'Docker 容器部署',
},
relay: {
title: '中继部署命令',
description: '使用 Discovery Token 将 frps 中继节点接入控制端。',
tokenLabel: 'Discovery Token',
scriptLabel: '一键脚本部署 (Linux / macOS)',
dockerLabel: 'Docker 容器部署',
},
tunnel: {
title: '隧道部署命令',
description: '使用 Tunnel Token 将 openflared 客户端接入控制端。',
tokenLabel: 'Tunnel Token',
scriptLabel: '一键脚本部署 (Linux / macOS)',
dockerLabel: 'Docker 容器部署',
},
};
export function InstallCommand({
node,
variant,
@@ -67,7 +36,40 @@ export function InstallCommand({
node: NodeItem;
variant: InstallVariant;
}) {
const t = useTranslations('nodes.install');
const tn = useTranslations('nodes');
const [serverUrl, setServerUrl] = useState('');
const variantMeta: Record<
InstallVariant,
{
title: string;
description: string;
tokenLabel: string;
scriptLabel?: string;
dockerLabel: string;
}
> = {
edge: {
title: t('edgeTitle'),
description: t('edgeDesc'),
tokenLabel: t('agentToken'),
dockerLabel: t('dockerLabel'),
},
relay: {
title: t('relayTitle'),
description: t('relayDesc'),
tokenLabel: t('discoveryToken'),
scriptLabel: t('scriptLabel'),
dockerLabel: t('dockerLabel'),
},
tunnel: {
title: t('tunnelTitle'),
description: t('tunnelDesc'),
tokenLabel: t('tunnelToken'),
scriptLabel: t('scriptLabel'),
dockerLabel: t('dockerLabel'),
},
};
const meta = variantMeta[variant];
const statusQuery = useQuery({
@@ -123,7 +125,7 @@ export function InstallCommand({
await navigator.clipboard.writeText(value);
toast.success(message);
} catch {
toast.error('复制失败,请手动选择命令文本。');
toast.error(t('copyFailed'));
}
};
@@ -143,11 +145,11 @@ export function InstallCommand({
size='sm'
className='h-7 text-xs'
onClick={() =>
void handleCopy(scriptCommand, '部署脚本命令已复制')
void handleCopy(scriptCommand, t('copiedScript'))
}
>
<Copy className='size-3.5 mr-1' />
复制脚本
{t('copyScript')}
</Button>
) : null}
{dockerCommand ? (
@@ -156,11 +158,11 @@ export function InstallCommand({
size='sm'
className='h-7 text-xs'
onClick={() =>
void handleCopy(dockerCommand, 'Docker 部署命令已复制')
void handleCopy(dockerCommand, t('copiedDocker'))
}
>
<Copy className='size-3.5 mr-1' />
复制 Docker
{t('copyDocker')}
</Button>
) : null}
</div>
@@ -168,20 +170,20 @@ export function InstallCommand({
<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='text-xs text-muted-foreground'>{t('nodeId')}</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 || '暂无'}
{node.access_token || tn('na')}
</p>
</div>
</div>
<div className='space-y-2'>
<Label htmlFor={`server-url-${variant}`}>
Server URL(控制端地址)
{t('serverUrl')}
</Label>
<Input
id={`server-url-${variant}`}
@@ -190,17 +192,17 @@ export function InstallCommand({
placeholder='https://openflare.example.com'
/>
<p className='text-xs text-muted-foreground'>
部署脚本和容器将使用此地址连接 OpenFlare 控制端。
{t('serverUrlHint')}
</p>
</div>
{!node.access_token ? (
<p className='text-sm text-muted-foreground'>
节点尚未生成接入 Token,请稍后刷新。
{t('noToken')}
</p>
) : !normalizedServerUrl ? (
<p className='text-sm text-muted-foreground'>
请填写有效的 Server URL 以生成部署命令。
{t('needServerUrl')}
</p>
) : (
<>
@@ -1,5 +1,7 @@
'use client';
import { useTranslations } from 'next-intl';
import { TrendChart } from '@/components/data/trend-chart';
import {
Card,
@@ -17,18 +19,23 @@ import {
export function NetworkTrendChart({
points,
title = '24 小时网络趋势',
description = '按小时展示已提供/接收数据(访问日志);摘要为近 24 小时总量。',
title,
description,
}: {
points: NetworkTrendPoint[];
title?: string;
description?: string;
}) {
const t = useTranslations('nodes.networkTrend');
return (
<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 ?? t('title')}
</CardTitle>
<CardDescription className='text-xs'>
{description ?? t('description')}
</CardDescription>
</CardHeader>
<CardContent>
<TrendChart
@@ -36,11 +43,11 @@ export function NetworkTrendChart({
formatTrendHour(point.bucket_started_at),
)}
summaryScope='total'
summaryHint='近 24 小时'
summaryHint={t('summaryHint')}
yAxisValueFormatter={formatBytes}
series={[
{
label: '接收数据',
label: t('received'),
color: '#22c55e',
fillColor: 'rgba(34, 197, 94, 0.14)',
variant: 'area',
@@ -48,7 +55,7 @@ export function NetworkTrendChart({
valueFormatter: formatBytes,
},
{
label: '已提供数据',
label: t('provided'),
color: '#38bdf8',
values: points.map((point) => point.bytes_provided),
valueFormatter: formatBytes,
@@ -5,6 +5,7 @@ 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 { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -28,16 +29,7 @@ export type NodeDetailKpi = {
icon?: LucideIcon;
};
const TAB_CONFIGS: NodeDetailTabConfig[] = [
{
id: 'overview',
label: '概览',
},
{
id: 'manage',
label: '状态与部署',
},
];
const TAB_IDS: NodeDetailTabId[] = ['overview', 'manage'];
function resolveTab(value: string | null): NodeDetailTabId | null {
if (value === 'overview' || value === 'dashboard') {
@@ -70,6 +62,7 @@ export function NodeDetailShell({
manage: ReactNode;
defaultTab?: NodeDetailTabId;
}) {
const t = useTranslations('nodes');
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -110,7 +103,7 @@ export function NodeDetailShell({
className='mt-0.5 h-8 w-8 shrink-0 p-0'
asChild
>
<Link href='/nodes' aria-label='返回节点列表'>
<Link href='/nodes' aria-label={t('backToList')}>
<ArrowLeft className='size-4' />
</Link>
</Button>
@@ -162,16 +155,16 @@ export function NodeDetailShell({
variant='line'
className='h-auto w-full justify-start gap-6 bg-transparent p-0'
>
{TAB_CONFIGS.map((tab) => (
{TAB_IDS.map((tabId) => (
<TabsTrigger
key={tab.id}
value={tab.id}
key={tabId}
value={tabId}
className={cn(
'h-auto flex-none rounded-none px-0 pb-3 pt-1 text-sm font-semibold',
'data-[state=active]:text-foreground data-[state=inactive]:text-muted-foreground',
)}
>
{tab.label}
{tabId === 'overview' ? t('tabOverview') : t('tabManage')}
</TabsTrigger>
))}
</TabsList>
@@ -2,6 +2,7 @@
import { useEffect } from 'react';
import { zodResolver } from '@hookform/resolvers/zod';
import { useTranslations } from 'next-intl';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
@@ -30,49 +31,51 @@ import type {
NodeType,
} from '@/lib/services/openflare';
const nodeSchema = z
.object({
node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
name: z.string().trim().min(1, '请输入节点名称').max(255),
ip: z.string(),
ip_manual_override: z.boolean(),
auto_update_enabled: z.boolean(),
geo_manual_override: z.boolean(),
geo_name: z.string(),
geo_latitude: z.string(),
geo_longitude: z.string(),
relay_bind_port: z.string(),
relay_vhost_http_port: z.string(),
})
.superRefine((value, context) => {
if (value.ip_manual_override && !value.ip.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['ip'],
message: '锁定 IP 时必须填写节点 IP',
});
}
if (value.node_type === 'tunnel_relay') {
const bindPort = Number(value.relay_bind_port);
const vhostPort = Number(value.relay_vhost_http_port);
if (!Number.isFinite(bindPort) || bindPort <= 0) {
function buildNodeSchema(t: (key: string) => string) {
return z
.object({
node_type: z.enum(['edge_node', 'tunnel_relay', 'tunnel_client']),
name: z.string().trim().min(1, t('editor.errName')).max(255),
ip: z.string(),
ip_manual_override: z.boolean(),
auto_update_enabled: z.boolean(),
geo_manual_override: z.boolean(),
geo_name: z.string(),
geo_latitude: z.string(),
geo_longitude: z.string(),
relay_bind_port: z.string(),
relay_vhost_http_port: z.string(),
})
.superRefine((value, context) => {
if (value.ip_manual_override && !value.ip.trim()) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_bind_port'],
message: '请输入有效的绑定端口',
path: ['ip'],
message: t('editor.errLockIp'),
});
}
if (!Number.isFinite(vhostPort) || vhostPort <= 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_vhost_http_port'],
message: '请输入有效的 VHost 端口',
});
if (value.node_type === 'tunnel_relay') {
const bindPort = Number(value.relay_bind_port);
const vhostPort = Number(value.relay_vhost_http_port);
if (!Number.isFinite(bindPort) || bindPort <= 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_bind_port'],
message: t('editor.errBindPort'),
});
}
if (!Number.isFinite(vhostPort) || vhostPort <= 0) {
context.addIssue({
code: z.ZodIssueCode.custom,
path: ['relay_vhost_http_port'],
message: t('editor.errVhostPort'),
});
}
}
}
});
});
}
type NodeFormValues = z.infer<typeof nodeSchema>;
type NodeFormValues = z.infer<ReturnType<typeof buildNodeSchema>>;
const defaultForm: NodeFormValues = {
node_type: 'edge_node',
@@ -156,8 +159,10 @@ export function NodeEditorDialog({
onClose: () => void;
onSubmit: (payload: NodeMutationPayload) => Promise<void>;
}) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const form = useForm<NodeFormValues>({
resolver: zodResolver(nodeSchema),
resolver: zodResolver(buildNodeSchema(t)),
defaultValues: defaultForm,
});
@@ -179,10 +184,10 @@ export function NodeEditorDialog({
<Dialog open={open} onOpenChange={(next) => !next && onClose()}>
<DialogContent className='sm:max-w-lg'>
<DialogHeader>
<DialogTitle>{node ? '编辑节点' : '新增节点'}</DialogTitle>
<DialogDescription>
预创建节点后可在详情页查看专属 Token 与部署命令。
</DialogDescription>
<DialogTitle>
{node ? t('editor.editTitle') : t('editor.createTitle')}
</DialogTitle>
<DialogDescription>{t('editor.description')}</DialogDescription>
</DialogHeader>
<form
@@ -190,7 +195,7 @@ export function NodeEditorDialog({
className='space-y-4'
>
<div className='space-y-2'>
<Label>节点类型</Label>
<Label>{t('editor.nodeType')}</Label>
<Select
value={nodeType}
disabled={Boolean(node)}
@@ -202,18 +207,22 @@ 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'>{t('editor.typeEdge')}</SelectItem>
<SelectItem value='tunnel_relay'>
{t('editor.typeRelay')}
</SelectItem>
<SelectItem value='tunnel_client'>
{t('editor.typeTunnel')}
</SelectItem>
</SelectContent>
</Select>
</div>
<div className='space-y-2'>
<Label htmlFor='node-name'>节点名称</Label>
<Label htmlFor='node-name'>{t('editor.name')}</Label>
<Input
id='node-name'
placeholder='例如 edge-hk-01'
placeholder={t('editor.namePlaceholder')}
{...form.register('name')}
/>
{form.formState.errors.name ? (
@@ -224,10 +233,10 @@ export function NodeEditorDialog({
</div>
<div className='space-y-2'>
<Label htmlFor='node-ip'>节点 IP</Label>
<Label htmlFor='node-ip'>{t('editor.ip')}</Label>
<Input
id='node-ip'
placeholder='可选,接入后自动上报'
placeholder={t('editor.ipPlaceholder')}
{...form.register('ip')}
/>
{form.formState.errors.ip ? (
@@ -239,9 +248,9 @@ export function NodeEditorDialog({
<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-sm font-medium'>{t('editor.lockIp')}</p>
<p className='text-xs text-muted-foreground'>
启用后管理端不再自动覆盖 IP
{t('editor.lockIpDesc')}
</p>
</div>
<Switch
@@ -254,9 +263,9 @@ export function NodeEditorDialog({
<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-sm font-medium'>{t('editor.autoUpdate')}</p>
<p className='text-xs text-muted-foreground'>
启用后节点将自动拉取正式版更新
{t('editor.autoUpdateDesc')}
</p>
</div>
<Switch
@@ -270,7 +279,7 @@ export function NodeEditorDialog({
{nodeType === 'tunnel_relay' ? (
<div className='grid gap-3 sm:grid-cols-2'>
<div className='space-y-2'>
<Label htmlFor='relay-bind-port'>绑定端口</Label>
<Label htmlFor='relay-bind-port'>{t('editor.bindPort')}</Label>
<Input
id='relay-bind-port'
{...form.register('relay_bind_port')}
@@ -282,7 +291,9 @@ export function NodeEditorDialog({
) : null}
</div>
<div className='space-y-2'>
<Label htmlFor='relay-vhost-port'>VHost 端口</Label>
<Label htmlFor='relay-vhost-port'>
{t('editor.vhostPort')}
</Label>
<Input
id='relay-vhost-port'
{...form.register('relay_vhost_http_port')}
@@ -298,8 +309,10 @@ export function NodeEditorDialog({
<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'>{t('editor.manualGeo')}</p>
<p className='text-xs text-muted-foreground'>
{t('editor.manualGeoDesc')}
</p>
</div>
<Switch
checked={geoManualOverride}
@@ -312,15 +325,15 @@ export function NodeEditorDialog({
{geoManualOverride ? (
<div className='grid gap-3 sm:grid-cols-3'>
<div className='space-y-2 sm:col-span-3'>
<Label htmlFor='geo-name'>位置名称</Label>
<Label htmlFor='geo-name'>{t('editor.geoName')}</Label>
<Input id='geo-name' {...form.register('geo_name')} />
</div>
<div className='space-y-2'>
<Label htmlFor='geo-lat'>纬度</Label>
<Label htmlFor='geo-lat'>{t('editor.latitude')}</Label>
<Input id='geo-lat' {...form.register('geo_latitude')} />
</div>
<div className='space-y-2 sm:col-span-2'>
<Label htmlFor='geo-lng'>经度</Label>
<Label htmlFor='geo-lng'>{t('editor.longitude')}</Label>
<Input id='geo-lng' {...form.register('geo_longitude')} />
</div>
</div>
@@ -333,10 +346,14 @@ export function NodeEditorDialog({
onClick={onClose}
disabled={submitting}
>
取消
{tc('cancel')}
</Button>
<Button type='submit' disabled={submitting}>
{submitting ? '保存中...' : node ? '保存修改' : '新增节点'}
{submitting
? t('editor.saving')
: node
? t('editor.saveEdit')
: t('editor.createAction')}
</Button>
</DialogFooter>
</form>
@@ -3,6 +3,7 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Loader2, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
@@ -120,18 +121,19 @@ function SystemProfileCard({
profile: NodeSystemProfile;
nodeName: string;
}) {
const t = useTranslations('nodes');
return (
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-4 rounded-lg border px-4 py-4'>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
主机名
{t('obs.hostname')}
</p>
<p className='mt-2 text-sm'>{profile.hostname || nodeName}</p>
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
操作系统
{t('obs.os')}
</p>
<p className='mt-2 text-sm'>
{profile.os_name || 'unknown'}
@@ -140,7 +142,7 @@ function SystemProfileCard({
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
内核 / 架构
{t('obs.kernelArch')}
</p>
<p className='mt-2 text-sm'>
{profile.kernel_version || 'unknown'} ·{' '}
@@ -149,25 +151,27 @@ function SystemProfileCard({
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
在线时长
{t('obs.uptime')}
</p>
<p className='mt-2 text-sm'>
{formatUptime(profile.uptime_seconds, t)}
</p>
<p className='mt-2 text-sm'>{formatUptime(profile.uptime_seconds)}</p>
</div>
</div>
<div className='space-y-4 rounded-lg border px-4 py-4'>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
CPU
{t('obs.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} 核
{t('obs.cpuCores', { count: profile.cpu_cores || 0 })}
</p>
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
总内存
{t('obs.totalMemory')}
</p>
<p className='mt-2 text-sm'>
{formatBytes(profile.total_memory_bytes)}
@@ -175,7 +179,7 @@ function SystemProfileCard({
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
总存储
{t('obs.totalStorage')}
</p>
<p className='mt-2 text-sm'>
{formatBytes(profile.total_disk_bytes)}
@@ -183,7 +187,7 @@ function SystemProfileCard({
</div>
<div>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
上报时间
{t('obs.reportedAt')}
</p>
<p className='mt-2 text-sm'>
{isMeaningfulTime(profile.reported_at)
@@ -211,16 +215,15 @@ function HealthEventTimeline({
onCleanup: () => void;
cleanupPending: boolean;
}) {
const t = useTranslations('nodes');
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='flex-row items-center justify-between space-y-0'>
<div>
<CardTitle className='text-base font-semibold'>
健康事件时间线
{t('obs.eventsTitle')}
</CardTitle>
<CardDescription>
保留活动与已恢复事件,帮助判断运行状态。
</CardDescription>
<CardDescription>{t('obs.eventsDesc')}</CardDescription>
</div>
<Button
variant='outline'
@@ -230,7 +233,7 @@ function HealthEventTimeline({
onClick={onCleanup}
>
<Trash2 className='size-3.5 mr-1' />
清理日志
{t('obs.cleanupLogs')}
</Button>
</CardHeader>
<CardContent className='space-y-4'>
@@ -239,9 +242,9 @@ function HealthEventTimeline({
<div className='flex flex-wrap gap-2'>
{(
[
['all', '全部事件'],
['active', '活动中'],
['resolved', '已恢复'],
['all', t('obs.filterAll')],
['active', t('obs.filterActive')],
['resolved', t('obs.filterResolved')],
] as const
).map(([filter, label]) => (
<Button
@@ -261,7 +264,7 @@ function HealthEventTimeline({
{events.length === 0 ? (
<p className='text-sm text-muted-foreground'>
当前筛选下没有健康事件。
{t('obs.filterEmpty')}
</p>
) : null}
@@ -276,28 +279,32 @@ function HealthEventTimeline({
tone={getHealthEventTone(event)}
/>
<NodeStatusBadge
label={event.status === 'active' ? '活动中' : '已恢复'}
label={
event.status === 'active'
? t('obs.filterActive')
: t('obs.filterResolved')
}
tone={event.status === 'active' ? 'warning' : 'success'}
/>
</div>
<p className='mt-2 text-sm text-muted-foreground'>
{event.message || '暂无详细消息'}
{event.message || t('obs.noMessage')}
</p>
<div className='mt-2 grid gap-1 text-xs text-muted-foreground md:grid-cols-3'>
<p>
首次触发:
{t('obs.firstTriggered')}
{isMeaningfulTime(event.first_triggered_at)
? ` ${formatDateTime(event.first_triggered_at)}`
: ' —'}
</p>
<p>
最近触发:
{t('obs.lastTriggered')}
{isMeaningfulTime(event.last_triggered_at)
? ` ${formatDateTime(event.last_triggered_at)}`
: ' —'}
</p>
<p>
恢复时间:
{t('obs.resolvedAt')}
{isMeaningfulTime(event.resolved_at) && event.resolved_at
? ` ${formatDateTime(event.resolved_at)}`
: ' —'}
@@ -308,7 +315,7 @@ function HealthEventTimeline({
</>
) : (
<p className='text-sm text-muted-foreground'>
节点当前还没有上报健康事件记录。
{t('obs.noEvents')}
</p>
)}
</CardContent>
@@ -320,13 +327,16 @@ export function NodeObservability({
nodeId,
node,
variant = 'edge',
connectionHint = 'OpenResty 当前连接',
connectionHint,
}: {
nodeId: number;
node?: NodeItem;
variant?: NodeObservabilityVariant;
connectionHint?: string;
}) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const resolvedConnectionHint = connectionHint ?? t('edge.connectionHint');
const queryClient = useQueryClient();
const [healthEventFilter, setHealthEventFilter] =
useState<HealthEventFilter>('all');
@@ -344,8 +354,8 @@ export function NodeObservability({
onSuccess: async (result) => {
toast.success(
result.deleted_count > 0
? `已清理 ${result.deleted_count} 条健康事件日志`
: '当前没有可清理的健康事件日志',
? t('obs.cleaned', { count: result.deleted_count })
: t('obs.nothingToClean'),
);
setCleanupOpen(false);
await Promise.all([
@@ -357,7 +367,7 @@ export function NodeObservability({
}),
]);
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const observability = observabilityQuery.data ?? null;
@@ -432,7 +442,7 @@ export function NodeObservability({
<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' />
加载运行观测数据中...
{t('obs.loading')}
</CardContent>
</Card>
);
@@ -443,7 +453,7 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'>
<CardContent className='py-6'>
<p className='text-sm text-destructive'>
{getErrorMessage(observabilityQuery.error)}
{getErrorMessage(observabilityQuery.error, t('requestFailed'))}
</p>
</CardContent>
</Card>
@@ -455,20 +465,20 @@ export function NodeObservability({
<div className='space-y-6'>
<div className='grid gap-4 md:grid-cols-3'>
<SummaryStat
label='运行诊断'
label={t('obs.diag')}
value={
activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常`
: '运行稳定'
? t('obs.activeIssues', { count: activeHealthEvents.length })
: t('obs.stable')
}
hint={
latestHealthEvent
? getHealthEventLabel(latestHealthEvent)
: '当前没有活动中的健康事件'
: t('obs.noActiveEvents')
}
/>
<SummaryStat
label='系统核心'
label={t('obs.systemCore')}
value={profile?.hostname || '—'}
hint={
profile
@@ -477,9 +487,9 @@ export function NodeObservability({
}
/>
<SummaryStat
label='在线时长'
value={formatUptime(profile?.uptime_seconds)}
hint='来自节点系统画像上报'
label={t('obs.uptime')}
value={formatUptime(profile?.uptime_seconds, t)}
hint={t('obs.uptimeFromProfile')}
/>
</div>
@@ -487,16 +497,16 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>
系统画像
{t('obs.profileTitle')}
</CardTitle>
<CardDescription>节点上报的主机与硬件信息</CardDescription>
<CardDescription>{t('obs.profileDesc')}</CardDescription>
</CardHeader>
<CardContent>
{profile ? (
<SystemProfileCard profile={profile} nodeName={nodeName} />
) : (
<p className='text-sm text-muted-foreground'>
节点已接入,但尚未上报完整系统画像。
{t('obs.noProfile')}
</p>
)}
</CardContent>
@@ -505,45 +515,50 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>
实时资源快照
{t('obs.snapshotTitle')}
</CardTitle>
<CardDescription>最近一次 metrics 上报</CardDescription>
<CardDescription>{t('obs.snapshotDesc')}</CardDescription>
</CardHeader>
<CardContent>
{latestMetric ? (
<div className='grid gap-3 sm:grid-cols-2'>
<MetricBar
label='CPU'
label={t('obs.cpu')}
value={formatPercent(latestMetric.cpu_usage_percent)}
progress={latestMetric.cpu_usage_percent}
hint={
isMeaningfulTime(latestMetric.captured_at)
? `快照 ${formatRelativeTime(latestMetric.captured_at)}`
? t('obs.snapshotAgo', {
time: formatRelativeTime(
latestMetric.captured_at,
t,
),
})
: undefined
}
/>
<MetricBar
label='内存'
label={t('obs.memory')}
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
progress={memoryUsageRatio}
/>
<MetricBar
label='存储'
label={t('obs.storage')}
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
progress={storageUsageRatio}
/>
<MetricBar
label='连接数'
label={t('obs.connections')}
value={formatMetricCount(
latestMetric.openresty_connections,
)}
progress={null}
hint={connectionHint}
hint={resolvedConnectionHint}
/>
</div>
) : (
<p className='text-sm text-muted-foreground'>
节点已接入,但尚未上报资源快照。
{t('obs.noSnapshot')}
</p>
)}
</CardContent>
@@ -562,21 +577,23 @@ export function NodeObservability({
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清理健康事件日志</AlertDialogTitle>
<AlertDialogTitle>{t('obs.cleanupTitle')}</AlertDialogTitle>
<AlertDialogDescription>
该操作会删除此节点在控制端记录的所有健康诊断事件历史,不会影响后续新事件上报。
{t('obs.cleanupDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleanupMutation.isPending}>
取消
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={cleanupMutation.isPending}
onClick={() => cleanupMutation.mutate()}
>
{cleanupMutation.isPending ? '清理中...' : '确认清理'}
{cleanupMutation.isPending
? t('obs.cleaning')
: t('obs.confirmCleanup')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -589,43 +606,57 @@ export function NodeObservability({
<div className='space-y-6'>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<SummaryStat
label='运行诊断'
label={t('obs.diag')}
value={
activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常`
: '运行稳定'
? t('obs.activeIssues', { count: activeHealthEvents.length })
: t('obs.stable')
}
hint={
latestHealthEvent
? getHealthEventLabel(latestHealthEvent)
: '当前没有活动中的健康事件'
: t('obs.noActiveEvents')
}
/>
<SummaryStat
label='查询窗口请求'
label={t('obs.windowRequests')}
value={formatMetricCount(trafficSummary?.request_count)}
hint={
trafficSummary
? `查询窗口独立访客 ${formatMetricCount(trafficSummary.unique_visitor_count)} · 错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
: '当前没有可展示的请求窗口摘要'
? t('obs.windowVisitorsHint', {
visitors: formatMetricCount(
trafficSummary.unique_visitor_count,
),
rate: trafficSummary.error_rate_percent.toFixed(1),
})
: t('obs.noWindowSummary')
}
/>
<SummaryStat
label='容量压力'
value={healthSummary?.has_capacity_risk ? '需要关注' : '正常范围'}
label={t('obs.capacityPressure')}
value={
healthSummary?.has_capacity_risk
? t('obs.needsAttention')
: t('obs.normalRange')
}
hint={
latestMetric
? `CPU ${formatPercent(latestMetric.cpu_usage_percent)} · 存储 ${formatPercent(storageUsageRatio)}`
: '当前没有资源快照'
? t('obs.cpuStorage', {
cpu: formatPercent(latestMetric.cpu_usage_percent),
storage: formatPercent(storageUsageRatio),
})
: t('obs.noResourceSnapshot')
}
/>
<SummaryStat
label='来源信号'
label={t('obs.sourceSignal')}
value={topSourceCountry?.key ?? '—'}
hint={
topSourceCountry
? `${topSourceCountry.value.toLocaleString('zh-CN')} 次请求`
: '当前没有来源分布数据'
? t('obs.requestTimes', {
count: topSourceCountry.value.toLocaleString('zh-CN'),
})
: t('obs.noSourceDist')
}
/>
</div>
@@ -633,14 +664,16 @@ export function NodeObservability({
<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'>
{t('obs.systemInfo')}
</CardTitle>
</CardHeader>
<CardContent>
{profile ? (
<SystemProfileCard profile={profile} nodeName={nodeName} />
) : (
<p className='text-sm text-muted-foreground'>
节点已经接入,但还没有上报完整系统画像。
{t('obs.noProfileLong')}
</p>
)}
</CardContent>
@@ -648,41 +681,45 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>实时资源</CardTitle>
<CardTitle className='text-base font-semibold'>
{t('obs.realtimeResources')}
</CardTitle>
</CardHeader>
<CardContent>
{latestMetric ? (
<div className='grid gap-3 sm:grid-cols-2'>
<MetricBar
label='CPU'
label={t('obs.cpu')}
value={formatPercent(latestMetric.cpu_usage_percent)}
progress={latestMetric.cpu_usage_percent}
hint={
isMeaningfulTime(latestMetric.captured_at)
? `快照 ${formatRelativeTime(latestMetric.captured_at)}`
? t('obs.snapshotAgo', {
time: formatRelativeTime(latestMetric.captured_at, t),
})
: undefined
}
/>
<MetricBar
label='内存'
label={t('obs.memory')}
value={`${formatBytes(latestMetric.memory_used_bytes)} / ${formatBytes(latestMetric.memory_total_bytes)}`}
progress={memoryUsageRatio}
/>
<MetricBar
label='存储'
label={t('obs.storage')}
value={`${formatBytes(latestMetric.storage_used_bytes)} / ${formatBytes(latestMetric.storage_total_bytes)}`}
progress={storageUsageRatio}
/>
<MetricBar
label='连接数'
label={t('obs.connections')}
value={formatMetricCount(latestMetric.openresty_connections)}
progress={null}
hint={connectionHint}
hint={resolvedConnectionHint}
/>
</div>
) : (
<p className='text-sm text-muted-foreground'>
节点已经接入,但还没有上报资源快照。
{t('obs.noSnapshotLong')}
</p>
)}
</CardContent>
@@ -690,7 +727,9 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>网络流量</CardTitle>
<CardTitle className='text-base font-semibold'>
{t('obs.networkTraffic')}
</CardTitle>
</CardHeader>
<CardContent className='space-y-4'>
{latestMetric ? (
@@ -698,18 +737,20 @@ export function NodeObservability({
{node ? (
<div className='flex flex-wrap gap-2'>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
label={getOpenrestyStatusLabel(node.openresty_status, t)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
<NodeStatusBadge
label={
activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常`
: '无活动异常'
? t('obs.activeIssues', {
count: activeHealthEvents.length,
})
: t('obs.noActiveIssue')
}
tone={activeHealthEvents.length ? 'warning' : 'success'}
/>
@@ -719,16 +760,14 @@ export function NodeObservability({
<div className='grid gap-3 sm:grid-cols-2'>
<div className='rounded-lg border px-3 py-3 sm:col-span-2'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
OpenResty 连接
{t('obs.openrestyConn')}
</p>
<div className='mt-3 space-y-2 text-sm text-muted-foreground'>
<p>
当前连接:
{t('obs.currentConn')}
{latestMetric.openresty_connections ?? 0}
</p>
<p className='text-xs'>
业务吞吐请看访问日志趋势(已提供/接收数据)
</p>
<p className='text-xs'>{t('obs.throughputHint')}</p>
</div>
</div>
</div>
@@ -736,35 +775,42 @@ export function NodeObservability({
<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'>
查询窗口独立访客
{t('obs.windowVisitors')}
</p>
<p className='mt-3 text-2xl font-semibold'>
{formatMetricCount(trafficSummary?.unique_visitor_count)}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{trafficSummary
? `请求 ${formatMetricCount(trafficSummary.request_count)} · 估算 QPS ${trafficSummary.estimated_qps.toFixed(2)}`
: '暂无窗口流量摘要'}
? t('obs.reqQps', {
requests: formatMetricCount(
trafficSummary.request_count,
),
qps: trafficSummary.estimated_qps.toFixed(2),
})
: t('obs.noTrafficSummary')}
</p>
</div>
<div className='rounded-lg border px-3 py-3'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
查询窗口错误
{t('obs.windowErrors')}
</p>
<p className='mt-3 text-2xl font-semibold'>
{formatMetricCount(trafficSummary?.error_count)}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{trafficSummary
? `错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%`
: '暂无错误率摘要'}
? t('obs.errorRate', {
rate: trafficSummary.error_rate_percent.toFixed(1),
})
: t('obs.noErrorSummary')}
</p>
</div>
</div>
</>
) : (
<p className='text-sm text-muted-foreground'>
节点已经接入,但还没有上报网络流量相关快照。
{t('obs.noNetworkSnapshot')}
</p>
)}
</CardContent>
@@ -774,11 +820,11 @@ export function NodeObservability({
<div className='grid gap-6 xl:grid-cols-2'>
<TrafficTrendChart
points={trends?.traffic_24h ?? []}
description='按小时聚合该节点的请求量和错误量。'
description={t('obs.trafficTrendDesc')}
/>
<CapacityTrendChart
points={trends?.capacity_24h ?? []}
description='观察该节点 CPU 与内存使用率在 24 小时内的变化。'
description={t('obs.capacityTrendDesc')}
/>
</div>
@@ -791,50 +837,47 @@ export function NodeObservability({
<Card className='border-dashed shadow-none'>
<CardHeader>
<CardTitle className='text-base font-semibold'>
请求结构分布
{t('obs.structureTitle')}
</CardTitle>
<CardDescription>
聚合最近 24
小时窗口上报,帮助判断错误集中在哪些状态码、流量集中在哪些域名。
</CardDescription>
<CardDescription>{t('obs.structureDesc')}</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'>
主状态码
{t('obs.mainStatus')}
</p>
<p className='mt-3 text-2xl font-semibold'>
{dominantStatusCode?.label ?? '—'}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
{dominantStatusCode
? `${dominantStatusCode.value} 次`
: '暂无状态码分布'}
? t('obs.times', { count: dominantStatusCode.value })
: t('obs.noStatusDist')}
</p>
</div>
<div className='rounded-lg border px-4 py-4'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
Top Domain
{t('obs.topDomain')}
</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} 次`
: '暂无域名分布'}
? t('obs.times', { count: dominantDomain.value })
: t('obs.noDomainDist')}
</p>
</div>
<div className='rounded-lg border px-4 py-4'>
<p className='text-xs text-muted-foreground uppercase tracking-wide'>
已恢复事件
{t('obs.resolvedEvents')}
</p>
<p className='mt-3 text-2xl font-semibold'>
{resolvedHealthEvents.length}
</p>
<p className='mt-2 text-sm text-muted-foreground'>
最近 24 小时已恢复健康事件
{t('obs.resolvedHint')}
</p>
</div>
</div>
@@ -842,20 +885,20 @@ export function NodeObservability({
<div className='grid gap-6 xl:grid-cols-2'>
<div>
<p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'>
状态码分布
{t('obs.statusDist')}
</p>
<DistributionList
items={statusCodeDistribution}
emptyMessage='暂无状态码分布'
emptyMessage={t('obs.noStatusDist')}
/>
</div>
<div>
<p className='mb-4 text-xs text-muted-foreground uppercase tracking-wide'>
Top Domain
{t('obs.topDomain')}
</p>
<DistributionList
items={topDomains}
emptyMessage='暂无域名分布'
emptyMessage={t('obs.noDomainDist')}
/>
</div>
</div>
@@ -875,21 +918,23 @@ export function NodeObservability({
<AlertDialog open={cleanupOpen} onOpenChange={setCleanupOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>清理健康事件日志</AlertDialogTitle>
<AlertDialogTitle>{t('obs.cleanupTitle')}</AlertDialogTitle>
<AlertDialogDescription>
该操作会删除此节点在控制端记录的所有健康诊断事件历史,不会影响后续新事件上报。
{t('obs.cleanupDesc')}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={cleanupMutation.isPending}>
取消
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={cleanupMutation.isPending}
onClick={() => cleanupMutation.mutate()}
>
{cleanupMutation.isPending ? '清理中...' : '确认清理'}
{cleanupMutation.isPending
? t('obs.cleaning')
: t('obs.confirmCleanup')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -2,16 +2,17 @@
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { cn } from '@/lib/utils';
export type NodeFilter = 'all' | 'edge' | 'relay' | 'tunnel';
const filters: Array<{ key: NodeFilter; label: string; href: string }> = [
{ key: 'all', label: '全部节点', href: '/nodes' },
{ key: 'edge', label: 'Edge', href: '/nodes?filter=edge' },
{ key: 'relay', label: 'Relay', href: '/nodes?filter=relay' },
{ key: 'tunnel', label: 'Tunnel', href: '/nodes?filter=tunnel' },
const filters: Array<{ key: NodeFilter; href: string }> = [
{ key: 'all', href: '/nodes' },
{ key: 'edge', href: '/nodes?filter=edge' },
{ key: 'relay', href: '/nodes?filter=relay' },
{ key: 'tunnel', href: '/nodes?filter=tunnel' },
];
export function getNodeFilter(searchParams: URLSearchParams): NodeFilter {
@@ -44,23 +45,33 @@ export function filterNodesByType<T extends { node_type: string }>(
}
}
export function getFilterDescription(filter: NodeFilter) {
export function getFilterDescription(
filter: NodeFilter,
t: (key: string) => string,
) {
switch (filter) {
case 'relay':
return '当前仅展示 Relay 节点。';
return t('filter.descRelay');
case 'tunnel':
return '当前仅展示 Tunnel 节点。';
return t('filter.descTunnel');
case 'edge':
return '当前仅展示 Edge 节点。';
return t('filter.descEdge');
case 'all':
default:
return '当前展示全部节点。';
return t('filter.descAll');
}
}
export function NodeTypeFilter() {
const t = useTranslations('nodes');
const searchParams = useSearchParams();
const activeFilter = getNodeFilter(searchParams);
const filterLabels: Record<NodeFilter, string> = {
all: t('filter.all'),
edge: 'Edge',
relay: 'Relay',
tunnel: 'Tunnel',
};
return (
<div className='flex flex-wrap gap-2'>
@@ -75,7 +86,7 @@ export function NodeTypeFilter() {
: 'border-border text-muted-foreground hover:bg-muted/50',
)}
>
{item.label}
{filterLabels[item.key]}
</Link>
))}
</div>
@@ -11,6 +11,11 @@ export const FLARED_WS_CONNECTED_LAST_SEEN =
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
export type NodeMessageT = (
key: string,
values?: Record<string, string | number | Date>,
) => string;
export function isWSConnectedLastSeen(value: string | null | undefined) {
return (
value === WS_CONNECTED_LAST_SEEN || value === FLARED_WS_CONNECTED_LAST_SEEN
@@ -27,7 +32,7 @@ export function isMeaningfulTime(
);
}
export function formatRelativeTime(value: string) {
export function formatRelativeTime(value: string, t: NodeMessageT) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
@@ -35,16 +40,16 @@ export function formatRelativeTime(value: string) {
const diffMs = Date.now() - date.getTime();
const diffMinutes = Math.floor(diffMs / 60_000);
if (diffMinutes < 1) return '刚刚';
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
if (diffMinutes < 1) return t('relative.justNow');
if (diffMinutes < 60) return t('relative.minutesAgo', { count: diffMinutes });
const diffHours = Math.floor(diffMinutes / 60);
if (diffHours < 24) return `${diffHours} 小时前`;
if (diffHours < 24) return t('relative.hoursAgo', { count: diffHours });
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays} 天前`;
if (diffDays < 30) return t('relative.daysAgo', { count: diffDays });
return `${Math.floor(diffDays / 30)} 个月前`;
return t('relative.monthsAgo', { count: Math.floor(diffDays / 30) });
}
export function getNodeStatusTone(status: NodeStatus): StatusTone {
@@ -53,10 +58,10 @@ export function getNodeStatusTone(status: NodeStatus): StatusTone {
return 'danger';
}
export function getNodeStatusLabel(status: NodeStatus) {
if (status === 'online') return '在线';
if (status === 'pending') return '待接入';
return '离线';
export function getNodeStatusLabel(status: NodeStatus, t: NodeMessageT) {
if (status === 'online') return t('status.online');
if (status === 'pending') return t('status.pending');
return t('status.offline');
}
export function getApplyTone(result: ApplyResult): StatusTone {
@@ -66,11 +71,11 @@ export function getApplyTone(result: ApplyResult): StatusTone {
return 'warning';
}
export function getApplyLabel(result: ApplyResult) {
if (result === 'success') return '成功';
if (result === 'warning') return '警告';
if (result === 'failed') return '失败';
return '暂无';
export function getApplyLabel(result: ApplyResult, t: NodeMessageT) {
if (result === 'success') return t('apply.success');
if (result === 'warning') return t('apply.warning');
if (result === 'failed') return t('apply.failed');
return t('apply.none');
}
export function getOpenrestyStatusTone(status: OpenrestyStatus): StatusTone {
@@ -79,10 +84,13 @@ export function getOpenrestyStatusTone(status: OpenrestyStatus): StatusTone {
return 'warning';
}
export function getOpenrestyStatusLabel(status: OpenrestyStatus) {
if (status === 'healthy') return '健康';
if (status === 'unhealthy') return '异常';
return '未知';
export function getOpenrestyStatusLabel(
status: OpenrestyStatus,
t: NodeMessageT,
) {
if (status === 'healthy') return t('health.healthy');
if (status === 'unhealthy') return t('health.unhealthy');
return t('health.unknown');
}
export function getRelayStatusTone(
@@ -93,10 +101,13 @@ export function getRelayStatusTone(
return 'warning';
}
export function getRelayStatusLabel(status: string | null | undefined) {
if (status === 'healthy') return '健康';
if (status === 'unhealthy') return '异常';
return '未知';
export function getRelayStatusLabel(
status: string | null | undefined,
t: NodeMessageT,
) {
if (status === 'healthy') return t('health.healthy');
if (status === 'unhealthy') return t('health.unhealthy');
return t('health.unknown');
}
export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
@@ -105,8 +116,8 @@ export function getNodeTypeLabel(nodeType: NodeItem['node_type']) {
return 'Edge';
}
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
export function getErrorMessage(error: unknown, fallback: string) {
return error instanceof Error ? error.message : fallback;
}
export function getServerUrl(value: string) {
@@ -264,7 +275,10 @@ export function formatUsageRatio(used?: number | null, total?: number | null) {
return Math.max(0, Math.min(100, (used / total) * 100));
}
export function formatUptime(seconds?: number | null) {
export function formatUptime(
seconds: number | null | undefined,
t: NodeMessageT,
) {
if (!seconds || seconds <= 0) {
return '—';
}
@@ -274,12 +288,12 @@ export function formatUptime(seconds?: number | null) {
const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) {
return `${days} 天 ${hours} 小时`;
return t('uptime.daysHours', { days, hours });
}
if (hours > 0) {
return `${hours} 小时 ${minutes} 分钟`;
return t('uptime.hoursMinutes', { hours, minutes });
}
return `${minutes} 分钟`;
return t('uptime.minutes', { minutes });
}
export function getHealthEventTone(event: {
@@ -302,17 +316,17 @@ export function getHealthEventLabel(event: { event_type: string }) {
return event.event_type.replaceAll('_', ' ');
}
export function getFlaredStatusLabel(node: NodeItem) {
export function getFlaredStatusLabel(node: NodeItem, t: NodeMessageT) {
if (isWSConnectedLastSeen(node.last_seen_at)) {
return 'WS 已连接';
return t('status.wsConnected');
}
if (node.status === 'online') {
return '运行中';
return t('status.running');
}
if (node.status === 'pending') {
return '待接入';
return t('status.pending');
}
return '离线';
return t('status.offline');
}
export function getFlaredStatusTone(node: NodeItem): StatusTone {
@@ -2,6 +2,7 @@
import Link from 'next/link';
import { Pencil, Trash2 } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import {
@@ -42,19 +43,24 @@ export function NodesTable({
onEdit: (node: NodeItem) => void;
onDelete: (node: NodeItem) => void;
}) {
const t = useTranslations('nodes');
return (
<div className='border border-dashed rounded-lg overflow-hidden'>
<Table>
<TableHeader>
<TableRow className='border-dashed hover:bg-transparent'>
<TableHead className='py-2 h-8'>节点</TableHead>
<TableHead className='py-2 h-8'>状态</TableHead>
<TableHead className='py-2 h-8'>Version</TableHead>
<TableHead className='py-2 h-8'>运行健康</TableHead>
<TableHead className='py-2 h-8'>当前版本</TableHead>
<TableHead className='py-2 h-8'>最近应用</TableHead>
<TableHead className='py-2 h-8'>最近心跳</TableHead>
<TableHead className='py-2 h-8 text-right'>操作</TableHead>
<TableHead className='py-2 h-8'>{t('table.node')}</TableHead>
<TableHead className='py-2 h-8'>{t('table.status')}</TableHead>
<TableHead className='py-2 h-8'>{t('table.version')}</TableHead>
<TableHead className='py-2 h-8'>{t('table.runHealth')}</TableHead>
<TableHead className='py-2 h-8'>
{t('table.currentVersion')}
</TableHead>
<TableHead className='py-2 h-8'>{t('table.latestApply')}</TableHead>
<TableHead className='py-2 h-8'>{t('table.lastSeen')}</TableHead>
<TableHead className='py-2 h-8 text-right'>
{t('table.actions')}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -70,17 +76,19 @@ export function NodesTable({
/>
</div>
<p className='text-xs text-muted-foreground'>
IP:{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
{t('ipLine', { ip: node.ip || '—' })}
{node.ip_manual_override ? t('lockedSuffix') : ''}
</p>
<p className='text-xs text-muted-foreground'>
位置:{node.geo_name || '未配置地图点位'}
{t('locationLine', {
location: node.geo_name || t('noGeo'),
})}
</p>
</div>
</TableCell>
<TableCell className='py-3'>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
</TableCell>
@@ -90,41 +98,45 @@ export function NodesTable({
<TableCell className='py-3'>
{node.node_type === 'tunnel_relay' ? (
<NodeStatusBadge
label={getRelayStatusLabel(node.relay_status)}
label={getRelayStatusLabel(node.relay_status, t)}
tone={getRelayStatusTone(node.relay_status)}
/>
) : node.node_type === 'tunnel_client' ? (
<NodeStatusBadge
label={node.status === 'online' ? '运行中' : '未知'}
label={
node.status === 'online' ? t('running') : t('unknown')
}
tone={node.status === 'online' ? 'success' : 'warning'}
/>
) : (
<NodeStatusBadge
label={getOpenrestyStatusLabel(node.openresty_status)}
label={getOpenrestyStatusLabel(node.openresty_status, t)}
tone={getOpenrestyStatusTone(node.openresty_status)}
/>
)}
</TableCell>
<TableCell className='py-3 text-muted-foreground'>
{node.current_version ||
(node.node_type === 'tunnel_relay' ? '实时配置' : '未应用')}
(node.node_type === 'tunnel_relay'
? t('liveConfig')
: t('notApplied'))}
</TableCell>
<TableCell className='py-3'>
{node.node_type === 'tunnel_relay' ? (
<span className='text-sm text-muted-foreground'>—</span>
) : (
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)}
/>
)}
</TableCell>
<TableCell className='py-3 text-muted-foreground'>
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
? t('wsConnected')
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
: t('na')}
</TableCell>
<TableCell className='py-3'>
<div className='flex flex-wrap justify-end gap-1'>
@@ -134,7 +146,9 @@ export function NodesTable({
className='h-7 text-xs'
asChild
>
<Link href={`/nodes/detail?id=${node.id}`}>详情</Link>
<Link href={`/nodes/detail?id=${node.id}`}>
{t('detail')}
</Link>
</Button>
<Button
variant='outline'
@@ -143,7 +157,7 @@ export function NodesTable({
onClick={() => onEdit(node)}
>
<Pencil className='size-3 mr-1' />
编辑
{t('edit')}
</Button>
<Button
variant='outline'
@@ -153,7 +167,7 @@ export function NodesTable({
onClick={() => onDelete(node)}
>
<Trash2 className='size-3 mr-1' />
删除
{t('delete')}
</Button>
</div>
</TableCell>
@@ -18,6 +18,7 @@ import {
Trash2,
Upload,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
@@ -78,6 +79,8 @@ function systemConfigMap(configs: SystemConfig[]) {
}
export function RelayNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter();
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
@@ -106,7 +109,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
mutationFn: async (port: string) => {
const portNum = parseInt(port, 10);
if (isNaN(portNum) || portNum < 1 || portNum > 65535) {
throw new Error('端口号必须在 1 ~ 65535 范围内');
throw new Error(t('relay.invalidPort'));
}
const portCfg = configs['relay_frps_web_ui_port'];
await services.adminSystemConfig.updateSystemConfig(
@@ -121,21 +124,23 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
await queryClient.invalidateQueries({
queryKey: ['admin', 'system-configs'],
});
toast.success('FRPS WebUI 端口配置已更新');
toast.success(t('relay.toastPortSaved'));
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : '更新端口失败'),
toast.error(
error instanceof Error ? error.message : t('relay.portSaveFailed'),
),
});
const saveMutation = useMutation({
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload),
onSuccess: async () => {
toast.success('中继节点已更新');
toast.success(t('relay.toastUpdated'));
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const webServerMutation = useMutation({
@@ -155,19 +160,19 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
relay_web_server_enabled: enabled,
}),
onSuccess: async () => {
toast.success('FRPS WebUI 设置已更新');
toast.success(t('relay.toastWebui'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => {
toast.success(`已向中继节点 ${updated.name} 下发强制同步指令`);
toast.success(t('relay.toastForceSync', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const upgradeMutation = useMutation({
@@ -187,22 +192,28 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
}),
onSuccess: async (updated) => {
toast.success(
`已向中继节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`,
t('relay.toastUpgrade', {
name: updated.name,
channel:
updated.update_channel === 'preview'
? t('channel.preview')
: t('channel.stable'),
}),
);
setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => {
toast.success('中继节点已删除');
toast.success(t('relay.toastDeleted'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes');
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const handleRefresh = () => {
@@ -236,7 +247,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
className='h-8'
onClick={() => setEditorOpen(true)}
>
编辑
{t('edit')}
</Button>
<Button
variant='outline'
@@ -245,7 +256,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
onClick={handleRefresh}
>
<RefreshCw className='size-3.5 mr-1.5' />
刷新
{t('refresh')}
</Button>
<Button
variant='outline'
@@ -255,7 +266,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className='size-3.5 mr-1.5' />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
{forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
</Button>
<Button
variant='secondary'
@@ -264,7 +275,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setUpgradeOpen(true)}
>
<Upload className='size-3.5 mr-1.5' />
{node.update_requested ? '查看升级' : '升级 Relay'}
{node.update_requested ? t('viewUpgrade') : t('relay.upgrade')}
</Button>
<Button
variant='outline'
@@ -273,7 +284,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setDeleteOpen(true)}
>
<Trash2 className='size-3.5 mr-1.5' />
删除
{t('delete')}
</Button>
</>
);
@@ -284,76 +295,76 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<div className='grid gap-6 xl:grid-cols-2'>
<NodeSectionCard
title='中继运行状态'
description='frps 在线情况与负载摘要'
title={t('relay.runStatus')}
description={t('relay.runStatusDesc')}
>
<div className='divide-y'>
<NodeInfoRow label='运行状态'>
<NodeInfoRow label={t('edge.runStatus')}>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
</NodeInfoRow>
<NodeInfoRow label='中继健康'>
<NodeInfoRow label={t('relay.health')}>
<NodeStatusBadge
label={getRelayStatusLabel(node.relay_status)}
label={getRelayStatusLabel(node.relay_status, t)}
tone={getRelayStatusTone(node.relay_status)}
/>
</NodeInfoRow>
<NodeInfoRow label='最近心跳'>
<NodeInfoRow label={t('kpiLastSeen')}>
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
? t('wsConnected')
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
: t('na')}
</NodeInfoRow>
<NodeInfoRow label='活动连接 / 代理数'>
<NodeInfoRow label={t('relay.connections')}>
{node.relay_frps_connections ?? '—'} /{' '}
{node.relay_frps_proxy_count ?? '—'}
</NodeInfoRow>
<NodeInfoRow label='IP 地址'>
<NodeInfoRow label={t('edge.ip')}>
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
{node.ip_manual_override ? t('lockedSuffix') : ''}
</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard
title='网络端口'
description='frps 控制面与 HTTP 虚拟主机配置'
title={t('relay.portsTitle')}
description={t('relay.portsDesc')}
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' />
应用记录
{t('applyLogs')}
</Link>
</Button>
}
>
<div className='divide-y'>
<NodeInfoRow label='绑定控制端口'>
<NodeInfoRow label={t('relay.bindPort')}>
{node.relay_bind_port || '—'}
</NodeInfoRow>
<NodeInfoRow label='VHost HTTP 端口'>
<NodeInfoRow label={t('relay.vhostPort')}>
{node.relay_vhost_http_port || '—'}
</NodeInfoRow>
<NodeInfoRow label='Agent 接入地址'>
<NodeInfoRow label={t('relay.agentAddr')}>
<span className='break-all'>
{node.relay_agent_access_addr || '—'}
</span>
</NodeInfoRow>
<NodeInfoRow label='最近应用'>
<NodeInfoRow label={t('edge.latestApply')}>
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)}
/>
</NodeInfoRow>
<NodeInfoRow label='Relay 版本'>
<NodeInfoRow label={t('relay.relayVersion')}>
{node.version || 'unknown'}
</NodeInfoRow>
<NodeInfoRow label='frps 核心版本'>
<NodeInfoRow label={t('relay.frpsVersion')}>
{node.ext_version || 'unknown'}
</NodeInfoRow>
</div>
@@ -361,15 +372,15 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
</div>
<NodeSectionCard
title='FRPS WebUI'
description='控制 frps 内置 Web 管理界面是否启用及其监听端口'
title={t('relay.webuiTitle')}
description={t('relay.webuiDesc')}
>
<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>
<Label>{t('relay.enableWebui')}</Label>
<p className='text-xs text-muted-foreground'>
开启后,节点将启动 frps 的内置管理服务。
{t('relay.enableWebuiHint')}
</p>
</div>
<Switch
@@ -381,7 +392,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<div className='rounded-xl border p-4 space-y-3'>
<Label htmlFor='web_server_port' className='text-xs font-semibold'>
Web 管理界面端口
{t('relay.webuiPort')}
</Label>
<div className='flex max-w-sm items-center gap-2'>
<Input
@@ -391,7 +402,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
max={65535}
value={webServerPort}
onChange={(e) => setWebServerPort(e.target.value)}
placeholder='例如: 17500'
placeholder={t('relay.webuiPortPlaceholder')}
className='bg-card text-xs h-9'
disabled={
savePortMutation.isPending || systemConfigsQuery.isLoading
@@ -408,13 +419,12 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
{savePortMutation.isPending ? (
<Loader2 className='size-3.5 animate-spin' />
) : (
'保存'
tc('save')
)}
</Button>
</div>
<p className='text-[11px] text-muted-foreground'>
当前实际监听端口:{resolvedPort}{' '}
(修改后在下次节点心跳同步时生效,默认为绑定端口 + 500)。
{t('relay.actualPort', { port: resolvedPort })}
</p>
</div>
@@ -426,7 +436,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
rel='noopener noreferrer'
className='inline-flex items-center font-medium text-primary hover:underline'
>
打开 FRPS WebUI
{t('relay.openWebui')}
<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'>
@@ -438,16 +448,16 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
className='size-7 text-muted-foreground hover:text-foreground'
onClick={() => {
void navigator.clipboard.writeText(webUiUrl);
toast.success('链接已复制到剪贴板');
toast.success(t('relay.copiedLink'));
}}
title='复制链接'
title={t('relay.copyLink')}
>
<Copy className='size-3.5' />
</Button>
</div>
) : (
<p className='text-sm text-muted-foreground'>
WebUI 已禁用或未配置绑定端口。
{t('relay.webuiDisabled')}
</p>
)}
</div>
@@ -455,15 +465,15 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<InstallCommand node={node} variant='relay' />
<NodeSectionCard title='节点元数据'>
<NodeSectionCard title={t('relay.metadata')}>
<div className='divide-y'>
<NodeInfoRow label='节点 ID'>
<NodeInfoRow label={t('kpiNodeId')}>
<span className='font-mono text-xs break-all'>{node.node_id}</span>
</NodeInfoRow>
<NodeInfoRow label='创建时间'>
<NodeInfoRow label={t('relay.createdAt')}>
{formatDateTime(node.created_at)}
</NodeInfoRow>
<NodeInfoRow label='更新时间'>
<NodeInfoRow label={t('relay.updatedAt')}>
{formatDateTime(node.updated_at)}
</NodeInfoRow>
</div>
@@ -479,34 +489,34 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
typeTone='warning'
statusBadges={[
{
label: getNodeStatusLabel(node.status),
label: getNodeStatusLabel(node.status, t),
tone: getNodeStatusTone(node.status),
},
{
label: getRelayStatusLabel(node.relay_status),
label: getRelayStatusLabel(node.relay_status, t),
tone: getRelayStatusTone(node.relay_status),
},
]}
actions={headerActions}
kpis={[
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
{ label: t('kpiNodeId'), value: node.node_id, icon: Fingerprint },
{
label: 'Relay 版本',
label: t('relay.relayVersion'),
value: node.version || 'unknown',
icon: Server,
},
{
label: 'frps 核心',
label: t('relay.kpiFrps'),
value: node.ext_version || 'unknown',
icon: Network,
},
{
label: '最近心跳',
label: t('kpiLastSeen'),
value: isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
? t('wsConnected')
: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at)
: '暂无',
? formatRelativeTime(node.last_seen_at, t)
: t('na'),
icon: Activity,
},
]}
@@ -514,7 +524,7 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<NodeObservability
nodeId={node.id}
variant='compact'
connectionHint='中继承载活动连接数'
connectionHint={t('relay.connectionHint')}
/>
}
manage={manageTab}
@@ -544,22 +554,21 @@ export function RelayNodeDetail({ node }: { node: NodeItem }) {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除中继节点</AlertDialogTitle>
<AlertDialogTitle>{t('relay.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除中继节点「{node.name}
」吗?删除后该节点需要重新创建并重新接入。
{t('relay.deleteDesc', { name: node.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
@@ -15,6 +15,7 @@ import {
Trash2,
Upload,
} from 'lucide-react';
import { useTranslations } from 'next-intl';
import { toast } from 'sonner';
import {
@@ -63,6 +64,8 @@ import {
const nodesQueryKey = ['openflare', 'nodes'];
export function TunnelNodeDetail({ node }: { node: NodeItem }) {
const t = useTranslations('nodes');
const tc = useTranslations('common');
const router = useRouter();
const queryClient = useQueryClient();
const [editorOpen, setEditorOpen] = useState(false);
@@ -73,20 +76,20 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
mutationFn: (payload: Parameters<typeof NodeService.updateNode>[1]) =>
NodeService.updateNode(node.id, payload),
onSuccess: async () => {
toast.success('隧道节点已更新');
toast.success(t('tunnel.toastUpdated'));
setEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const forceSyncMutation = useMutation({
mutationFn: () => NodeService.requestForceSync(node.id),
onSuccess: async (updated) => {
toast.success(`已向隧道节点 ${updated.name} 下发强制同步指令`);
toast.success(t('tunnel.toastForceSync', { name: updated.name }));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const upgradeMutation = useMutation({
@@ -106,22 +109,28 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
}),
onSuccess: async (updated) => {
toast.success(
`已向隧道节点 ${updated.name} 下发${updated.update_channel === 'preview' ? '预览版' : '正式版'}升级指令`,
t('tunnel.toastUpgrade', {
name: updated.name,
channel:
updated.update_channel === 'preview'
? t('channel.preview')
: t('channel.stable'),
}),
);
setUpgradeOpen(false);
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const deleteMutation = useMutation({
mutationFn: () => NodeService.deleteNode(node.id),
onSuccess: async () => {
toast.success('隧道节点已删除');
toast.success(t('tunnel.toastDeleted'));
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
router.push('/nodes');
},
onError: (error) => toast.error(getErrorMessage(error)),
onError: (error) => toast.error(getErrorMessage(error, t('requestFailed'))),
});
const handleRefresh = () => {
@@ -141,7 +150,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
className='h-8'
onClick={() => setEditorOpen(true)}
>
编辑
{t('edit')}
</Button>
<Button
variant='outline'
@@ -150,7 +159,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
onClick={handleRefresh}
>
<RefreshCw className='size-3.5 mr-1.5' />
刷新
{t('refresh')}
</Button>
<Button
variant='outline'
@@ -160,7 +169,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
onClick={() => forceSyncMutation.mutate()}
>
<RotateCcw className='size-3.5 mr-1.5' />
{forceSyncMutation.isPending ? '同步中...' : '强制同步'}
{forceSyncMutation.isPending ? t('syncing') : t('forceSync')}
</Button>
<Button
variant='secondary'
@@ -169,7 +178,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setUpgradeOpen(true)}
>
<Upload className='size-3.5 mr-1.5' />
{node.update_requested ? '查看升级' : '升级 openflared'}
{node.update_requested ? t('viewUpgrade') : t('tunnel.upgrade')}
</Button>
<Button
variant='outline'
@@ -178,7 +187,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
onClick={() => setDeleteOpen(true)}
>
<Trash2 className='size-3.5 mr-1.5' />
删除
{t('delete')}
</Button>
</>
);
@@ -189,81 +198,83 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<div className='grid gap-6 xl:grid-cols-2'>
<NodeSectionCard
title='隧道运行状态'
description='openflared 连接与在线情况'
title={t('tunnel.runStatus')}
description={t('tunnel.runStatusDesc')}
>
<div className='divide-y'>
<NodeInfoRow label='运行状态'>
<NodeInfoRow label={t('edge.runStatus')}>
<NodeStatusBadge
label={getNodeStatusLabel(node.status)}
label={getNodeStatusLabel(node.status, t)}
tone={getNodeStatusTone(node.status)}
/>
</NodeInfoRow>
<NodeInfoRow label='flared 状态'>
<NodeInfoRow label={t('tunnel.flaredStatus')}>
<NodeStatusBadge
label={getFlaredStatusLabel(node)}
label={getFlaredStatusLabel(node, t)}
tone={getFlaredStatusTone(node)}
/>
</NodeInfoRow>
<NodeInfoRow label='最近心跳'>
<NodeInfoRow label={t('kpiLastSeen')}>
{isWSConnectedLastSeen(node.last_seen_at)
? 'WebSocket 长连接已建立'
? t('wsEstablished')
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
? `${formatRelativeTime(node.last_seen_at, t)} · ${formatDateTime(node.last_seen_at)}`
: t('na')}
</NodeInfoRow>
<NodeInfoRow label='IP 地址'>
<NodeInfoRow label={t('edge.ip')}>
{node.ip || '—'}
{node.ip_manual_override ? '(已锁定)' : ''}
{node.ip_manual_override ? t('lockedSuffix') : ''}
</NodeInfoRow>
<NodeInfoRow label='自动更新'>
{node.auto_update_enabled ? '已启用' : '手动'}
<NodeInfoRow label={t('tunnel.autoUpdate')}>
{node.auto_update_enabled ? t('enabled') : t('manual')}
</NodeInfoRow>
</div>
</NodeSectionCard>
<NodeSectionCard
title='配置同步'
description='隧道客户端配置追平状态'
title={t('tunnel.syncTitle')}
description={t('tunnel.syncDesc')}
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' />
应用记录
{t('applyLogs')}
</Link>
</Button>
}
>
<div className='divide-y'>
<NodeInfoRow label='当前配置版本'>
{node.current_version || '未应用'}
<NodeInfoRow label={t('tunnel.currentVersion')}>
{node.current_version || t('notApplied')}
</NodeInfoRow>
<NodeInfoRow label='最近应用'>
<NodeInfoRow label={t('tunnel.latestApply')}>
<NodeStatusBadge
label={getApplyLabel(node.latest_apply_result)}
label={getApplyLabel(node.latest_apply_result, t)}
tone={getApplyTone(node.latest_apply_result)}
/>
</NodeInfoRow>
<NodeInfoRow label='最近应用时间'>
<NodeInfoRow label={t('tunnel.latestApplyAt')}>
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(node.latest_apply_at)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无'}
? `${formatRelativeTime(node.latest_apply_at, t)} · ${formatDateTime(node.latest_apply_at)}`
: t('na')}
</NodeInfoRow>
{node.latest_apply_checksum ? (
<NodeInfoRow label='同步文件数'>
{node.latest_support_file_count} 个
<NodeInfoRow label={t('tunnel.fileCount')}>
{t('tunnel.fileCountValue', {
count: node.latest_support_file_count,
})}
</NodeInfoRow>
) : null}
<NodeInfoRow label='升级状态'>
<NodeInfoRow label={t('tunnel.upgradeStatus')}>
<NodeStatusBadge
label={
node.update_requested
? node.update_channel === 'preview'
? '等待预览更新'
: '等待更新'
: '未下发'
? t('update.waitingPreview')
: t('update.waitingUpdate')
: t('update.notRequested')
}
tone={node.update_requested ? 'warning' : 'info'}
/>
@@ -274,20 +285,23 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<InstallCommand node={node} variant='tunnel' />
<NodeSectionCard title='接入凭证' description='隧道客户端接入所需 Token'>
<NodeSectionCard
title={t('tunnel.credentials')}
description={t('tunnel.credentialsDesc')}
>
<div className='divide-y'>
<NodeInfoRow label='Tunnel Token'>
<NodeInfoRow label={t('tunnel.tunnelToken')}>
<span className='font-mono text-xs break-all'>
{node.access_token || '暂无'}
{node.access_token || t('na')}
</span>
</NodeInfoRow>
<NodeInfoRow label='节点 ID'>
<NodeInfoRow label={t('kpiNodeId')}>
<span className='font-mono text-xs break-all'>{node.node_id}</span>
</NodeInfoRow>
<NodeInfoRow label='创建时间'>
<NodeInfoRow label={t('tunnel.createdAt')}>
{formatDateTime(node.created_at)}
</NodeInfoRow>
<NodeInfoRow label='更新时间'>
<NodeInfoRow label={t('tunnel.updatedAt')}>
{formatDateTime(node.updated_at)}
</NodeInfoRow>
</div>
@@ -303,34 +317,34 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
typeTone='info'
statusBadges={[
{
label: getNodeStatusLabel(node.status),
label: getNodeStatusLabel(node.status, t),
tone: getNodeStatusTone(node.status),
},
{
label: getFlaredStatusLabel(node),
label: getFlaredStatusLabel(node, t),
tone: getFlaredStatusTone(node),
},
]}
actions={headerActions}
kpis={[
{ label: '节点 ID', value: node.node_id, icon: Fingerprint },
{ label: t('kpiNodeId'), value: node.node_id, icon: Fingerprint },
{
label: 'openflared',
label: t('tunnel.kpiOpenflared'),
value: node.version || 'unknown',
icon: Package,
},
{
label: '当前配置',
value: node.current_version || '未应用',
label: t('kpiCurrentConfig'),
value: node.current_version || t('notApplied'),
icon: KeyRound,
},
{
label: '最近心跳',
label: t('kpiLastSeen'),
value: isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
? t('wsConnected')
: isMeaningfulTime(node.last_seen_at)
? formatRelativeTime(node.last_seen_at)
: '暂无',
? formatRelativeTime(node.last_seen_at, t)
: t('na'),
icon: Activity,
},
]}
@@ -338,7 +352,7 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<NodeObservability
nodeId={node.id}
variant='compact'
connectionHint='隧道并发连接数'
connectionHint={t('tunnel.connectionHint')}
/>
}
manage={manageTab}
@@ -368,22 +382,21 @@ export function TunnelNodeDetail({ node }: { node: NodeItem }) {
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除隧道节点</AlertDialogTitle>
<AlertDialogTitle>{t('tunnel.deleteTitle')}</AlertDialogTitle>
<AlertDialogDescription>
确认删除隧道节点「{node.name}
」吗?删除后该节点需要重新创建并重新接入。
{t('tunnel.deleteDesc', { name: node.name })}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={deleteMutation.isPending}>
取消
{tc('cancel')}
</AlertDialogCancel>
<AlertDialogAction
className='bg-destructive text-white hover:bg-destructive/90'
disabled={deleteMutation.isPending}
onClick={() => deleteMutation.mutate()}
>
{deleteMutation.isPending ? '删除中...' : '确认删除'}
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>