forked from boos/BoosAPI
Support digest-aware update center rollbacks (#318)
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user