Files
OpenFlare/openflare_server/web/features/nodes/components/relay-detail-page.tsx
T
2026-06-01 16:02:02 +08:00

1422 lines
57 KiB
TypeScript

'use client';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useEffect, useMemo, useState } from 'react';
import { EmptyState } from '@/components/feedback/empty-state';
import { ErrorState } from '@/components/feedback/error-state';
import { InlineMessage } from '@/components/feedback/inline-message';
import { LoadingState } from '@/components/feedback/loading-state';
import { PageHeader } from '@/components/layout/page-header';
import { AppModal } from '@/components/ui/app-modal';
import { AppCard } from '@/components/ui/app-card';
import { StatusBadge } from '@/components/ui/status-badge';
import { getConfigVersions } from '@/features/config-versions/api/config-versions';
import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal';
import type { ConfigVersionSummary } from '@/features/config-versions/types';
import { getApplyLogs } from '@/features/apply-logs/api/apply-logs';
import {
cleanupNodeHealthEvents,
deleteNode,
getNodeAgentRelease,
getNodeObservability,
requestNodeForceSync,
requestNodeAgentUpdate,
updateNode,
} from '@/features/nodes/api/nodes';
import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal';
import type { NodeItem, NodeAgentReleaseInfo } from '@/features/nodes/types';
import {
CodeBlock,
DangerButton,
PrimaryButton,
ResourceField,
ResourceInput,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import type { ReleaseChannel } from '@/features/update/types';
import { formatDateTime, formatRelativeTime } from '@/lib/utils/date';
import { formatBytes, formatPercent } from '@/lib/utils/metrics';
import {
buildRelayInstallCommand,
buildRelayDockerInstallCommand,
getApplyLabel,
getApplyVariant,
getServerUrl,
getUpdateMode,
isMeaningfulTime,
isWSConnectedLastSeen,
} from '@/features/nodes/utils';
import {
copyToClipboard,
formatUsageRatio,
formatUptime,
getErrorMessage,
getHealthEventLabel,
getHealthEventVariant,
FeedbackState,
HealthEventFilter,
NodeDetailTab,
MetricBar,
SummaryStat,
} from './node-shared';
export function RelayDetailPage({ node }: { node: NodeItem }) {
const router = useRouter();
const queryClient = useQueryClient();
const nodeId = String(node.id);
const [feedback, setFeedback] = useState<FeedbackState | null>(null);
const [isEditorOpen, setIsEditorOpen] = useState(false);
const [isAgentUpdateModalOpen, setIsAgentUpdateModalOpen] = useState(false);
const [isTargetSnapshotOpen, setIsTargetSnapshotOpen] = useState(false);
const [selectedReleaseChannel, setSelectedReleaseChannel] =
useState<ReleaseChannel>('stable');
const [agentUpdateFeedback, setAgentUpdateFeedback] =
useState<FeedbackState | null>(null);
const [serverUrl, setServerUrl] = useState('');
const [healthEventFilter, setHealthEventFilter] =
useState<HealthEventFilter>('all');
const [activeTab, setActiveTab] = useState<NodeDetailTab>('dashboard');
const [isHealthEventCleanupModalOpen, setHealthEventCleanupModalOpen] =
useState(false);
const stableAgentReleaseQuery = useQuery({
queryKey: ['node-agent-release', nodeId, 'stable'],
queryFn: () => getNodeAgentRelease(Number(nodeId), 'stable'),
enabled: false,
});
const previewAgentReleaseQuery = useQuery({
queryKey: ['node-agent-release', nodeId, 'preview'],
queryFn: () => getNodeAgentRelease(Number(nodeId), 'preview'),
enabled: false,
});
const applyLogsQuery = useQuery({
queryKey: ['apply-logs', node.node_id, 1, 10],
queryFn: () =>
getApplyLogs({
node_id: node.node_id,
pageNo: 1,
pageSize: 10,
}),
refetchInterval: 5000,
});
const configVersionsQuery = useQuery({
queryKey: ['config-versions'],
queryFn: getConfigVersions,
refetchInterval: 5000,
});
const observabilityQuery = useQuery({
queryKey: ['node-observability', nodeId],
queryFn: () =>
getNodeObservability(Number(nodeId), { hours: 24, limit: 48 }),
refetchInterval: 10000,
});
useEffect(() => {
if (typeof window !== 'undefined' && !serverUrl) {
setServerUrl(window.location.origin);
}
}, [serverUrl]);
const saveMutation = useMutation({
mutationFn: async (payload: Parameters<typeof updateNode>[1]) =>
updateNode(Number(nodeId), payload),
onSuccess: async () => {
setFeedback({ tone: 'success', message: '中继节点已更新。' });
setIsEditorOpen(false);
await queryClient.invalidateQueries({ queryKey: ['nodes'] });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const updateAgentMutation = useMutation({
mutationFn: (release: NodeAgentReleaseInfo | null) =>
requestNodeAgentUpdate(Number(nodeId), {
channel: release?.channel ?? selectedReleaseChannel,
tag_name:
release?.channel === 'preview'
? release.tag_name || undefined
: undefined,
}),
onSuccess: async (updatedNode) => {
setFeedback({
tone: 'success',
message: `已向中继节点 ${updatedNode.name} 下发${updatedNode.update_channel === 'preview' ? '预览版' : '正式版'}更新指令。`,
});
setAgentUpdateFeedback({
tone: 'success',
message: `节点将在下一次心跳后执行${updatedNode.update_channel === 'preview' ? '预览版' : '正式版'}中继代理更新。`,
});
await queryClient.invalidateQueries({ queryKey: ['nodes'] });
},
onError: (error) => {
const message = getErrorMessage(error);
setFeedback({ tone: 'danger', message });
setAgentUpdateFeedback({ tone: 'danger', message });
},
});
const forceSyncMutation = useMutation({
mutationFn: () => requestNodeForceSync(Number(nodeId)),
onSuccess: async (updatedNode) => {
setFeedback({
tone: 'success',
message: `已向中继节点 ${updatedNode.name} 下发强制同步指令,无视当前错误拦截。`,
});
await queryClient.invalidateQueries({ queryKey: ['nodes'] });
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteNode(Number(nodeId)),
onSuccess: async () => {
setFeedback({ tone: 'success', message: '中继节点已删除。' });
await queryClient.invalidateQueries({ queryKey: ['nodes'] });
router.push('/node');
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const cleanupHealthEventsMutation = useMutation({
mutationFn: () => cleanupNodeHealthEvents(Number(nodeId)),
onSuccess: async (result) => {
setFeedback({
tone: 'success',
message:
result.deleted_count > 0
? `已清理 ${result.deleted_count} 条中继健康事件日志。`
: '当前没有可清理的健康事件日志。',
});
setHealthEventCleanupModalOpen(false);
await Promise.all([
queryClient.invalidateQueries({
queryKey: ['node-observability', nodeId],
}),
queryClient.invalidateQueries({ queryKey: ['dashboard', 'overview'] }),
]);
},
onError: (error) => {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
},
});
const handleDelete = () => {
if (
!window.confirm(
`确认删除中继节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`,
)
) {
return;
}
setFeedback(null);
deleteMutation.mutate();
};
const handleCopy = async (value: string, message: string) => {
try {
await copyToClipboard(value);
setFeedback({ tone: 'success', message });
} catch (error) {
setFeedback({ tone: 'danger', message: getErrorMessage(error) });
}
};
const activeConfigVersion = useMemo<ConfigVersionSummary | null>(() => {
return (
(configVersionsQuery.data ?? []).find((item) => item.is_active) ?? null
);
}, [configVersionsQuery.data]);
const observability = observabilityQuery.data ?? null;
const latestMetricSnapshot = observability?.metric_snapshots?.[0] ?? null;
const activeHealthEvents = useMemo(
() =>
observability?.health_events.filter(
(event) => event.status === 'active',
) ?? [],
[observability?.health_events],
);
const resolvedHealthEvents = useMemo(
() =>
observability?.health_events.filter(
(event) => event.status === 'resolved',
) ?? [],
[observability?.health_events],
);
const filteredHealthEvents = useMemo(() => {
switch (healthEventFilter) {
case 'active':
return activeHealthEvents;
case 'resolved':
return resolvedHealthEvents;
default:
return observability?.health_events ?? [];
}
}, [
activeHealthEvents,
healthEventFilter,
observability?.health_events,
resolvedHealthEvents,
]);
const tabs = useMemo(
() =>
[
{
key: 'dashboard' as const,
label: '数据看板',
description: '隧道状态、资源快照与运行诊断。',
},
{
key: 'info' as const,
label: '节点信息',
description: '更新模式、版本状态、部署信息与应用记录。',
},
] satisfies Array<{
key: NodeDetailTab;
label: string;
description: string;
}>,
[],
);
const normalizedServerUrl = getServerUrl(serverUrl);
const relayInstallCommand =
normalizedServerUrl && node.access_token
? buildRelayInstallCommand(normalizedServerUrl, node.access_token)
: '';
const relayDockerInstallCommand =
normalizedServerUrl && node.access_token
? buildRelayDockerInstallCommand(normalizedServerUrl, node.access_token)
: '';
const updateMode = getUpdateMode(node);
const selectedAgentRelease =
selectedReleaseChannel === 'preview'
? previewAgentReleaseQuery.data
: stableAgentReleaseQuery.data;
const selectedAgentReleaseError =
selectedReleaseChannel === 'preview'
? previewAgentReleaseQuery.error
: stableAgentReleaseQuery.error;
const isCheckingAgentRelease =
selectedReleaseChannel === 'preview'
? previewAgentReleaseQuery.isFetching
: stableAgentReleaseQuery.isFetching;
const applyLogs = applyLogsQuery.data?.rows ?? [];
const latestHealthEvent = activeHealthEvents[0] ?? null;
const memoryUsageRatio = formatUsageRatio(
latestMetricSnapshot?.memory_used_bytes,
latestMetricSnapshot?.memory_total_bytes,
);
const storageUsageRatio = formatUsageRatio(
latestMetricSnapshot?.storage_used_bytes,
latestMetricSnapshot?.storage_total_bytes,
);
const isTargetVersionApplied =
activeConfigVersion !== null &&
activeConfigVersion.version === node.current_version;
const handleOpenAgentUpdateModal = () => {
setAgentUpdateFeedback(null);
setSelectedReleaseChannel('stable');
setIsAgentUpdateModalOpen(true);
void stableAgentReleaseQuery.refetch();
};
const handleCheckStableAgentRelease = () => {
setAgentUpdateFeedback(null);
setSelectedReleaseChannel('stable');
void stableAgentReleaseQuery.refetch();
};
const handleCheckPreviewAgentRelease = () => {
setAgentUpdateFeedback(null);
setSelectedReleaseChannel('preview');
void previewAgentReleaseQuery.refetch();
};
const handleRequestAgentUpdate = () => {
updateAgentMutation.mutate(selectedAgentRelease ?? null);
};
const isRefreshing =
applyLogsQuery.isFetching ||
observabilityQuery.isFetching ||
configVersionsQuery.isFetching;
const handleRefresh = async () => {
setFeedback(null);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['nodes'] }),
queryClient.invalidateQueries({
queryKey: ['apply-logs', node.node_id],
}),
queryClient.invalidateQueries({
queryKey: ['config-versions'],
}),
queryClient.invalidateQueries({
queryKey: ['node-observability', nodeId],
}),
]);
};
return (
<>
<div className="space-y-6">
<PageHeader
title={node.name}
description="中继节点详情 (frps)"
action={
<>
<Link
href="/node"
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
返回
</Link>
<SecondaryButton
type="button"
onClick={() => setIsEditorOpen(true)}
>
编辑节点
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() => void handleRefresh()}
disabled={isRefreshing}
>
{isRefreshing ? '刷新中...' : '刷新'}
</SecondaryButton>
<SecondaryButton
type="button"
onClick={() => {
setFeedback(null);
forceSyncMutation.mutate();
}}
disabled={forceSyncMutation.isPending}
>
{forceSyncMutation.isPending ? '同步中...' : '同步'}
</SecondaryButton>
<PrimaryButton
type="button"
onClick={handleOpenAgentUpdateModal}
disabled={updateAgentMutation.isPending}
>
{node.update_requested ? '查看升级' : '升级'}
</PrimaryButton>
<DangerButton
type="button"
onClick={handleDelete}
disabled={deleteMutation.isPending}
>
删除
</DangerButton>
</>
}
/>
{feedback ? (
<InlineMessage
tone={feedback.tone}
message={feedback.message}
onClear={() => setFeedback(null)}
/>
) : null}
<div className="flex flex-wrap gap-3">
{tabs.map((tab) => (
<button
key={tab.key}
type="button"
onClick={() => setActiveTab(tab.key)}
className={[
'rounded-2xl border px-4 py-3 text-left transition',
activeTab === tab.key
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] bg-[var(--surface-muted)] text-[var(--foreground-secondary)] hover:border-[var(--border-strong)] hover:text-[var(--foreground-primary)]',
].join(' ')}
>
<p className="text-sm font-semibold">{tab.label}</p>
<p className="mt-1 text-xs leading-5 text-inherit/80">
{tab.description}
</p>
</button>
))}
</div>
{activeTab === 'dashboard' ? (
<>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<SummaryStat
label="运行诊断"
value={
activeHealthEvents.length
? `${activeHealthEvents.length} 个活动异常`
: '运行稳定'
}
hint={
latestHealthEvent
? `${getHealthEventLabel(latestHealthEvent)} · ${latestHealthEvent.message || '等待处理'}`
: '当前没有活动中的健康事件'
}
/>
<SummaryStat
label="中继代理总数"
value={
observability?.relay_dashboard
? `${observability.relay_dashboard.total_proxies}`
: '—'
}
hint={
observability?.relay_dashboard
? `${observability.relay_dashboard.online_proxies} 个在线,${observability.relay_dashboard.offline_proxies} 个离线`
: '暂无代理通道统计'
}
/>
<SummaryStat
label="活动并发连接数"
value={
observability?.relay_dashboard
? `${observability.relay_dashboard.total_connections}`
: '—'
}
hint="中继代理网络承载的当前活动连接总数"
/>
<SummaryStat
label="活动客户端"
value={
observability?.relay_dashboard
? `${observability.relay_dashboard.client_counts}`
: '—'
}
hint="已注册连接到该中继的主动客户端数量"
/>
</div>
<div className="grid gap-6 xl:grid-cols-3">
<AppCard title="系统画像">
{observabilityQuery.isLoading ? (
<LoadingState />
) : observabilityQuery.isError ? (
<InlineMessage
tone="danger"
message={getErrorMessage(observabilityQuery.error)}
/>
) : observability?.profile ? (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
主机名
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{observability.profile.hostname || node.name}
</p>
</div>
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
操作系统
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{observability.profile.os_name || 'unknown'}
{observability.profile.os_version
? ` ${observability.profile.os_version}`
: ''}
</p>
</div>
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
内核 / 架构
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{observability.profile.kernel_version || 'unknown'} ·{' '}
{observability.profile.architecture || 'unknown'}
</p>
</div>
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
在线时长
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatUptime(observability.profile.uptime_seconds)}
</p>
</div>
</div>
<div className="space-y-4 rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
CPU
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{observability.profile.cpu_model || 'unknown'}
</p>
<p className="mt-1 text-xs text-[var(--foreground-muted)]">
{observability.profile.cpu_cores || 0} 核
</p>
</div>
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
总内存
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatBytes(
observability.profile.total_memory_bytes,
)}
</p>
</div>
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
总存储
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatBytes(observability.profile.total_disk_bytes)}
</p>
</div>
<div>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
上报时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{isMeaningfulTime(observability.profile.reported_at)
? formatDateTime(observability.profile.reported_at)
: '—'}
</p>
</div>
</div>
</div>
) : (
<EmptyState
title="暂无中继画像"
description="节点已经接入,但还没有上报完整系统配置。"
/>
)}
</AppCard>
<AppCard title="实时中继资源">
{observabilityQuery.isLoading ? (
<LoadingState />
) : observabilityQuery.isError ? (
<InlineMessage
tone="danger"
message={getErrorMessage(observabilityQuery.error)}
/>
) : latestMetricSnapshot ? (
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<MetricBar
label="CPU"
value={formatPercent(
latestMetricSnapshot.cpu_usage_percent,
)}
progress={latestMetricSnapshot.cpu_usage_percent}
hint={
isMeaningfulTime(latestMetricSnapshot.captured_at)
? `快照 ${formatRelativeTime(latestMetricSnapshot.captured_at)}`
: undefined
}
/>
<MetricBar
label="内存"
value={`${formatBytes(
latestMetricSnapshot.memory_used_bytes,
)} / ${formatBytes(latestMetricSnapshot.memory_total_bytes)}`}
progress={memoryUsageRatio}
/>
<MetricBar
label="存储"
value={`${formatBytes(
latestMetricSnapshot.storage_used_bytes,
)} / ${formatBytes(latestMetricSnapshot.storage_total_bytes)}`}
progress={storageUsageRatio}
/>
<MetricBar
label="活动连接"
value={
latestMetricSnapshot.openresty_connections
? `${latestMetricSnapshot.openresty_connections}`
: '—'
}
progress={null}
hint="中继承载活动连接数"
/>
</div>
</div>
) : (
<EmptyState
title="暂无资源快照"
description="中继节点已经接入,但还没有上报资源快照。"
/>
)}
</AppCard>
<AppCard
title="诊断事件时间线"
description="保留活动与已恢复异常事件。"
action={
<DangerButton
type="button"
disabled={
cleanupHealthEventsMutation.isPending ||
!observability?.health_events.length
}
onClick={() => setHealthEventCleanupModalOpen(true)}
>
{cleanupHealthEventsMutation.isPending
? '清理中...'
: '清理日志'}
</DangerButton>
}
>
{observability?.health_events.length ? (
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<button
type="button"
onClick={() => setHealthEventFilter('all')}
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
healthEventFilter === 'all'
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
}`}
>
全部
</button>
<button
type="button"
onClick={() => setHealthEventFilter('active')}
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
healthEventFilter === 'active'
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
}`}
>
活动中
</button>
<button
type="button"
onClick={() => setHealthEventFilter('resolved')}
className={`inline-flex items-center rounded-full border px-3 py-1.5 text-xs transition ${
healthEventFilter === 'resolved'
? 'border-[var(--border-strong)] bg-[var(--accent-soft)] text-[var(--foreground-primary)]'
: 'border-[var(--border-default)] text-[var(--foreground-secondary)] hover:bg-[var(--control-background-hover)]'
}`}
>
已恢复
</button>
</div>
{filteredHealthEvents.slice(0, 8).map((event) => (
<div
key={`${event.event_type}-${event.last_triggered_at}-${event.status}`}
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
>
<div className="flex flex-wrap items-center gap-2">
<StatusBadge
label={getHealthEventLabel(event)}
variant={getHealthEventVariant(event)}
/>
<StatusBadge
label={
event.status === 'active' ? '活动中' : '已恢复'
}
variant={
event.status === 'active' ? 'warning' : 'success'
}
/>
</div>
<p className="mt-3 text-sm text-[var(--foreground-secondary)]">
{event.message || '暂无详细消息'}
</p>
<div className="mt-3 grid gap-2 text-xs text-[var(--foreground-muted)] md:grid-cols-3">
<p>
首次触发:
{isMeaningfulTime(event.first_triggered_at)
? ` ${formatDateTime(event.first_triggered_at)}`
: ' —'}
</p>
<p>
最近触发:
{isMeaningfulTime(event.last_triggered_at)
? ` ${formatDateTime(event.last_triggered_at)}`
: ' —'}
</p>
<p>
恢复时间:
{isMeaningfulTime(event.resolved_at)
? ` ${formatDateTime(event.resolved_at)}`
: ' —'}
</p>
</div>
</div>
))}
{filteredHealthEvents.length === 0 ? (
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm text-[var(--foreground-secondary)]">
当前筛选下没有健康事件。
</div>
) : null}
</div>
) : (
<EmptyState
title="暂无健康诊断事件"
description="节点当前还没有上报健康事件记录。"
/>
)}
</AppCard>
</div>
<div className="grid gap-6 xl:grid-cols-1">
<AppCard
title="隧道活跃通道状态"
description="展示中继节点上活跃并连接的 frps 网络隧道代理。"
>
{observability?.relay_dashboard?.proxies?.length ? (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm text-[var(--foreground-secondary)]">
<thead className="border-b border-[var(--border-default)]">
<tr>
<th className="px-4 py-3 font-medium">
代理名称 (Proxy Name)
</th>
<th className="px-4 py-3 font-medium">代理类型</th>
<th className="px-4 py-3 font-medium">在线状态</th>
<th className="px-4 py-3 font-medium">
客户端接入地址
</th>
<th className="px-4 py-3 font-medium">
最近连接时间
</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{observability.relay_dashboard.proxies.map((proxy) => (
<tr key={proxy.name}>
<td className="px-4 py-3 font-medium text-[var(--foreground-primary)]">
{proxy.name}
</td>
<td className="px-4 py-3">{proxy.type}</td>
<td className="px-4 py-3">
<StatusBadge
label={
proxy.status === 'online' ? '在线' : '离线'
}
variant={
proxy.status === 'online' ? 'success' : 'info'
}
/>
</td>
<td className="px-4 py-3">{proxy.client_addr}</td>
<td className="px-4 py-3">
{isMeaningfulTime(proxy.last_start_time)
? formatRelativeTime(proxy.last_start_time)
: '—'}
</td>
</tr>
))}
</tbody>
</table>
</div>
) : (
<EmptyState
title="暂无通道活动"
description="当前没有隧道客户端连接到该中继节点。"
/>
)}
</AppCard>
</div>
</>
) : null}
{activeTab === 'info' ? (
<>
<div className="grid gap-4 xl:grid-cols-3">
<AppCard title="更新模式">
<div className="space-y-3">
<StatusBadge
label={updateMode.label}
variant={updateMode.variant}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
{node.update_requested
? `已等待节点在下一次心跳后执行${node.update_channel === 'preview' ? '预览版' : '正式版'}更新。`
: node.auto_update_enabled
? '节点已启用自动更新。'
: '当前仅支持手动触发更新。'}
</p>
</div>
</AppCard>
<AppCard title="版本状态">
<div className="space-y-2 text-sm text-[var(--foreground-secondary)]">
<p>Relay 中继版本:{node.version || 'unknown'}</p>
<p>frps 核心版本:{node.ext_version || 'unknown'}</p>
<p>中继网络接入:{node.relay_agent_access_addr || '—'}</p>
</div>
</AppCard>
<AppCard title="最近应用配置">
<div className="space-y-3">
<StatusBadge
label={getApplyLabel(node.latest_apply_result)}
variant={getApplyVariant(node.latest_apply_result)}
/>
<p className="text-sm text-[var(--foreground-secondary)]">
{isMeaningfulTime(node.latest_apply_at)
? `${formatRelativeTime(
node.latest_apply_at,
)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无应用记录'}
</p>
{node.latest_apply_checksum ? (
<div className="space-y-1 text-sm text-[var(--foreground-secondary)]">
<p>同步文件:{node.latest_support_file_count} 个</p>
</div>
) : null}
</div>
</AppCard>
</div>
<AppCard
title="配置应用版本追平"
description="展示全局最新配置应用状态。"
action={
activeConfigVersion ? (
<SecondaryButton
type="button"
onClick={() => setIsTargetSnapshotOpen(true)}
>
查看目标快照
</SecondaryButton>
) : null
}
>
{configVersionsQuery.isLoading ? (
<LoadingState />
) : configVersionsQuery.isError ? (
<InlineMessage
tone="danger"
message={getErrorMessage(configVersionsQuery.error)}
/>
) : activeConfigVersion ? (
<div className="grid gap-4 lg:grid-cols-[220px_minmax(0,1fr)]">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
追平状态
</p>
<div className="mt-3 flex flex-wrap items-center gap-3">
<StatusBadge
label={
isTargetVersionApplied ? '已追平目标版本' : '同步落后'
}
variant={isTargetVersionApplied ? 'success' : 'warning'}
/>
</div>
<p className="mt-3 text-sm text-[var(--foreground-secondary)]">
{isTargetVersionApplied
? '当前节点已应用全局最新配置。'
: '当前中继代理版本落后于全局激活配置,请检查下面配置应用历史。'}
</p>
</div>
<div className="grid gap-4 md:grid-cols-3">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
目标配置版本
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{activeConfigVersion.version}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
配置校验和
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{activeConfigVersion.checksum}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
全局激活时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(activeConfigVersion.created_at)}
</p>
</div>
</div>
</div>
) : (
<InlineMessage
tone="info"
message="当前全局没有激活的配置版本。"
/>
)}
</AppCard>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<AppCard
title="中继节点部署与标识"
action={
<div className="flex gap-2">
{relayInstallCommand ? (
<PrimaryButton
type="button"
onClick={() =>
void handleCopy(
relayInstallCommand,
'部署脚本命令已复制。',
)
}
>
复制脚本命令
</PrimaryButton>
) : null}
{relayDockerInstallCommand ? (
<PrimaryButton
type="button"
onClick={() =>
void handleCopy(
relayDockerInstallCommand,
'Docker 部署命令已复制。',
)
}
>
复制 Docker 命令
</PrimaryButton>
) : null}
</div>
}
>
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
Node ID
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{node.node_id}
</p>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4">
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
Discovery Token (中继注册 Token)
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{node.access_token || '暂无'}
</p>
</div>
</div>
<ResourceField
label="Server URL (全局控制面访问地址)"
hint="中继运行脚本或容器通过此地址连通控制端。"
>
<ResourceInput
value={serverUrl}
onChange={(event) => setServerUrl(event.target.value)}
/>
</ResourceField>
{relayInstallCommand ? (
<div>
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
一键脚本部署 (Linux / macOS)
</p>
<CodeBlock className="whitespace-pre-wrap">
{relayInstallCommand}
</CodeBlock>
</div>
) : null}
{relayDockerInstallCommand ? (
<div>
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
Docker 容器部署
</p>
<CodeBlock className="whitespace-pre-wrap">
{relayDockerInstallCommand}
</CodeBlock>
</div>
) : null}
</div>
</AppCard>
<AppCard title="中继网络与属性">
<div className="space-y-4 text-sm text-[var(--foreground-secondary)]">
<div>
<p className="font-medium text-[var(--foreground-primary)]">
中继 IP 地址
</p>
<p className="mt-1">
{node.ip || '暂无上报'}
{node.ip_manual_override ? '(管理端锁定)' : ''}
</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
绑定控制端口
</p>
<p className="mt-1">{node.relay_bind_port || '—'}</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
最后心跳
</p>
<p className="mt-1">
{isWSConnectedLastSeen(node.last_seen_at)
? 'WS 已连接'
: isMeaningfulTime(node.last_seen_at)
? `${formatRelativeTime(node.last_seen_at)} · ${formatDateTime(node.last_seen_at)}`
: '暂无'}
</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
中继节点最近错误
</p>
<p className="mt-1 break-words whitespace-pre-wrap">
{node.last_error || '无'}
</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
创建时间
</p>
<p className="mt-1">{formatDateTime(node.created_at)}</p>
</div>
<div>
<p className="font-medium text-[var(--foreground-primary)]">
更新时间
</p>
<p className="mt-1">{formatDateTime(node.updated_at)}</p>
</div>
</div>
</AppCard>
</div>
<AppCard
title="配置应用历史"
description="仅展示此节点的应用历史记录。"
action={
<Link
href={`/apply-log?node_id=${encodeURIComponent(node.node_id)}`}
className="inline-flex items-center justify-center rounded-2xl border border-[var(--border-default)] bg-[var(--control-background)] px-4 py-3 text-sm font-medium text-[var(--foreground-primary)] transition hover:bg-[var(--control-background-hover)]"
>
查看完整记录
</Link>
}
>
{applyLogsQuery.isLoading ? (
<LoadingState />
) : applyLogsQuery.isError ? (
<ErrorState
title="应用记录加载失败"
description={getErrorMessage(applyLogsQuery.error)}
/>
) : applyLogs.length === 0 ? (
<EmptyState
title="暂无应用记录"
description="中继节点当前还没有同步应用过任何配置。"
/>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">配置版本</th>
<th className="px-3 py-3 font-medium">结果</th>
<th className="px-3 py-3 font-medium">校验和</th>
<th className="px-3 py-3 font-medium">应用时间</th>
<th className="px-3 py-3 font-medium">消息</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{applyLogs.map((log) => (
<tr key={log.id} className="align-top">
<td className="px-3 py-4 text-[var(--foreground-primary)]">
{log.version}
</td>
<td className="px-3 py-4">
<StatusBadge
label={log.result === 'success' ? '成功' : '失败'}
variant={
log.result === 'success' ? 'success' : 'danger'
}
/>
</td>
<td
className="px-3 py-4 text-[var(--foreground-secondary)]"
title={log.checksum}
>
{log.checksum
? `${log.checksum.slice(0, 12)}...`
: '—'}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{formatRelativeTime(log.created_at)} ·{' '}
{formatDateTime(log.created_at)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div className="max-w-80 space-y-2 break-words whitespace-pre-wrap">
<p>{log.message || '—'}</p>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</AppCard>
</>
) : null}
</div>
<ConfigVersionSnapshotModal
version={isTargetSnapshotOpen ? activeConfigVersion : null}
onClose={() => setIsTargetSnapshotOpen(false)}
/>
<NodeEditorModal
isOpen={isEditorOpen}
node={node}
isSubmitting={saveMutation.isPending}
onClose={() => setIsEditorOpen(false)}
title="编辑中继节点"
description="可以修改中继节点名、中继控制端口等信息。"
submitLabel="保存修改"
onSubmit={(payload) => {
setFeedback(null);
saveMutation.mutate(payload);
}}
/>
<AppModal
isOpen={isHealthEventCleanupModalOpen}
onClose={() => setHealthEventCleanupModalOpen(false)}
title="清理运行诊断日志"
description={`确认清理中继节点“${node.name}”的健康诊断事件日志吗?`}
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={() => setHealthEventCleanupModalOpen(false)}
>
取消
</SecondaryButton>
<DangerButton
type="button"
disabled={cleanupHealthEventsMutation.isPending}
onClick={() => {
setFeedback(null);
cleanupHealthEventsMutation.mutate();
}}
>
{cleanupHealthEventsMutation.isPending ? '清理中...' : '确认清理'}
</DangerButton>
</div>
}
>
{cleanupHealthEventsMutation.isError ? (
<ErrorState
title="健康事件清理失败"
description={getErrorMessage(cleanupHealthEventsMutation.error)}
/>
) : (
<div className="space-y-3 text-sm text-[var(--foreground-secondary)]">
<p>该操作会删除此中继节点在控制端记录的所有健康诊断事件历史。</p>
<p>这不会影响节点在后续运行中继续捕捉并上报新的故障。</p>
</div>
)}
</AppModal>
<AppModal
isOpen={isAgentUpdateModalOpen}
onClose={() => setIsAgentUpdateModalOpen(false)}
title="中继代理升级"
description="管理当前中继软件 (relay/agent) 版本。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={handleCheckStableAgentRelease}
disabled={isCheckingAgentRelease || updateAgentMutation.isPending}
>
{isCheckingAgentRelease && selectedReleaseChannel === 'stable'
? '检查中...'
: '检查正式版'}
</SecondaryButton>
<SecondaryButton
type="button"
onClick={handleCheckPreviewAgentRelease}
disabled={isCheckingAgentRelease || updateAgentMutation.isPending}
>
{isCheckingAgentRelease && selectedReleaseChannel === 'preview'
? '检查中...'
: '检查预览版'}
</SecondaryButton>
<PrimaryButton
type="button"
onClick={handleRequestAgentUpdate}
disabled={
!selectedAgentRelease?.has_update ||
updateAgentMutation.isPending ||
isCheckingAgentRelease ||
node.update_requested
}
>
{updateAgentMutation.isPending
? '下发中...'
: selectedReleaseChannel === 'preview'
? '升级到预览版'
: '升级到正式版'}
</PrimaryButton>
</div>
}
>
<div className="space-y-6">
{agentUpdateFeedback ? (
<InlineMessage
tone={agentUpdateFeedback.tone}
message={agentUpdateFeedback.message}
/>
) : null}
<div className="grid gap-4 md:grid-cols-3">
<AppCard title="当前版本">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
{node.version || 'unknown'}
</p>
</AppCard>
<AppCard title="检查通道">
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm font-medium text-[var(--foreground-primary)]">
{selectedReleaseChannel === 'preview' ? '预览版' : '正式版'}
</p>
<StatusBadge
label={
selectedReleaseChannel === 'preview' ? 'Preview' : 'Stable'
}
variant={
selectedReleaseChannel === 'preview' ? 'warning' : 'info'
}
/>
</div>
</AppCard>
<AppCard title="更新状态">
<StatusBadge
label={
node.update_requested
? node.update_channel === 'preview'
? '等待预览更新'
: '等待更新'
: '未下发'
}
variant={node.update_requested ? 'warning' : 'info'}
/>
</AppCard>
</div>
{isCheckingAgentRelease && !selectedAgentRelease ? (
<LoadingState />
) : null}
{!isCheckingAgentRelease && selectedAgentReleaseError ? (
<ErrorState
title="版本检查失败"
description={getErrorMessage(selectedAgentReleaseError)}
/>
) : null}
{!isCheckingAgentRelease &&
!selectedAgentReleaseError &&
!selectedAgentRelease ? (
<EmptyState
title="尚未检查中继更新"
description="选择上面的通道进行中继软件版本检查。"
/>
) : null}
{selectedAgentRelease ? (
<AppCard
title={`GitHub ${selectedReleaseChannel === 'preview' ? '预览版' : '正式版'} · ${selectedAgentRelease.tag_name || '未找到版本'}`}
description={
selectedAgentRelease.published_at
? `发布时间:${formatRelativeTime(selectedAgentRelease.published_at)}`
: '未提供发布时间'
}
>
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3">
<StatusBadge
label={
selectedAgentRelease.has_update
? '发现可升级版本'
: '当前已是最新版本'
}
variant={
selectedAgentRelease.has_update ? 'warning' : 'success'
}
/>
{selectedAgentRelease.prerelease ? (
<StatusBadge label="Preview 发布" variant="warning" />
) : (
<StatusBadge label="正式发布" variant="info" />
)}
</div>
<div className="grid gap-4 md:grid-cols-2">
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
当前中继软件版本
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{selectedAgentRelease.current_version || 'unknown'}
</p>
</div>
<div>
<p className="text-xs text-[var(--foreground-secondary)]">
最新发布版本
</p>
<p className="mt-1 text-sm font-medium text-[var(--foreground-primary)]">
{selectedAgentRelease.tag_name || '未找到'}
</p>
</div>
</div>
<div className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4 text-sm leading-6 whitespace-pre-wrap text-[var(--foreground-secondary)]">
{selectedAgentRelease.body || '暂无更新说明'}
</div>
{selectedAgentRelease.html_url ? (
<a
href={selectedAgentRelease.html_url}
target="_blank"
rel="noreferrer"
className="text-sm font-medium text-[var(--brand-primary)] transition hover:opacity-80"
>
查看发布详情
</a>
) : null}
</div>
</AppCard>
) : null}
</div>
</AppModal>
</>
);
}