Support digest-aware update center rollbacks (#318)

This commit is contained in:
Cita
2026-03-29 22:16:39 +08:00
committed by GitHub
parent b9ae85e98b
commit d47c3c4a29
16 changed files with 1323 additions and 124 deletions
+4 -4
View File
@@ -45,15 +45,15 @@ export default function About() {
try {
const status = await api.getUpdateCenterStatus() as {
currentVersion?: string;
githubRelease?: { normalizedVersion?: string } | null;
dockerHubTag?: { normalizedVersion?: string } | null;
githubRelease?: { normalizedVersion?: string; displayVersion?: string } | null;
dockerHubTag?: { normalizedVersion?: string; displayVersion?: string } | null;
};
if (cancelled) return;
if (status.currentVersion) {
setCurrentVersion(`v${status.currentVersion}`);
}
setLatestGitHubVersion(String(status.githubRelease?.normalizedVersion || ''));
setLatestDockerHubVersion(String(status.dockerHubTag?.normalizedVersion || ''));
setLatestGitHubVersion(String(status.githubRelease?.displayVersion || status.githubRelease?.normalizedVersion || ''));
setLatestDockerHubVersion(String(status.dockerHubTag?.displayVersion || status.dockerHubTag?.normalizedVersion || ''));
} catch {
// ignore update-center lookup failures on about page
}
+4 -2
View File
@@ -35,10 +35,12 @@ describe('About update center', () => {
currentVersion: '1.2.3',
githubRelease: {
normalizedVersion: '1.3.0',
displayVersion: '1.3.0',
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0',
},
dockerHubTag: {
normalizedVersion: '1.3.1',
normalizedVersion: 'latest',
displayVersion: 'latest @ sha256:efb2ee655386',
},
});
});
@@ -64,7 +66,7 @@ describe('About update center', () => {
expect(text).toContain('GitHub 稳定版');
expect(text).toContain('1.3.0');
expect(text).toContain('Docker Hub');
expect(text).toContain('1.3.1');
expect(text).toContain('latest @ sha256:efb2ee655386');
expect(text).toContain('前往更新中心');
} finally {
root?.unmount();
@@ -11,6 +11,7 @@ const { apiMock } = vi.hoisted(() => ({
saveUpdateCenterConfig: vi.fn(),
checkUpdateCenter: vi.fn(),
deployUpdateCenter: vi.fn(),
rollbackUpdateCenter: vi.fn(),
streamUpdateCenterTaskLogs: vi.fn(),
},
}));
@@ -57,13 +58,31 @@ describe('UpdateCenterSection', () => {
},
githubRelease: {
normalizedVersion: '1.3.0',
displayVersion: '1.3.0',
},
dockerHubTag: {
normalizedVersion: '1.3.1',
normalizedVersion: 'latest',
tagName: 'latest',
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
displayVersion: 'latest @ sha256:efb2ee655386',
publishedAt: '2026-03-29T11:54:35.591877Z',
},
helper: {
ok: true,
healthy: true,
imageTag: 'latest',
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
history: [
{
revision: '16',
updatedAt: '2026-03-28T12:00:00Z',
status: 'superseded',
description: 'Rollback to stable digest',
imageRepository: '1467078763/metapi',
imageTag: 'main',
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
},
],
},
runningTask: null,
lastFinishedTask: null,
@@ -85,9 +104,14 @@ describe('UpdateCenterSection', () => {
apiMock.checkUpdateCenter.mockResolvedValue({
githubRelease: {
normalizedVersion: '1.3.0',
displayVersion: '1.3.0',
},
dockerHubTag: {
normalizedVersion: '1.3.1',
normalizedVersion: 'latest',
tagName: 'latest',
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
displayVersion: 'latest @ sha256:efb2ee655386',
publishedAt: '2026-03-29T11:54:35.591877Z',
},
});
apiMock.deployUpdateCenter.mockResolvedValue({
@@ -97,6 +121,13 @@ describe('UpdateCenterSection', () => {
id: 'task-1',
},
});
apiMock.rollbackUpdateCenter.mockResolvedValue({
success: true,
reused: false,
task: {
id: 'task-2',
},
});
apiMock.streamUpdateCenterTaskLogs.mockImplementation(async (_taskId: string, handlers: { onLog?: (entry: { message: string }) => void; onDone?: (payload: { status: string }) => void }) => {
handlers.onLog?.({ message: 'Running helm upgrade' });
handlers.onLog?.({ message: 'Waiting for rollout' });
@@ -190,11 +221,13 @@ describe('UpdateCenterSection', () => {
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
source: 'github-release',
targetVersion: '1.3.0',
targetTag: '1.3.0',
targetDigest: null,
});
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
const text = collectText(root.root);
expect(text).toContain('latest @ sha256:efb2ee655386');
expect(text).toContain('Running helm upgrade');
expect(text).toContain('Waiting for rollout');
expect(text).toContain('任务状态 · 已完成');
@@ -221,7 +254,8 @@ describe('UpdateCenterSection', () => {
normalizedVersion: '1.3.0',
},
dockerHubTag: {
normalizedVersion: '1.3.1',
normalizedVersion: 'latest',
displayVersion: 'latest @ sha256:efb2ee655386',
},
helper: {
ok: false,
@@ -271,4 +305,42 @@ describe('UpdateCenterSection', () => {
root?.unmount();
}
});
it('renders rollback history and triggers rollback tasks for previous revisions', async () => {
let root!: ReactTestRenderer;
try {
await act(async () => {
root = create(
<MemoryRouter>
<ToastProvider>
<UpdateCenterSection />
</ToastProvider>
</MemoryRouter>,
);
});
await flushMicrotasks();
const rollbackButton = root.root.find((node) => (
node.type === 'button'
&& typeof node.props.onClick === 'function'
&& collectText(node).includes('回退到 revision 16')
));
await act(async () => {
await rollbackButton.props.onClick();
});
await flushMicrotasks();
expect(apiMock.rollbackUpdateCenter).toHaveBeenCalledWith({
targetRevision: '16',
});
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-2', expect.any(Object));
const text = collectText(root.root);
expect(text).toContain('sha256:bbbbbbbbbbbb');
expect(text).toContain('Rollback to stable digest');
} finally {
root?.unmount();
}
});
});
+195 -24
View File
@@ -20,14 +20,34 @@ type UpdateCenterStatus = {
};
githubRelease?: {
normalizedVersion?: string;
displayVersion?: string;
tagName?: string;
digest?: string | null;
publishedAt?: string | null;
} | null;
dockerHubTag?: {
normalizedVersion?: string;
displayVersion?: string;
tagName?: string;
digest?: string | null;
publishedAt?: string | null;
} | null;
helper?: {
ok?: boolean;
healthy?: boolean;
error?: string | null;
revision?: string | null;
imageTag?: string | null;
imageDigest?: string | null;
history?: Array<{
revision?: string;
updatedAt?: string | null;
status?: string | null;
description?: string | null;
imageRepository?: string | null;
imageTag?: string | null;
imageDigest?: string | null;
}>;
} | null;
runningTask?: {
id?: string;
@@ -158,6 +178,23 @@ function getSourceBadge(enabled: boolean, version?: string) {
return { className: 'badge badge-warning', label: '未发现版本' };
}
function formatShortDigest(digest?: string | null) {
const value = String(digest || '').trim();
if (!value) return '';
return value.slice(0, 'sha256:'.length + 12);
}
function formatImageTarget(tag?: string | null, digest?: string | null) {
const normalizedTag = String(tag || '').trim();
const shortDigest = formatShortDigest(digest);
if (normalizedTag && shortDigest) {
return `${normalizedTag} @ ${shortDigest}`;
}
if (normalizedTag) return normalizedTag;
if (shortDigest) return shortDigest;
return '';
}
export default function UpdateCenterSection() {
const toast = useToast();
const isMobile = useIsMobile();
@@ -223,8 +260,36 @@ export default function UpdateCenterSection() {
}
};
const runDeploy = async (source: 'github-release' | 'docker-hub-tag', targetVersion: string) => {
if (!targetVersion) return;
const streamTaskLogs = async (taskId: string) => {
await api.streamUpdateCenterTaskLogs(taskId, {
signal: streamAbortRef.current?.signal,
onLog: (entry) => {
const message = String(entry?.message || '').trim();
if (!message) return;
setLogs((prev) => [...prev, message].slice(-200));
},
onDone: (payload) => {
setTaskStatus(String(payload?.status || 'unknown'));
},
});
};
const hydrateTaskSnapshot = async (taskId: string) => {
const taskResponse = await api.getTask(taskId) as { task?: { status?: string; logs?: Array<{ message?: string }> } };
const task = taskResponse.task;
if (!task) return false;
setTaskStatus(String(task.status || 'unknown'));
setLogs(Array.isArray(task.logs) ? task.logs.map((entry) => String(entry?.message || '')).filter(Boolean) : []);
toast.info('实时日志流已断开,已回退到任务详情快照');
return true;
};
const runDeploy = async (
source: 'github-release' | 'docker-hub-tag',
target: { tag?: string | null; digest?: string | null },
) => {
const targetTag = String(target.tag || '').trim();
if (!targetTag) return;
setDeploying(true);
setLogs([]);
setTaskStatus('running');
@@ -233,32 +298,21 @@ export default function UpdateCenterSection() {
let taskId = '';
try {
const response = await api.deployUpdateCenter({ source, targetVersion }) as { task?: { id: string } };
const response = await api.deployUpdateCenter({
source,
targetTag,
targetDigest: target.digest || null,
}) as { task?: { id: string } };
taskId = response.task?.id || '';
if (!taskId) {
throw new Error('部署任务未返回 taskId');
}
await api.streamUpdateCenterTaskLogs(taskId, {
signal: streamAbortRef.current.signal,
onLog: (entry) => {
const message = String(entry?.message || '').trim();
if (!message) return;
setLogs((prev) => [...prev, message].slice(-200));
},
onDone: (payload) => {
setTaskStatus(String(payload?.status || 'unknown'));
},
});
await streamTaskLogs(taskId);
} catch (error: any) {
if (taskId) {
try {
const taskResponse = await api.getTask(taskId) as { task?: { status?: string; logs?: Array<{ message?: string }> } };
const task = taskResponse.task;
if (task) {
setTaskStatus(String(task.status || 'unknown'));
setLogs(Array.isArray(task.logs) ? task.logs.map((entry) => String(entry?.message || '')).filter(Boolean) : []);
toast.info('实时日志流已断开,已回退到任务详情快照');
if (await hydrateTaskSnapshot(taskId)) {
return;
}
} catch {
@@ -273,6 +327,41 @@ export default function UpdateCenterSection() {
}
};
const runRollback = async (targetRevision: string) => {
if (!targetRevision) return;
setDeploying(true);
setLogs([]);
setTaskStatus('running');
streamAbortRef.current?.abort();
streamAbortRef.current = new AbortController();
let taskId = '';
try {
const response = await api.rollbackUpdateCenter({ targetRevision }) as { task?: { id: string } };
taskId = response.task?.id || '';
if (!taskId) {
throw new Error('回退任务未返回 taskId');
}
await streamTaskLogs(taskId);
} catch (error: any) {
if (taskId) {
try {
if (await hydrateTaskSnapshot(taskId)) {
return;
}
} catch {
// fall through to the generic error state
}
}
setTaskStatus('failed');
toast.error(error?.message || '回退失败');
} finally {
setDeploying(false);
void loadStatus();
}
};
const helperHealthy = !!status?.helper?.healthy;
const helperBadge = getHelperBadge(status?.helper, config.helperBaseUrl);
const runningTaskBadge = getTaskBadge(status?.runningTask?.status || taskStatus || undefined);
@@ -530,7 +619,7 @@ export default function UpdateCenterSection() {
</div>
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>优先使用仓库稳定版 release,适合保留语义化版本节奏。</div>
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
{status?.githubRelease?.normalizedVersion || '未发现'}
{status?.githubRelease?.displayVersion || status?.githubRelease?.normalizedVersion || '未发现'}
</div>
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
{renderDeployReason(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion, status?.helper?.error)}
@@ -539,7 +628,10 @@ export default function UpdateCenterSection() {
type="button"
onClick={() => {
if (!helperHealthy) return;
void runDeploy('github-release', status?.githubRelease?.normalizedVersion || '');
void runDeploy('github-release', {
tag: status?.githubRelease?.tagName || status?.githubRelease?.normalizedVersion || '',
digest: null,
});
}}
disabled={!canDeployGithub}
className={config.defaultDeploySource === 'github-release' ? 'btn btn-primary' : 'btn btn-ghost'}
@@ -563,16 +655,22 @@ export default function UpdateCenterSection() {
</div>
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>适合镜像标签领先于 release 的场景,直接跟随容器分发节奏。</div>
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', marginBottom: 8 }}>
{status?.dockerHubTag?.normalizedVersion || '未发现'}
{status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'}
</div>
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
{renderDeployReason(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion, status?.helper?.error)}
</div>
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
最近推送:{formatTaskTime(status?.dockerHubTag?.publishedAt)}
</div>
<button
type="button"
onClick={() => {
if (!helperHealthy) return;
void runDeploy('docker-hub-tag', status?.dockerHubTag?.normalizedVersion || '');
void runDeploy('docker-hub-tag', {
tag: status?.dockerHubTag?.tagName || status?.dockerHubTag?.normalizedVersion || '',
digest: status?.dockerHubTag?.digest || null,
});
}}
disabled={!canDeployDocker}
className={config.defaultDeploySource === 'docker-hub-tag' ? 'btn btn-primary' : 'btn btn-ghost'}
@@ -599,6 +697,9 @@ export default function UpdateCenterSection() {
<div style={fieldHintStyle}>
{status?.helper?.error || 'Helper 正常时会先执行 helm upgrade,再等待 kubectl rollout status。'}
</div>
<div style={{ ...fieldHintStyle, marginTop: 6 }}>
当前镜像:{formatImageTarget(status?.helper?.imageTag, status?.helper?.imageDigest) || '等待 helper 返回运行中镜像'}
</div>
</div>
<div style={sectionPanelStyle}>
@@ -626,6 +727,76 @@ export default function UpdateCenterSection() {
</div>
</div>
<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>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
这里展示 helper 读到的最近 Helm revision。支持直接回退到某个旧 revision,适合快速滚回上一次稳定镜像。
</div>
{status?.helper?.history && status.helper.history.length > 0 ? (
<div style={{ display: 'grid', gap: 10 }}>
{status.helper.history.map((entry) => {
const revision = String(entry?.revision || '').trim();
const isCurrentRevision = revision && revision === String(status?.helper?.revision || '').trim();
return (
<div
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)',
}}
>
<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>
) : (
<div style={fieldHintStyle}>
Helper 还没有返回可回退的 revision 历史。至少成功部署过一次后,这里才会稳定显示历史记录。
</div>
)}
</div>
<div style={sectionPanelStyle}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
<div style={{ fontWeight: 600, fontSize: 13 }}>部署日志</div>