'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 { RankChart } from '@/components/data/rank-chart'; import { TrendChart } from '@/components/data/trend-chart'; 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 { ConfigVersionItem } from '@/features/config-versions/types'; import { getApplyLogs } from '@/features/apply-logs/api/apply-logs'; import { deleteNode, getNodeAgentRelease, getNodeObservability, getNodes, requestNodeOpenrestyRestart, requestNodeAgentUpdate, updateNode, } from '@/features/nodes/api/nodes'; import { NodeEditorModal } from '@/features/nodes/components/node-editor-modal'; import type { NodeAgentReleaseInfo, NodeObservability, } 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, formatBytesPerSecond, formatPercent, } from '@/lib/utils/metrics'; import { buildNodeInstallCommand, getApplyLabel, getApplyVariant, getNodeStatusLabel, getNodeStatusVariant, getOpenrestyStatusLabel, getOpenrestyStatusVariant, getServerUrl, getUpdateMode, isMeaningfulTime, } from '@/features/nodes/utils'; const nodesQueryKey = ['nodes']; type FeedbackState = { tone: 'info' | 'success' | 'danger'; message: string; }; type HealthEventFilter = 'all' | 'active' | 'resolved'; type NodeDetailTab = 'dashboard' | 'info'; function getErrorMessage(error: unknown) { return error instanceof Error ? error.message : '请求失败,请稍后重试。'; } async function copyToClipboard(value: string) { await navigator.clipboard.writeText(value); } function formatUsageRatio(used?: number | null, total?: number | null) { if (!used || !total || total <= 0) { return null; } return Math.max(0, Math.min(100, (used / total) * 100)); } function formatUptime(seconds?: number | null) { if (!seconds || seconds <= 0) { return '—'; } const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); if (days > 0) { return `${days} 天 ${hours} 小时`; } if (hours > 0) { return `${hours} 小时 ${minutes} 分钟`; } return `${minutes} 分钟`; } function formatTrendHour(value: string) { const date = new Date(value); if (Number.isNaN(date.getTime())) { return '—'; } return `${date.getHours().toString().padStart(2, '0')}:00`; } function parseTrafficMap(value?: string | null) { if (!value) { return {} as Record; } try { const parsed = JSON.parse(value) as Record; return Object.entries(parsed).reduce>( (result, [key, count]) => { if (typeof count === 'number' && Number.isFinite(count)) { result[key] = count; } return result; }, {}, ); } catch { return {} as Record; } } function aggregateTrafficBreakdown( reports: NodeObservability['traffic_reports'], field: 'status_codes_json' | 'top_domains_json', ) { const summary = new Map(); for (const report of reports) { const parsed = parseTrafficMap(report[field]); for (const [key, value] of Object.entries(parsed)) { summary.set(key, (summary.get(key) ?? 0) + value); } } return Array.from(summary.entries()) .sort((left, right) => { if (right[1] === left[1]) { return left[0].localeCompare(right[0]); } return right[1] - left[1]; }) .slice(0, 6) .map(([label, value]) => ({ label, value })); } function getHealthEventVariant( event: NodeObservability['health_events'][number], ): 'success' | 'warning' | 'danger' | 'info' { if (event.status === 'resolved') { return 'success'; } if (event.severity === 'critical') { return 'danger'; } if (event.severity === 'warning') { return 'warning'; } return 'info'; } function getHealthEventLabel( event: NodeObservability['health_events'][number], ) { return event.event_type.replaceAll('_', ' '); } function MetricBar({ label, value, progress, hint, }: { label: string; value: string; progress?: number | null; hint?: string; }) { return (

{label}

{hint ? (

{hint}

) : null}

{value}

); } function SummaryStat({ label, value, hint, }: { label: string; value: string; hint: string; }) { return (

{label}

{value}

{hint}

); } export function NodeDetailPage({ nodeId }: { nodeId: string }) { const router = useRouter(); const queryClient = useQueryClient(); const [feedback, setFeedback] = useState(null); const [isEditorOpen, setIsEditorOpen] = useState(false); const [isAgentUpdateModalOpen, setIsAgentUpdateModalOpen] = useState(false); const [isTargetSnapshotOpen, setIsTargetSnapshotOpen] = useState(false); const [selectedReleaseChannel, setSelectedReleaseChannel] = useState('stable'); const [agentUpdateFeedback, setAgentUpdateFeedback] = useState(null); const [serverUrl, setServerUrl] = useState(''); const [healthEventFilter, setHealthEventFilter] = useState('all'); const [activeTab, setActiveTab] = useState('dashboard'); const nodesQuery = useQuery({ queryKey: nodesQueryKey, queryFn: getNodes, refetchInterval: 5000, }); 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 node = useMemo(() => { return ( (nodesQuery.data ?? []).find((item) => String(item.id) === nodeId) ?? null ); }, [nodeId, nodesQuery.data]); const applyLogsQuery = useQuery({ queryKey: ['apply-logs', node?.node_id ?? ''], queryFn: () => getApplyLogs(node?.node_id), enabled: Boolean(node?.node_id), 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 }), enabled: Boolean(nodeId), refetchInterval: 10000, }); useEffect(() => { if (typeof window !== 'undefined' && !serverUrl) { setServerUrl(window.location.origin); } }, [serverUrl]); const saveMutation = useMutation({ mutationFn: async (payload: Parameters[1]) => updateNode(Number(nodeId), payload), onSuccess: async () => { setFeedback({ tone: 'success', message: '节点已更新。' }); setIsEditorOpen(false); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, 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: nodesQueryKey }); }, onError: (error) => { const message = getErrorMessage(error); setFeedback({ tone: 'danger', message }); setAgentUpdateFeedback({ tone: 'danger', message }); }, }); const restartOpenrestyMutation = useMutation({ mutationFn: () => requestNodeOpenrestyRestart(Number(nodeId)), onSuccess: async (updatedNode) => { setFeedback({ tone: 'success', message: `已向节点 ${updatedNode.name} 下发 OpenResty 重启指令。`, }); await queryClient.invalidateQueries({ queryKey: nodesQueryKey }); }, 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: nodesQueryKey }); router.push('/node'); }, onError: (error) => { setFeedback({ tone: 'danger', message: getErrorMessage(error) }); }, }); const handleDelete = () => { if (!node) { return; } if ( !window.confirm( `确认删除节点“${node.name}”吗?删除后该节点需要重新创建并重新接入。`, ) ) { return; } setFeedback(null); deleteMutation.mutate(); }; const handleRestartOpenresty = () => { if (!node) { return; } if ( !window.confirm( `确认向节点“${node.name}”下发 OpenResty 重启指令吗?该指令会在下一次心跳后执行。`, ) ) { return; } setFeedback(null); restartOpenrestyMutation.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(() => { 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 statusCodeDistribution = useMemo( () => aggregateTrafficBreakdown( observability?.traffic_reports ?? [], 'status_codes_json', ), [observability?.traffic_reports], ); const topDomains = useMemo( () => aggregateTrafficBreakdown( observability?.traffic_reports ?? [], 'top_domains_json', ), [observability?.traffic_reports], ); 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; }>, [], ); if (nodesQuery.isLoading) { return ; } if (nodesQuery.isError) { return ( ); } if (!node) { return ( ); } const normalizedServerUrl = getServerUrl(serverUrl); const nodeInstallCommand = normalizedServerUrl && node.agent_token ? buildNodeInstallCommand(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 ?? []; const dominantStatusCode = statusCodeDistribution[0] ?? null; const dominantDomain = topDomains[0] ?? null; const topSourceCountry = observability?.analytics.distributions.source_countries[0] ?? null; const trafficSummary = observability?.analytics.traffic ?? null; const healthSummary = observability?.analytics.health ?? null; 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 = nodesQuery.isFetching || applyLogsQuery.isFetching || observabilityQuery.isFetching; const handleRefresh = async () => { setFeedback(null); await Promise.all([ queryClient.invalidateQueries({ queryKey: nodesQueryKey }), queryClient.invalidateQueries({ queryKey: ['apply-logs', node.node_id], }), queryClient.invalidateQueries({ queryKey: ['config-versions'], }), queryClient.invalidateQueries({ queryKey: ['node-observability', nodeId], }), ]); }; return ( <>
返回 setIsEditorOpen(true)} > 编辑节点 void handleRefresh()} disabled={isRefreshing} > {isRefreshing ? '刷新中...' : '刷新'} {node.update_requested ? '查看升级' : '升级'} 删除 } /> {feedback ? ( ) : null}
{tabs.map((tab) => ( ))}
{activeTab === 'dashboard' ? ( <>
{observabilityQuery.isLoading ? ( ) : observabilityQuery.isError ? ( ) : observability?.profile ? (

主机名

{observability.profile.hostname || node.name}

操作系统

{observability.profile.os_name || 'unknown'} {observability.profile.os_version ? ` ${observability.profile.os_version}` : ''}

内核 / 架构

{observability.profile.kernel_version || 'unknown'} ·{' '} {observability.profile.architecture || 'unknown'}

在线时长

{formatUptime(observability.profile.uptime_seconds)}

CPU

{observability.profile.cpu_model || 'unknown'}

{observability.profile.cpu_cores || 0} 核

总内存

{formatBytes( observability.profile.total_memory_bytes, )}

总存储

{formatBytes(observability.profile.total_disk_bytes)}

上报时间

{isMeaningfulTime(observability.profile.reported_at) ? formatDateTime(observability.profile.reported_at) : '—'}

) : ( )}
{observabilityQuery.isLoading ? ( ) : observabilityQuery.isError ? ( ) : latestMetricSnapshot ? (
) : ( )}
{observabilityQuery.isLoading ? ( ) : observabilityQuery.isError ? ( ) : latestMetricSnapshot ? (

OpenResty 吞吐

入站: {formatBytesPerSecond( latestMetricSnapshot.openresty_rx_bytes, 60, )}

出站: {formatBytesPerSecond( latestMetricSnapshot.openresty_tx_bytes, 60, )}

节点网络

入站: {formatBytes(latestMetricSnapshot.network_rx_bytes)}

出站: {formatBytes(latestMetricSnapshot.network_tx_bytes)}

最近窗口请求

{trafficSummary ? trafficSummary.request_count.toLocaleString( 'zh-CN', ) : '—'}

{trafficSummary ? `QPS ${trafficSummary.estimated_qps.toFixed(1)} · UV ${trafficSummary.unique_visitor_count}` : '暂无窗口流量摘要'}

最近窗口错误

{trafficSummary ? trafficSummary.error_count.toLocaleString('zh-CN') : '—'}

{trafficSummary ? `错误率 ${trafficSummary.error_rate_percent.toFixed(1)}%` : '暂无错误率摘要'}

) : ( )}
formatTrendHour(point.bucket_started_at), ) ?? [] } series={[ { label: '请求量', color: '#f59e0b', fillColor: 'rgba(245, 158, 11, 0.18)', variant: 'area', values: observability?.trends.traffic_24h.map( (point) => point.request_count, ) ?? [], }, { label: '错误量', color: '#ef4444', values: observability?.trends.traffic_24h.map( (point) => point.error_count, ) ?? [], }, ]} /> formatTrendHour(point.bucket_started_at), ) ?? [] } yAxisValueFormatter={formatPercent} series={[ { label: '平均 CPU', color: '#0f766e', fillColor: 'rgba(15, 118, 110, 0.15)', variant: 'area', values: observability?.trends.capacity_24h.map( (point) => point.average_cpu_usage_percent, ) ?? [], valueFormatter: formatPercent, }, { label: '平均内存', color: '#2563eb', values: observability?.trends.capacity_24h.map( (point) => point.average_memory_usage_percent, ) ?? [], valueFormatter: formatPercent, }, ]} />
formatTrendHour(point.bucket_started_at), ) ?? [] } yAxisValueFormatter={(value) => formatBytesPerSecond(value, 3600) } series={[ { label: 'OpenResty 入站', color: '#22c55e', fillColor: 'rgba(34, 197, 94, 0.14)', variant: 'area', values: observability?.trends.network_24h.map( (point) => point.openresty_rx_bytes, ) ?? [], valueFormatter: (value) => formatBytesPerSecond(value, 3600), }, { label: 'OpenResty 出站', color: '#38bdf8', values: observability?.trends.network_24h.map( (point) => point.openresty_tx_bytes, ) ?? [], valueFormatter: (value) => formatBytesPerSecond(value, 3600), }, ]} /> formatTrendHour(point.bucket_started_at), ) ?? [] } yAxisValueFormatter={formatBytes} series={[ { label: '磁盘读', color: '#a78bfa', fillColor: 'rgba(167, 139, 250, 0.14)', variant: 'area', values: observability?.trends.disk_io_24h.map( (point) => point.disk_read_bytes, ) ?? [], valueFormatter: formatBytes, }, { label: '磁盘写', color: '#fb7185', values: observability?.trends.disk_io_24h.map( (point) => point.disk_write_bytes, ) ?? [], valueFormatter: formatBytes, }, ]} />

主状态码

{dominantStatusCode?.label ?? '—'}

{dominantStatusCode ? `${dominantStatusCode.value} 次` : '暂无状态码聚合'}

Top Domain

{dominantDomain?.label ?? '—'}

{dominantDomain ? `${dominantDomain.value} 次` : '暂无域名聚合'}

已恢复事件

{resolvedHealthEvents.length}

最近 24 小时已恢复健康事件

状态码分布

Top Domain

{observability?.health_events.length ? (
{filteredHealthEvents.slice(0, 8).map((event) => (

{event.message || '暂无详细消息'}

首次触发: {isMeaningfulTime(event.first_triggered_at) ? ` ${formatDateTime(event.first_triggered_at)}` : ' —'}

最近触发: {isMeaningfulTime(event.last_triggered_at) ? ` ${formatDateTime(event.last_triggered_at)}` : ' —'}

恢复时间: {isMeaningfulTime(event.resolved_at) ? ` ${formatDateTime(event.resolved_at)}` : ' —'}

))} {filteredHealthEvents.length === 0 ? (
当前筛选下没有健康事件。
) : null}
) : ( )}
) : null} {activeTab === 'info' ? ( <>

{node.update_requested ? `已等待节点在下一次心跳后执行${node.update_channel === 'preview' ? '预览版' : '正式版'}更新。` : node.auto_update_enabled ? '节点已启用正式版自动更新。' : '当前仅支持手动触发更新。'}

Agent:{node.agent_version || 'unknown'}

Nginx:{node.nginx_version || 'unknown'}

当前配置:{node.current_version || '未应用'}

{isMeaningfulTime(node.latest_apply_at) ? `${formatRelativeTime( node.latest_apply_at, )} · ${formatDateTime(node.latest_apply_at)}` : '暂无应用记录'}

{node.latest_apply_checksum ? (

支持文件:{node.latest_support_file_count}

) : null}
setIsTargetSnapshotOpen(true)} > 查看目标快照 ) : null } > {configVersionsQuery.isLoading ? ( ) : configVersionsQuery.isError ? ( ) : activeConfigVersion ? (

追平状态

{isTargetVersionApplied ? '当前节点已应用全局激活配置。' : '当前节点版本落后于全局激活配置,可结合应用记录定位原因。'}

目标版本

{activeConfigVersion.version}

Target Checksum

{activeConfigVersion.checksum}

激活时间

{formatDateTime(activeConfigVersion.created_at)}

) : ( )}
{restartOpenrestyMutation.isPending ? '下发重启中...' : node.restart_openresty_requested ? '等待 OpenResty 重启' : '重启 OpenResty'}
} >

健康状态

{node.restart_openresty_requested ? ( ) : null}

{node.restart_openresty_requested ? '已等待节点在下一次心跳后执行 OpenResty 重启。' : '系统会在每次心跳前自动采集健康状态。'}

状态消息

{node.openresty_message || '当前未上报额外错误。'}

void handleCopy( nodeInstallCommand, '节点专属部署命令已复制。', ) } > 复制部署命令 ) : null } >

Node ID

{node.node_id}

Agent Token

{node.agent_token || '暂无'}

setServerUrl(event.target.value)} /> {nodeInstallCommand ? ( {nodeInstallCommand} ) : null}

IP 地址

{node.ip || '暂无'}

最近错误

{node.last_error || '无'}

OpenResty 状态消息

{node.openresty_message || '无'}

创建时间

{formatDateTime(node.created_at)}

更新时间

{formatDateTime(node.updated_at)}

查看完整记录 } > {applyLogsQuery.isLoading ? ( ) : applyLogsQuery.isError ? ( ) : applyLogs.length === 0 ? ( ) : (
{applyLogs.slice(0, 10).map((log) => ( ))}
版本 结果 Checksum 时间 消息
{log.version} {log.checksum ? `${log.checksum.slice(0, 12)}...` : '—'} {formatRelativeTime(log.created_at)} ·{' '} {formatDateTime(log.created_at)}

{log.main_config_checksum ? `主配置:${log.main_config_checksum}` : ''}

{log.route_config_checksum ? `路由配置:${log.route_config_checksum}` : ''}

{log.support_file_count ? `支持文件:${log.support_file_count}` : ''}

{log.message || '—'}

)}
) : null}
setIsTargetSnapshotOpen(false)} /> setIsEditorOpen(false)} title="编辑节点" description="更新模式和节点名都在详情页维护。" submitLabel="保存修改" onSubmit={(payload) => { setFeedback(null); saveMutation.mutate(payload); }} /> setIsAgentUpdateModalOpen(false)} title="Agent 更新" description="默认检查正式版;你也可以手动检查 preview 发布,并选择向当前节点下发对应版本的升级指令。" footer={
{isCheckingAgentRelease && selectedReleaseChannel === 'stable' ? '检查中...' : '检查正式版'} {isCheckingAgentRelease && selectedReleaseChannel === 'preview' ? '检查中...' : '检查预览版'} {updateAgentMutation.isPending ? '下发中...' : selectedReleaseChannel === 'preview' ? '升级到预览版' : '升级到正式版'}
} >
{agentUpdateFeedback ? ( ) : null}

{node.agent_version || 'unknown'}

{selectedReleaseChannel === 'preview' ? '预览版' : '正式版'}

{isCheckingAgentRelease && !selectedAgentRelease ? ( ) : null} {!isCheckingAgentRelease && selectedAgentReleaseError ? ( ) : null} {!isCheckingAgentRelease && !selectedAgentReleaseError && !selectedAgentRelease ? ( ) : null} {selectedAgentRelease ? (
{selectedAgentRelease.prerelease ? ( ) : ( )} {node.update_requested ? ( ) : null}

当前版本

{selectedAgentRelease.current_version || 'unknown'}

目标版本

{selectedAgentRelease.tag_name || '未找到'}

{selectedAgentRelease.body || '暂无更新说明'}
{selectedAgentRelease.html_url ? ( 查看发布详情 ) : null}
) : null}
); }