Files
OpenFlare/openflare-server/web/features/nodes/components/relay-detail-page.tsx
T
2026-06-06 10:46:40 +08:00

1328 lines
53 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
requestNodeAgentUpdate,
requestNodeForceSync,
updateNode,
} from '@/features/nodes/api/nodes';
import {NodeEditorModal} from '@/features/nodes/components/node-editor-modal';
import type {NodeAgentReleaseInfo, NodeItem} 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 {
buildRelayDockerInstallCommand,
buildRelayInstallCommand,
getApplyLabel,
getApplyVariant,
getServerUrl,
getUpdateMode,
isMeaningfulTime,
isWSConnectedLastSeen,
} from '@/features/nodes/utils';
import {
copyToClipboard,
FeedbackState,
formatUptime,
formatUsageRatio,
getErrorMessage,
getHealthEventLabel,
getHealthEventVariant,
HealthEventFilter,
MetricBar,
NodeDetailTab,
} 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 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-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>
</>
) : 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>
{node.relay_web_server_enabled ? (
<p>
FRPS WebUI:
<a
href={`http://${node.ip || '127.0.0.1'}:${node.relay_bind_port + 500}`}
target="_blank"
rel="noreferrer"
className="font-medium text-[var(--accent-strong)] hover:underline"
>
点击打开 Web 界面
</a>
</p>
) : (
<p>FRPS WebUI:已禁用</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>
</>
);
}