mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
1308 lines
53 KiB
TypeScript
1308 lines
53 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 {
|
|
buildTunnelInstallCommand,
|
|
buildTunnelDockerInstallCommand,
|
|
getApplyLabel,
|
|
getApplyVariant,
|
|
getNodeStatusLabel,
|
|
getNodeStatusVariant,
|
|
getServerUrl,
|
|
getUpdateMode,
|
|
isMeaningfulTime,
|
|
} from '@/features/nodes/utils';
|
|
import {
|
|
copyToClipboard,
|
|
formatUsageRatio,
|
|
formatUptime,
|
|
getErrorMessage,
|
|
getHealthEventLabel,
|
|
getHealthEventVariant,
|
|
FeedbackState,
|
|
HealthEventFilter,
|
|
NodeDetailTab,
|
|
MetricBar,
|
|
SummaryStat,
|
|
} from './node-shared';
|
|
|
|
export function TunnelDetailPage({ 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' ? '预览版' : '正式版'} Agent 更新。`,
|
|
});
|
|
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 tunnelInstallCommand =
|
|
normalizedServerUrl && node.agent_token
|
|
? buildTunnelInstallCommand(normalizedServerUrl, node.agent_token)
|
|
: '';
|
|
const tunnelDockerInstallCommand =
|
|
normalizedServerUrl && node.agent_token
|
|
? buildTunnelDockerInstallCommand(normalizedServerUrl, node.agent_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="隧道客户端节点详情 (openflared)"
|
|
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-3">
|
|
<SummaryStat
|
|
label="运行诊断"
|
|
value={
|
|
activeHealthEvents.length
|
|
? `${activeHealthEvents.length} 个活动异常`
|
|
: '运行稳定'
|
|
}
|
|
hint={
|
|
latestHealthEvent
|
|
? `${getHealthEventLabel(latestHealthEvent)} · ${latestHealthEvent.message || '等待处理'}`
|
|
: '当前没有活动中的健康事件'
|
|
}
|
|
/>
|
|
<SummaryStat
|
|
label="当前状态"
|
|
value={getNodeStatusLabel(node.status)}
|
|
hint={`最后心跳上报: ${node.last_seen_at ? formatRelativeTime(node.last_seen_at) : '—'}`}
|
|
/>
|
|
<SummaryStat
|
|
label="系统核心"
|
|
value={observability?.profile?.hostname || node.name}
|
|
hint={`${observability?.profile?.os_name || '—'} · ${observability?.profile?.architecture || '—'}`}
|
|
/>
|
|
</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>
|
|
</>
|
|
) : 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>Client (openflared): {node.agent_version || 'unknown'}</p>
|
|
<p>当前配置版本:{node.current_version || '未应用'}</p>
|
|
<p>节点类型:隧道客户端 (tunnel_client)</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">
|
|
{tunnelInstallCommand ? (
|
|
<PrimaryButton
|
|
type="button"
|
|
onClick={() =>
|
|
void handleCopy(
|
|
tunnelInstallCommand,
|
|
'部署脚本命令已复制。',
|
|
)
|
|
}
|
|
>
|
|
复制脚本命令
|
|
</PrimaryButton>
|
|
) : null}
|
|
{tunnelDockerInstallCommand ? (
|
|
<PrimaryButton
|
|
type="button"
|
|
onClick={() =>
|
|
void handleCopy(
|
|
tunnelDockerInstallCommand,
|
|
'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">
|
|
Tunnel Token
|
|
</p>
|
|
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
|
|
{node.agent_token || '暂无'}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<ResourceField
|
|
label="Server URL (控制端地址)"
|
|
hint="部署脚本和容器以此地址接入 OpenFlare 控制端。"
|
|
>
|
|
<ResourceInput
|
|
value={serverUrl}
|
|
onChange={(event) => setServerUrl(event.target.value)}
|
|
/>
|
|
</ResourceField>
|
|
|
|
{tunnelInstallCommand ? (
|
|
<div>
|
|
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
|
一键脚本部署 (Linux / macOS)
|
|
</p>
|
|
<CodeBlock className="whitespace-pre-wrap">
|
|
{tunnelInstallCommand}
|
|
</CodeBlock>
|
|
</div>
|
|
) : null}
|
|
|
|
{tunnelDockerInstallCommand ? (
|
|
<div>
|
|
<p className="mb-2 text-sm font-medium text-[var(--foreground-primary)]">
|
|
Docker 容器部署
|
|
</p>
|
|
<CodeBlock className="whitespace-pre-wrap">
|
|
{tunnelDockerInstallCommand}
|
|
</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 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="可以修改节点名称、IP等标识信息。"
|
|
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="客户端 Agent 升级"
|
|
description="管理当前客户端 (flared/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.agent_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>
|
|
</>
|
|
);
|
|
}
|