mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 10:26:38 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMemo } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Server } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
@@ -20,6 +21,7 @@ import { getErrorMessage } from '../components/node-utils';
|
||||
const nodesQueryKey = ['openflare', 'nodes'];
|
||||
|
||||
export function NodeDetailPageClient() {
|
||||
const t = useTranslations('nodes');
|
||||
const searchParams = useSearchParams();
|
||||
const nodeId = searchParams.get('id')?.trim() ?? '';
|
||||
|
||||
@@ -41,7 +43,7 @@ export function NodeDetailPageClient() {
|
||||
<div className='py-6 px-1'>
|
||||
<EmptyStateWithBorder
|
||||
icon={Server}
|
||||
description='缺少节点 ID,请从节点列表进入详情页。'
|
||||
description={t('missingId')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -50,7 +52,10 @@ export function NodeDetailPageClient() {
|
||||
if (nodesQuery.isLoading) {
|
||||
return (
|
||||
<div className='py-6 px-1'>
|
||||
<LoadingStateWithBorder icon={Server} description='加载节点详情中...' />
|
||||
<LoadingStateWithBorder
|
||||
icon={Server}
|
||||
description={t('loadingDetail')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -60,7 +65,7 @@ export function NodeDetailPageClient() {
|
||||
<div className='py-6 px-1'>
|
||||
<div className='p-8 border border-dashed rounded-lg'>
|
||||
<ErrorInline
|
||||
message={getErrorMessage(nodesQuery.error)}
|
||||
message={getErrorMessage(nodesQuery.error, t('requestFailed'))}
|
||||
onRetry={() => void nodesQuery.refetch()}
|
||||
className='justify-center'
|
||||
/>
|
||||
@@ -75,13 +80,10 @@ export function NodeDetailPageClient() {
|
||||
<Button variant='ghost' size='sm' className='h-8 px-2' asChild>
|
||||
<Link href='/nodes'>
|
||||
<ArrowLeft className='size-4 mr-1' />
|
||||
返回节点列表
|
||||
{t('backToList')}
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyStateWithBorder
|
||||
icon={Server}
|
||||
description='节点不存在,可能已被删除或 ID 无效。'
|
||||
/>
|
||||
<EmptyStateWithBorder icon={Server} description={t('notFound')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ 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 { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
@@ -44,6 +45,8 @@ import { getErrorMessage } from './components/node-utils';
|
||||
const nodesQueryKey = ['openflare', 'nodes'];
|
||||
|
||||
export function NodesPageClient() {
|
||||
const t = useTranslations('nodes');
|
||||
const tc = useTranslations('common');
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const [editingNode, setEditingNode] = useState<NodeItem | null>(null);
|
||||
@@ -75,25 +78,25 @@ export function NodesPageClient() {
|
||||
return NodeService.createNode(payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success(editingNode ? '节点已更新' : '节点已创建');
|
||||
toast.success(editingNode ? t('updated') : t('created'));
|
||||
setEditingNode(null);
|
||||
setEditorOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error));
|
||||
toast.error(getErrorMessage(error, t('requestFailed')));
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => NodeService.deleteNode(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('节点已删除');
|
||||
toast.success(t('deleted'));
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({ queryKey: nodesQueryKey });
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(getErrorMessage(error));
|
||||
toast.error(getErrorMessage(error, t('requestFailed')));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -116,11 +119,11 @@ export function NodesPageClient() {
|
||||
<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>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>{t('title')}</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>
|
||||
<Link href='/apply-logs'>{t('applyLogs')}</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant='secondary'
|
||||
@@ -129,7 +132,7 @@ export function NodesPageClient() {
|
||||
onClick={handleCreate}
|
||||
>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
新增节点
|
||||
{t('create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -139,10 +142,10 @@ export function NodesPageClient() {
|
||||
<div className='flex items-center justify-between gap-3'>
|
||||
<div>
|
||||
<CardTitle className='text-base font-semibold'>
|
||||
节点列表
|
||||
{t('listTitle')}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{getFilterDescription(nodeFilter)}
|
||||
{getFilterDescription(nodeFilter, t)}
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
@@ -157,7 +160,7 @@ export function NodesPageClient() {
|
||||
) : (
|
||||
<RefreshCw className='size-3.5 mr-1' />
|
||||
)}
|
||||
立即刷新
|
||||
{t('refreshNow')}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -167,12 +170,12 @@ export function NodesPageClient() {
|
||||
{nodesQuery.isLoading ? (
|
||||
<LoadingStateWithBorder
|
||||
icon={Server}
|
||||
description='加载节点列表中...'
|
||||
description={t('loadingList')}
|
||||
/>
|
||||
) : nodesQuery.isError ? (
|
||||
<div className='p-8 border border-dashed rounded-lg'>
|
||||
<ErrorInline
|
||||
message={getErrorMessage(nodesQuery.error)}
|
||||
message={getErrorMessage(nodesQuery.error, t('requestFailed'))}
|
||||
onRetry={handleRefresh}
|
||||
className='justify-center'
|
||||
/>
|
||||
@@ -181,9 +184,7 @@ export function NodesPageClient() {
|
||||
<EmptyStateWithBorder
|
||||
icon={Server}
|
||||
description={
|
||||
nodes.length === 0
|
||||
? '暂无节点,请先创建一个节点。'
|
||||
: '当前筛选无结果'
|
||||
nodes.length === 0 ? t('emptyAll') : t('emptyFilter')
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
@@ -218,15 +219,14 @@ export function NodesPageClient() {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除节点</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除节点「{deleteTarget?.name}
|
||||
」吗?删除后该节点需要重新创建并重新接入。
|
||||
{t('deleteDesc', { name: deleteTarget?.name ?? '' })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteMutation.isPending}>
|
||||
取消
|
||||
{tc('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
@@ -235,7 +235,7 @@ export function NodesPageClient() {
|
||||
deleteTarget && deleteMutation.mutate(deleteTarget.id)
|
||||
}
|
||||
>
|
||||
{deleteMutation.isPending ? '删除中...' : '确认删除'}
|
||||
{deleteMutation.isPending ? t('deleting') : t('confirmDelete')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
Reference in New Issue
Block a user