[codex] tighten update center and proxy debug UX (#325)

* tighten update center and proxy debug UX

* Address update center review feedback
This commit is contained in:
Cita
2026-03-30 13:39:20 +08:00
committed by GitHub
parent 71be4cb63a
commit 26d87db2cb
16 changed files with 1252 additions and 134 deletions
@@ -0,0 +1,83 @@
export type UpdateCenterHistoryEntry = {
revision?: string;
updatedAt?: string | null;
status?: string | null;
description?: string | null;
imageTag?: string | null;
imageDigest?: string | null;
};
type UpdateCenterHistoryEntryCardProps = {
entry: UpdateCenterHistoryEntry;
currentRevision: string;
helperHealthy: boolean;
deploying: boolean;
compact?: boolean;
formatTaskTime: (value?: string | null) => string;
formatImageTarget: (tag?: string | null, digest?: string | null) => string;
onRollback: (revision: string) => void;
};
export default function UpdateCenterHistoryEntryCard({
entry,
currentRevision,
helperHealthy,
deploying,
compact = false,
formatTaskTime,
formatImageTarget,
onRollback,
}: UpdateCenterHistoryEntryCardProps) {
const revision = String(entry?.revision || '').trim();
const isCurrentRevision = revision && revision === currentRevision;
return (
<div
style={{
border: '1px solid var(--color-border-light)',
borderRadius: 'var(--radius-sm)',
padding: compact ? 10 : 12,
display: 'grid',
gap: compact ? 5 : 6,
background: isCurrentRevision
? 'color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card))'
: 'var(--color-bg-card)',
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
revision {revision || '-'}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{entry?.status ? <span className="badge badge-muted">{entry.status}</span> : null}
{isCurrentRevision ? <span className="badge badge-info">当前运行</span> : null}
</div>
</div>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)', fontFamily: 'var(--font-mono)' }}>
{formatImageTarget(entry?.imageTag, entry?.imageDigest) || '未记录镜像信息'}
</div>
{entry?.description ? (
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
{entry.description}
</div>
) : null}
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.5 }}>
更新时间:{formatTaskTime(entry?.updatedAt)}
</div>
<div>
<button
type="button"
onClick={() => {
if (isCurrentRevision) return;
onRollback(revision);
}}
disabled={!helperHealthy || deploying || isCurrentRevision || !revision}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
回退到 revision {revision}
</button>
</div>
</div>
);
}
@@ -0,0 +1,65 @@
import CenteredModal from '../../components/CenteredModal.js';
import UpdateCenterHistoryEntryCard, { type UpdateCenterHistoryEntry } from './UpdateCenterHistoryEntryCard.js';
type UpdateCenterHistoryModalProps = {
open: boolean;
helperHealthy: boolean;
deploying: boolean;
currentRevision: string;
history: UpdateCenterHistoryEntry[];
formatTaskTime: (value?: string | null) => string;
formatImageTarget: (tag?: string | null, digest?: string | null) => string;
onClose: () => void;
onRollback: (revision: string) => void;
};
export default function UpdateCenterHistoryModal({
open,
helperHealthy,
deploying,
currentRevision,
history,
formatTaskTime,
formatImageTarget,
onClose,
onRollback,
}: UpdateCenterHistoryModalProps) {
return (
<CenteredModal
open={open}
onClose={onClose}
title="全部 revision"
maxWidth={880}
closeOnBackdrop
closeOnEscape
footer={(
<button type="button" className="btn btn-ghost" onClick={onClose}>
关闭
</button>
)}
>
<div style={{ display: 'grid', gap: 10 }}>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.6 }}>
这里保留 helper 读到的全部 Helm revision。默认列表只显示最近几条,弹窗里再展开全部回退记录,避免设置页被历史卡片拉得过长。
</div>
<div style={{ display: 'grid', gap: 10, maxHeight: 520, overflowY: 'auto', paddingRight: 4 }}>
{history.map((entry) => {
const revision = String(entry?.revision || '').trim();
return (
<UpdateCenterHistoryEntryCard
key={revision || 'unknown-revision-modal'}
entry={entry}
currentRevision={currentRevision}
helperHealthy={helperHealthy}
deploying={deploying}
formatTaskTime={formatTaskTime}
formatImageTarget={formatImageTarget}
onRollback={onRollback}
/>
);
})}
</div>
</div>
</CenteredModal>
);
}
@@ -40,6 +40,12 @@ describe('UpdateCenterSection', () => {
beforeEach(() => {
vi.clearAllMocks();
globalThis.document = {
body: {
nodeType: 1,
style: {
overflow: '',
},
},
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Document;
@@ -70,9 +76,19 @@ describe('UpdateCenterSection', () => {
helper: {
ok: true,
healthy: true,
revision: '17',
imageTag: 'latest',
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
history: [
{
revision: '17',
updatedAt: '2026-03-29T12:00:00Z',
status: 'deployed',
description: 'Upgrade complete',
imageRepository: '1467078763/metapi',
imageTag: 'latest',
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
},
{
revision: '16',
updatedAt: '2026-03-28T12:00:00Z',
@@ -82,6 +98,15 @@ describe('UpdateCenterSection', () => {
imageTag: 'main',
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
{
revision: '15',
updatedAt: '2026-03-27T12:00:00Z',
status: 'superseded',
description: 'Earlier stable release',
imageRepository: '1467078763/metapi',
imageTag: '1.2.2',
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
},
],
},
runningTask: null,
@@ -228,6 +253,7 @@ describe('UpdateCenterSection', () => {
const text = collectText(root.root);
expect(text).toContain('latest @ sha256:efb2ee655386');
expect(text).toContain('发现新版本');
expect(text).toContain('Running helm upgrade');
expect(text).toContain('Waiting for rollout');
expect(text).toContain('任务状态 · 已完成');
@@ -306,6 +332,111 @@ describe('UpdateCenterSection', () => {
}
});
it('keeps rollback history compact and opens the full revision list in a centered modal', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<UpdateCenterSection />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const compactText = collectText(root.root);
expect(compactText).toContain('展开全部 3 条');
expect(compactText).not.toContain('Earlier stable release');
expect(compactText).not.toContain('回退到 revision 15');
const openHistoryButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('展开全部 3 条')
));
await act(async () => {
await openHistoryButton.props.onClick();
});
await flushMicrotasks();
const modal = root.root.find((node) => (
typeof node.props.className === 'string'
&& node.props.className.includes('modal-content')
&& collectText(node).includes('全部 revision')
));
expect(collectText(modal)).toContain('Earlier stable release');
expect(collectText(modal)).toContain('回退到 revision 15');
} finally {
root?.unmount();
}
});
it('blocks Docker Hub deploys when the current helper image already matches the target digest', async () => {
apiMock.getUpdateCenterStatus.mockResolvedValueOnce({
currentVersion: '1.2.3',
config: {
enabled: true,
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
namespace: 'ai',
releaseName: 'metapi',
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
imageRepository: '1467078763/metapi',
githubReleasesEnabled: true,
dockerHubTagsEnabled: true,
defaultDeploySource: 'docker-hub-tag',
},
githubRelease: {
normalizedVersion: '1.3.0',
displayVersion: '1.3.0',
},
dockerHubTag: {
normalizedVersion: 'latest',
tagName: 'latest',
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
displayVersion: 'latest @ sha256:efb2ee655386',
},
helper: {
ok: true,
healthy: true,
revision: '17',
imageTag: 'latest',
imageDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
},
runningTask: null,
lastFinishedTask: null,
});
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<UpdateCenterSection />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const dockerDeployButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.className === 'string'
&& node.props.className.includes('btn')
&& collectText(node).includes('部署 Docker Hub 标签')
));
expect(dockerDeployButton.props.disabled).toBe(true);
expect(collectText(root.root)).toContain('当前已运行该镜像');
} finally {
root?.unmount();
}
});
it('renders rollback history and triggers rollback tasks for previous revisions', async () => {
let root!: ReactTestRenderer;
try {
+95 -77
View File
@@ -4,6 +4,13 @@ import { api } from '../../api.js';
import ModernSelect from '../../components/ModernSelect.js';
import { useToast } from '../../components/Toast.js';
import { useIsMobile } from '../../components/useIsMobile.js';
import {
buildUpdateReminder,
describeDockerDeployState,
describeGitHubDeployState,
} from '../helpers/updateCenterPresentation.js';
import UpdateCenterHistoryModal from './UpdateCenterHistoryModal.js';
import UpdateCenterHistoryEntryCard from './UpdateCenterHistoryEntryCard.js';
type UpdateCenterStatus = {
currentVersion?: string;
@@ -206,6 +213,7 @@ export default function UpdateCenterSection() {
const [config, setConfig] = useState(DEFAULT_CONFIG);
const [logs, setLogs] = useState<string[]>([]);
const [taskStatus, setTaskStatus] = useState('');
const [historyModalOpen, setHistoryModalOpen] = useState(false);
const streamAbortRef = useRef<AbortController | null>(null);
const loadStatus = async () => {
@@ -367,29 +375,33 @@ export default function UpdateCenterSection() {
const runningTaskBadge = getTaskBadge(status?.runningTask?.status || taskStatus || undefined);
const lastFinishedTaskBadge = getTaskBadge(status?.lastFinishedTask?.status || undefined);
const visibleTaskStatus = taskStatus || status?.runningTask?.status || status?.lastFinishedTask?.status || 'idle';
const canDeployGithub = Boolean(
!deploying
&& config.enabled
&& helperHealthy
&& config.githubReleasesEnabled
&& status?.githubRelease?.normalizedVersion,
);
const canDeployDocker = Boolean(
!deploying
&& config.enabled
&& helperHealthy
&& config.dockerHubTagsEnabled
&& status?.dockerHubTag?.normalizedVersion,
);
const renderDeployReason = (enabled: boolean, version?: string, helperError?: string | null) => {
if (!config.enabled) return '更新中心已关闭,保存启用后才能部署。';
if (!enabled) return '当前来源已停用,开启后才会参与检查和部署。';
if (!helperHealthy) return helperError || 'Deploy Helper 未健康,先修复 helper 再部署。';
if (!version) return '当前来源还没有可部署版本。';
return '版本可用,点击按钮即可通过 helper 发起滚动更新。';
};
const githubDeployState = describeGitHubDeployState({
enabled: config.enabled && config.githubReleasesEnabled,
helperHealthy,
helperError: status?.helper?.error,
currentVersion: status?.currentVersion,
helperImageTag: status?.helper?.imageTag,
candidate: status?.githubRelease,
});
const dockerDeployState = describeDockerDeployState({
enabled: config.enabled && config.dockerHubTagsEnabled,
helperHealthy,
helperError: status?.helper?.error,
currentVersion: status?.currentVersion,
helper: status?.helper,
candidate: status?.dockerHubTag,
});
const updateReminder = buildUpdateReminder({
currentVersion: status?.currentVersion,
helper: status?.helper,
githubRelease: status?.githubRelease,
dockerHubTag: status?.dockerHubTag,
});
const canDeployGithub = !deploying && githubDeployState.canDeploy;
const canDeployDocker = !deploying && dockerDeployState.canDeploy;
const helperHistory = Array.isArray(status?.helper?.history) ? status.helper.history : [];
const historyPreview = helperHistory.slice(0, 2);
const currentRevision = String(status?.helper?.revision || '').trim();
if (loading) {
return (
@@ -405,10 +417,18 @@ export default function UpdateCenterSection() {
return (
<div className="card" style={{ padding: 20 }}>
<div style={{ marginBottom: 14 }}>
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 6 }}>更新中心</div>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10, flexWrap: 'wrap', marginBottom: 6 }}>
<div style={{ fontWeight: 600, fontSize: 14 }}>更新中心</div>
<span className={`${updateReminder.badgeClassName} ${updateReminder.highlight ? 'stat-value-glow' : ''}`.trim()}>
{updateReminder.label}
</span>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.55 }}>
在设置页里统一查看 GitHub Releases、Docker Hub 版本和 K3s helper 状态,避免部署信息散落在多个入口。
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.5, marginTop: 6 }}>
{updateReminder.detail}
</div>
</div>
<div
@@ -612,17 +632,20 @@ export default function UpdateCenterSection() {
<span className={getSourceBadge(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion).className}>
{getSourceBadge(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion).label}
</span>
<span className={`${githubDeployState.badgeClassName} ${githubDeployState.highlight ? 'stat-value-glow' : ''}`.trim()}>
{githubDeployState.badgeLabel}
</span>
{config.defaultDeploySource === 'github-release' ? (
<span className="badge badge-info">默认来源</span>
) : null}
</div>
</div>
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>优先使用仓库稳定版 release,适合保留语义化版本节奏。</div>
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
<div className={githubDeployState.highlight ? 'stat-value-glow' : ''} style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
{status?.githubRelease?.displayVersion || status?.githubRelease?.normalizedVersion || '未发现'}
</div>
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
{renderDeployReason(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion, status?.helper?.error)}
{githubDeployState.reason}
</div>
<button
type="button"
@@ -648,17 +671,20 @@ export default function UpdateCenterSection() {
<span className={getSourceBadge(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion).className}>
{getSourceBadge(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion).label}
</span>
<span className={`${dockerDeployState.badgeClassName} ${dockerDeployState.highlight ? 'stat-value-glow' : ''}`.trim()}>
{dockerDeployState.badgeLabel}
</span>
{config.defaultDeploySource === 'docker-hub-tag' ? (
<span className="badge badge-info">默认来源</span>
) : null}
</div>
</div>
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>适合镜像标签领先于 release 的场景,直接跟随容器分发节奏。</div>
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
<div className={dockerDeployState.highlight ? 'stat-value-glow' : ''} style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
{status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'}
</div>
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
{renderDeployReason(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion, status?.helper?.error)}
{dockerDeployState.reason}
</div>
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
最近推送:{formatTaskTime(status?.dockerHubTag?.publishedAt)}
@@ -730,63 +756,41 @@ export default function UpdateCenterSection() {
<div style={{ ...sectionPanelStyle, marginBottom: 12 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>回退历史</div>
<span className="badge badge-muted">最近 revision</span>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span className="badge badge-muted">最近 revision</span>
{helperHistory.length > historyPreview.length ? (
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '4px 10px', minHeight: 0 }}
onClick={() => setHistoryModalOpen(true)}
>
展开全部 {helperHistory.length} 条
</button>
) : null}
</div>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
这里展示 helper 读到的最近 Helm revision。支持直接回退到某个旧 revision,适合快速滚回上一次稳定镜像。
页面里默认只保留最近 revision 预览,完整历史放进弹窗,避免设置页被长回退列表拖得过长。
</div>
{status?.helper?.history && status.helper.history.length > 0 ? (
{helperHistory.length > 0 ? (
<div style={{ display: 'grid', gap: 10 }}>
{status.helper.history.map((entry) => {
{historyPreview.map((entry) => {
const revision = String(entry?.revision || '').trim();
const isCurrentRevision = revision && revision === String(status?.helper?.revision || '').trim();
return (
<div
<UpdateCenterHistoryEntryCard
key={revision || 'unknown-revision'}
style={{
border: '1px solid var(--color-border-light)',
borderRadius: 'var(--radius-sm)',
padding: 12,
display: 'grid',
gap: 6,
background: 'var(--color-bg-card)',
entry={entry}
currentRevision={currentRevision}
helperHealthy={helperHealthy}
deploying={deploying}
compact
formatTaskTime={formatTaskTime}
formatImageTarget={formatImageTarget}
onRollback={(nextRevision) => {
void runRollback(nextRevision);
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
revision {revision || '-'}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{entry?.status ? (
<span className="badge badge-muted">{entry.status}</span>
) : null}
{isCurrentRevision ? (
<span className="badge badge-info">当前运行</span>
) : null}
</div>
</div>
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', fontSize: 13 }}>
{formatImageTarget(entry?.imageTag, entry?.imageDigest) || '未记录镜像信息'}
</div>
{entry?.description ? (
<div style={fieldHintStyle}>{entry.description}</div>
) : null}
<div style={fieldHintStyle}>更新时间:{formatTaskTime(entry?.updatedAt)}</div>
<div>
<button
type="button"
onClick={() => {
if (isCurrentRevision) return;
void runRollback(revision);
}}
disabled={!helperHealthy || deploying || isCurrentRevision || !revision}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
回退到 revision {revision}
</button>
</div>
</div>
/>
);
})}
</div>
@@ -845,6 +849,20 @@ export default function UpdateCenterSection() {
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 10 }}>
最近状态:{visibleTaskStatus}
</div>
<UpdateCenterHistoryModal
open={historyModalOpen}
helperHealthy={helperHealthy}
deploying={deploying}
currentRevision={currentRevision}
history={helperHistory}
formatTaskTime={formatTaskTime}
formatImageTarget={formatImageTarget}
onClose={() => setHistoryModalOpen(false)}
onRollback={(revision) => {
setHistoryModalOpen(false);
void runRollback(revision);
}}
/>
</div>
);
}