feat: enhance apply logs and config versions features

- Add detailed diff reporting for OpenResty options in TestPreviewAndDiffConfigVersion.
- Update ApplyLogsPage to display additional log details including checksums and support file counts.
- Introduce ConfigVersionSnapshotModal for viewing configuration snapshots.
- Refactor config versions page to utilize new snapshot modal and improve option diff display.
- Extend ApplyLogItem type to include checksums and support file count.
- Enhance NodeDetailPage to show target version details and checksums.
This commit is contained in:
ryan
2026-03-12 22:49:18 +08:00
parent 4c0466a92b
commit 594057be06
13 changed files with 618 additions and 234 deletions
@@ -39,12 +39,26 @@ function buildSummary(logs: ApplyLogItem[]) {
return [
{ label: '记录总数', value: logs.length },
{ label: '成功', value: logs.filter((item) => item.result === 'success').length },
{ label: '失败', value: logs.filter((item) => item.result !== 'success').length },
{
label: '成功',
value: logs.filter((item) => item.result === 'success').length,
},
{
label: '失败',
value: logs.filter((item) => item.result !== 'success').length,
},
{ label: '节点数', value: nodeIds.size },
];
}
function truncateHash(value: string) {
if (!value) {
return '—';
}
return value.length > 12 ? `${value.slice(0, 12)}...` : value;
}
export function ApplyLogsPage() {
const queryClient = useQueryClient();
const [nodeFilterInput, setNodeFilterInput] = useState('');
@@ -85,62 +99,73 @@ export function ApplyLogsPage() {
};
return (
<div className='space-y-6'>
<div className="space-y-6">
<PageHeader
title='应用记录'
description='查看节点应用版本的成功或失败记录,支持按 node_id 过滤并查看单条详情。'
title="应用记录"
description="查看节点应用版本的成功或失败记录,支持按 node_id 过滤并查看单条详情。"
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)]'
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>
}
/>
{feedback ? <InlineMessage tone='info' message={feedback} /> : null}
{feedback ? <InlineMessage tone="info" message={feedback} /> : null}
<AppCard title='记录摘要' description='帮助快速识别失败趋势和受影响节点范围。'>
<div className='grid gap-4 md:grid-cols-2 xl:grid-cols-4'>
<AppCard
title="记录摘要"
description="帮助快速识别失败趋势和受影响节点范围。"
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summary.map((item) => (
<div
key={item.label}
className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'
className="rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4"
>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>{item.label}</p>
<p className='mt-2 text-lg font-semibold text-[var(--foreground-primary)]'>{item.value}</p>
<p className="text-xs tracking-[0.2em] text-[var(--foreground-muted)] uppercase">
{item.label}
</p>
<p className="mt-2 text-lg font-semibold text-[var(--foreground-primary)]">
{item.value}
</p>
</div>
))}
</div>
</AppCard>
<div className='grid gap-6 xl:grid-cols-[1.2fr_0.8fr]'>
<div className="grid gap-6 xl:grid-cols-[1.2fr_0.8fr]">
<AppCard
title='过滤与列表'
description='默认展示全部记录,可按 node_id 快速筛选单节点应用结果。'
title="过滤与列表"
description="默认展示全部记录,可按 node_id 快速筛选单节点应用结果。"
action={
<SecondaryButton
type='button'
onClick={() => void queryClient.invalidateQueries({ queryKey: applyLogsQueryKey(nodeFilter) })}
type="button"
onClick={() =>
void queryClient.invalidateQueries({
queryKey: applyLogsQueryKey(nodeFilter),
})
}
>
刷新
</SecondaryButton>
}
>
<div className='space-y-5'>
<div className='flex flex-col gap-3 lg:flex-row lg:items-center'>
<div className="space-y-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<ResourceInput
value={nodeFilterInput}
onChange={(event) => setNodeFilterInput(event.target.value)}
placeholder='输入 node_id 过滤应用记录'
className='lg:max-w-md'
placeholder="输入 node_id 过滤应用记录"
className="lg:max-w-md"
/>
<div className='flex flex-wrap gap-2'>
<PrimaryButton type='button' onClick={handleSearch}>
<div className="flex flex-wrap gap-2">
<PrimaryButton type="button" onClick={handleSearch}>
筛选
</PrimaryButton>
<SecondaryButton type='button' onClick={handleReset}>
<SecondaryButton type="button" onClick={handleReset}>
清空
</SecondaryButton>
</div>
@@ -149,45 +174,69 @@ export function ApplyLogsPage() {
{logsQuery.isLoading ? (
<LoadingState />
) : logsQuery.isError ? (
<ErrorState title='应用记录加载失败' description={getErrorMessage(logsQuery.error)} />
<ErrorState
title="应用记录加载失败"
description={getErrorMessage(logsQuery.error)}
/>
) : logs.length === 0 ? (
<EmptyState title='暂无应用记录' description='当前筛选条件下没有可展示的应用结果。' />
<EmptyState
title="暂无应用记录"
description="当前筛选条件下没有可展示的应用结果。"
/>
) : (
<div className='overflow-x-auto'>
<table className='min-w-full divide-y divide-[var(--border-default)] text-left text-sm'>
<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'>Node ID</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 className="text-[var(--foreground-secondary)]">
<th className="px-3 py-3 font-medium">Node ID</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">Checksum</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)]'>
<tbody className="divide-y divide-[var(--border-default)]">
{logs.map((log) => {
const resultMeta = getResultMeta(log.result);
return (
<tr key={log.id} className='align-top'>
<td className='px-3 py-4 font-medium text-[var(--foreground-primary)]'>{log.node_id}</td>
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>{log.version}</td>
<td className='px-3 py-4'>
<StatusBadge label={resultMeta.label} variant={resultMeta.variant} />
<tr key={log.id} className="align-top">
<td className="px-3 py-4 font-medium text-[var(--foreground-primary)]">
{log.node_id}
</td>
<td className='px-3 py-4 text-[var(--foreground-secondary)]'>
<div className='space-y-1'>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
{log.version}
</td>
<td className="px-3 py-4">
<StatusBadge
label={resultMeta.label}
variant={resultMeta.variant}
/>
</td>
<td
className="px-3 py-4 text-[var(--foreground-secondary)]"
title={log.checksum}
>
{truncateHash(log.checksum)}
</td>
<td className="px-3 py-4 text-[var(--foreground-secondary)]">
<div className="space-y-1">
<p>{formatDateTime(log.created_at)}</p>
<p className='text-xs'>{formatRelativeTime(log.created_at)}</p>
<p className="text-xs">
{formatRelativeTime(log.created_at)}
</p>
</div>
</td>
<td className='px-3 py-4'>
<td className="px-3 py-4">
<SecondaryButton
type='button'
type="button"
onClick={() => {
setSelectedLogId(log.id);
setFeedback(`已选中节点 ${log.node_id} 的版本 ${log.version} 记录。`);
setFeedback(
`已选中节点 ${log.node_id} 的版本 ${log.version} 记录。`,
);
}}
className='px-3 py-2 text-xs'
className="px-3 py-2 text-xs"
>
查看详情
</SecondaryButton>
@@ -202,39 +251,92 @@ export function ApplyLogsPage() {
</div>
</AppCard>
<AppCard title='记录详情' description='展示所选应用记录的完整结果与错误信息。'>
<AppCard
title="记录详情"
description="展示所选应用记录的完整结果与错误信息。"
>
{selectedLog ? (
<div className='space-y-4'>
<div className='flex flex-wrap gap-2'>
<div className="space-y-4">
<div className="flex flex-wrap gap-2">
<StatusBadge {...getResultMeta(selectedLog.result)} />
<StatusBadge label={`Node:${selectedLog.node_id}`} variant='info' />
<StatusBadge label={`版本:${selectedLog.version}`} variant='warning' />
<StatusBadge
label={`Node:${selectedLog.node_id}`}
variant="info"
/>
<StatusBadge
label={`版本:${selectedLog.version}`}
variant="warning"
/>
</div>
<div className='grid gap-4 sm:grid-cols-2'>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>创建时间</p>
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>
<div className="grid gap-4 sm: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">
创建时间
</p>
<p className="mt-2 text-sm text-[var(--foreground-primary)]">
{formatDateTime(selectedLog.created_at)}
</p>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>相对时间</p>
<p className='mt-2 text-sm text-[var(--foreground-primary)]'>
<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)]">
{formatRelativeTime(selectedLog.created_at)}
</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">
目标 Checksum
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{selectedLog.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)]">
{selectedLog.support_file_count}
</p>
</div>
</div>
<div className='rounded-2xl border border-[var(--border-default)] bg-[var(--surface-elevated)] px-4 py-4'>
<p className='text-xs uppercase tracking-[0.2em] text-[var(--foreground-muted)]'>应用信息</p>
<p className='mt-3 whitespace-pre-wrap break-words text-sm leading-6 text-[var(--foreground-primary)]'>
<div className="grid gap-4 sm: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">
主配置摘要
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{selectedLog.main_config_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 break-all text-[var(--foreground-primary)]">
{selectedLog.route_config_checksum || '无'}
</p>
</div>
</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-3 text-sm leading-6 break-words whitespace-pre-wrap text-[var(--foreground-primary)]">
{selectedLog.message || '无附加信息'}
</p>
</div>
</div>
) : (
<EmptyState title='未选择记录' description='请先从左侧列表中选择一条应用记录查看详情。' />
<EmptyState
title="未选择记录"
description="请先从左侧列表中选择一条应用记录查看详情。"
/>
)}
</AppCard>
</div>
@@ -4,5 +4,9 @@ export interface ApplyLogItem {
version: string;
result: 'success' | 'failed' | string;
message: string;
checksum: string;
main_config_checksum: string;
route_config_checksum: string;
support_file_count: number;
created_at: string;
}
@@ -0,0 +1,90 @@
'use client';
import { AppModal } from '@/components/ui/app-modal';
import type { ConfigVersionItem } from '@/features/config-versions/types';
import {
CodeBlock,
SecondaryButton,
} from '@/features/shared/components/resource-primitives';
import { formatDateTime } from '@/lib/utils/date';
export function ConfigVersionSnapshotModal({
version,
onClose,
}: {
version: ConfigVersionItem | null;
onClose: () => void;
}) {
return (
<AppModal
isOpen={Boolean(version)}
onClose={onClose}
title={version ? `版本 ${version.version}` : '查看快照'}
description="在弹窗中查看快照 JSON 与渲染结果,避免把页面主视图撑长。"
size="xl"
footer={
<div className="flex justify-end">
<SecondaryButton type="button" onClick={onClose}>
关闭
</SecondaryButton>
</div>
}
>
{version ? (
<div className="space-y-5">
<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">
Checksum
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{version.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)]">
{version.created_by || '系统'}
</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(version.created_at)}
</p>
</div>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
快照 JSON
</p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.snapshot_json}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
主配置
</p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.main_config}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
路由配置
</p>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{version.rendered_config}
</CodeBlock>
</div>
</div>
) : null}
</AppModal>
);
}
@@ -8,7 +8,6 @@ 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 {
@@ -18,7 +17,9 @@ import {
getConfigVersions,
publishConfigVersion,
} from '@/features/config-versions/api/config-versions';
import { ConfigVersionSnapshotModal } from '@/features/config-versions/components/config-version-snapshot-modal';
import type {
ConfigOptionDiffItem,
ConfigDiffResult,
ConfigPreviewResult,
ConfigVersionItem,
@@ -121,84 +122,46 @@ function SupportFilesList({ files }: { files: SupportFile[] }) {
);
}
function SnapshotModal({
version,
onClose,
}: {
version: ConfigVersionItem | null;
onClose: () => void;
}) {
return (
<AppModal
isOpen={Boolean(version)}
onClose={onClose}
title={version ? `版本 ${version.version}` : '查看快照'}
description="在弹窗中查看快照 JSON 与渲染结果,避免把版本页主视图撑长。"
size="xl"
footer={
<div className="flex justify-end">
<SecondaryButton type="button" onClick={onClose}>
关闭
</SecondaryButton>
</div>
}
>
{version ? (
<div className="space-y-5">
<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">
Checksum
</p>
<p className="mt-2 text-sm break-all text-[var(--foreground-primary)]">
{version.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)]">
{version.created_by || '系统'}
</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(version.created_at)}
</p>
</div>
</div>
function renderOptionValue(value: string) {
return value === '' ? '空' : value;
}
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
快照 JSON
</p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.snapshot_json}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
主配置
</p>
<CodeBlock className="max-h-96 whitespace-pre-wrap">
{version.main_config}
</CodeBlock>
</div>
<div>
<p className="mb-2 text-sm font-semibold text-[var(--foreground-primary)]">
路由配置
</p>
<CodeBlock className="max-h-[32rem] whitespace-pre-wrap">
{version.rendered_config}
</CodeBlock>
</div>
</div>
) : null}
</AppModal>
function OptionDiffTable({ items }: { items: ConfigOptionDiffItem[] }) {
if (items.length === 0) {
return (
<p className="text-sm text-[var(--foreground-secondary)]">
当前无 OpenResty 性能参数变化。
</p>
);
}
return (
<div className="overflow-x-auto rounded-2xl border border-[var(--border-default)]">
<table className="min-w-full divide-y divide-[var(--border-default)] text-left text-sm">
<thead>
<tr className="bg-[var(--surface-elevated)] 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>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-default)]">
{items.map((item) => (
<tr key={item.key} className="align-top">
<td className="px-3 py-3 font-medium text-[var(--foreground-primary)]">
{item.key}
</td>
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
<code>{renderOptionValue(item.previous_value)}</code>
</td>
<td className="px-3 py-3 text-[var(--foreground-secondary)]">
<code>{renderOptionValue(item.current_value)}</code>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
@@ -295,22 +258,7 @@ function PublishPreviewCard({
variant={diff.changed_option_keys.length > 0 ? 'info' : 'warning'}
/>
</div>
{diff.changed_option_keys.length > 0 ? (
<div className="flex flex-wrap gap-2">
{diff.changed_option_keys.map((item) => (
<span
key={item}
className="rounded-full border border-[var(--border-default)] bg-[var(--surface-elevated)] px-3 py-1 text-xs text-[var(--foreground-secondary)]"
>
{item}
</span>
))}
</div>
) : (
<p className="text-sm text-[var(--foreground-secondary)]">
当前无 OpenResty 性能参数变化。
</p>
)}
<OptionDiffTable items={diff.changed_option_details} />
</div>
<div>
@@ -579,7 +527,7 @@ export function ConfigVersionsPage() {
</AppCard>
</div>
<SnapshotModal
<ConfigVersionSnapshotModal
version={selectedVersion}
onClose={() => setSelectedVersionId(null)}
/>
@@ -33,4 +33,11 @@ export interface ConfigDiffResult {
modified_domains: string[];
main_config_changed: boolean;
changed_option_keys: string[];
changed_option_details: ConfigOptionDiffItem[];
}
export interface ConfigOptionDiffItem {
key: string;
previous_value: string;
current_value: string;
}
@@ -16,6 +16,9 @@ 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,
@@ -97,6 +100,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
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] =
@@ -144,6 +148,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
refetchInterval: 5000,
});
const configVersionsQuery = useQuery({
queryKey: ['config-versions'],
queryFn: getConfigVersions,
refetchInterval: 5000,
});
useEffect(() => {
if (typeof window !== 'undefined' && !serverUrl) {
setServerUrl(window.location.origin);
@@ -279,6 +289,12 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
}
};
const activeConfigVersion = useMemo<ConfigVersionItem | null>(() => {
return (
(configVersionsQuery.data ?? []).find((item) => item.is_active) ?? null
);
}, [configVersionsQuery.data]);
if (nodesQuery.isLoading) {
return <LoadingState />;
}
@@ -320,6 +336,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
? previewAgentReleaseQuery.isFetching
: stableAgentReleaseQuery.isFetching;
const applyLogs = applyLogsQuery.data ?? [];
const isTargetVersionApplied =
activeConfigVersion !== null &&
activeConfigVersion.version === node.current_version;
const handleOpenAgentUpdateModal = () => {
setAgentUpdateFeedback(null);
@@ -353,6 +372,9 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
queryClient.invalidateQueries({
queryKey: ['apply-logs', node.node_id],
}),
queryClient.invalidateQueries({
queryKey: ['config-versions'],
}),
]);
};
@@ -460,10 +482,95 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
)} · ${formatDateTime(node.latest_apply_at)}`
: '暂无应用记录'}
</p>
{node.latest_apply_checksum ? (
<div className="space-y-1 text-sm text-[var(--foreground-secondary)]">
<p>目标 Checksum:{node.latest_apply_checksum}</p>
<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">
Target Checksum
</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>
<AppCard
title="OpenResty 健康与控制"
description="OpenResty 当前健康状态。"
@@ -643,6 +750,7 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
<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">Checksum</th>
<th className="px-3 py-3 font-medium">时间</th>
<th className="px-3 py-3 font-medium">消息</th>
</tr>
@@ -661,14 +769,35 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
}
/>
</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)]">
<p className="max-w-80 break-words whitespace-pre-wrap">
{log.message || '—'}
</p>
<div className="max-w-80 space-y-2 break-words whitespace-pre-wrap">
<p>
{log.main_config_checksum
? `主配置:${log.main_config_checksum}`
: ''}
</p>
<p>
{log.route_config_checksum
? `路由配置:${log.route_config_checksum}`
: ''}
</p>
<p>
{log.support_file_count
? `支持文件:${log.support_file_count}`
: ''}
</p>
<p>{log.message || '—'}</p>
</div>
</td>
</tr>
))}
@@ -679,6 +808,11 @@ export function NodeDetailPage({ nodeId }: { nodeId: string }) {
</AppCard>
</div>
<ConfigVersionSnapshotModal
version={isTargetSnapshotOpen ? activeConfigVersion : null}
onClose={() => setIsTargetSnapshotOpen(false)}
/>
<AppModal
isOpen={isEditorOpen}
onClose={() => setIsEditorOpen(false)}
+4
View File
@@ -21,6 +21,10 @@ export interface NodeItem {
last_error: string;
latest_apply_result: 'success' | 'failed' | '';
latest_apply_message: string;
latest_apply_checksum: string;
latest_main_config_checksum: string;
latest_route_config_checksum: string;
latest_support_file_count: number;
latest_apply_at?: string | null;
created_at: string;
updated_at: string;