chore: remove update center
This commit is contained in:
@@ -1783,43 +1783,6 @@ export const api = {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
getUpdateCenterStatus: () => request("/api/update-center/status"),
|
||||
saveUpdateCenterConfig: (data: any) =>
|
||||
request("/api/update-center/config", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
checkUpdateCenter: () =>
|
||||
request("/api/update-center/check", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
}),
|
||||
deployUpdateCenter: (data: {
|
||||
source: "github-release" | "docker-hub-tag";
|
||||
targetTag: string;
|
||||
targetDigest?: string | null;
|
||||
}) =>
|
||||
request("/api/update-center/deploy", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
rollbackUpdateCenter: (data: { targetRevision: string }) =>
|
||||
request("/api/update-center/rollback", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
streamUpdateCenterTaskLogs: (
|
||||
taskId: string,
|
||||
handlers: {
|
||||
onLog?: (entry: any) => void;
|
||||
onDone?: (payload: any) => void;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) =>
|
||||
streamSse(
|
||||
`/api/update-center/tasks/${encodeURIComponent(taskId)}/stream`,
|
||||
handlers,
|
||||
),
|
||||
testSystemProxy: (data: SystemProxyTestRequest) =>
|
||||
request("/api/settings/system-proxy/test", {
|
||||
method: "POST",
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import About from './About.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getUpdateCenterStatus: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('About page', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows product information without update prompts or project links', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<About />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('关于 BoosAPI');
|
||||
expect(text).toContain('核心特色');
|
||||
expect(text).toContain('兼容入口');
|
||||
expect(text).not.toContain('更新提醒');
|
||||
expect(text).not.toContain('前往更新中心');
|
||||
expect(text).not.toContain('项目链接');
|
||||
expect(apiMock.getUpdateCenterStatus).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -20,7 +20,6 @@ import {
|
||||
visualRulesToPayloadRules,
|
||||
} from './settings/payloadRulesVisual.js';
|
||||
import { PAYLOAD_RULE_PROTOCOL_OPTIONS } from './settings/payloadRuleProtocolOptions.js';
|
||||
import UpdateCenterSection from './settings/UpdateCenterSection.js';
|
||||
import {
|
||||
applyRoutingProfilePreset,
|
||||
resolveRoutingProfilePreset,
|
||||
@@ -2924,8 +2923,6 @@ export default function Settings() {
|
||||
</div>
|
||||
|
||||
<div id="settings-maintenance" style={settingsModernSectionStyle}>
|
||||
<UpdateCenterSection />
|
||||
|
||||
<div className="card animate-slide-up stagger-6" style={settingsModernCardStyle}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>维护工具</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -76,12 +76,6 @@ describe('navigationFocus helpers', () => {
|
||||
type: 'proxy',
|
||||
})).toBe('/routes');
|
||||
|
||||
expect(buildEventNavigationPath({
|
||||
relatedType: 'update_center',
|
||||
relatedId: null,
|
||||
type: 'status',
|
||||
})).toBe('/settings');
|
||||
|
||||
expect(buildEventNavigationPath({ type: 'proxy' })).toBe('/logs');
|
||||
expect(buildEventNavigationPath({ type: 'checkin' })).toBe('/checkin');
|
||||
expect(buildEventNavigationPath({ type: 'site_notice' })).toBe('/site-announcements');
|
||||
|
||||
@@ -98,9 +98,6 @@ export function buildEventNavigationPath(event: {
|
||||
if (relatedType === 'site_announcement' && relatedId) {
|
||||
return buildAnnouncementFocusPath(relatedId);
|
||||
}
|
||||
if (relatedType === 'update_center') {
|
||||
return '/settings';
|
||||
}
|
||||
if (relatedType === 'route') {
|
||||
return '/routes';
|
||||
}
|
||||
|
||||
@@ -1,104 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildUpdateReminder,
|
||||
describeDockerDeployState,
|
||||
} from './updateCenterPresentation.js';
|
||||
|
||||
describe('updateCenterPresentation', () => {
|
||||
it('returns an unknown reminder when no candidate source data is available', () => {
|
||||
expect(buildUpdateReminder({
|
||||
currentVersion: '1.2.3',
|
||||
helper: null,
|
||||
githubRelease: null,
|
||||
dockerHubTag: null,
|
||||
})).toEqual({
|
||||
label: '无法检查更新',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
detail: '暂未获取到可比较的版本信息。',
|
||||
highlight: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('treats a same-version Docker target with a different digest as a real new-digest deploy', () => {
|
||||
const state = describeDockerDeployState({
|
||||
enabled: true,
|
||||
helperHealthy: true,
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: '1.2.3',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
candidate: {
|
||||
normalizedVersion: '1.2.3',
|
||||
tagName: '1.2.3',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.kind).toBe('new-digest');
|
||||
expect(state.canDeploy).toBe(true);
|
||||
expect(state.badgeLabel).toBe('发现新 digest');
|
||||
});
|
||||
|
||||
it('treats semver tags with and without a v prefix as the same Docker digest target', () => {
|
||||
const state = describeDockerDeployState({
|
||||
enabled: true,
|
||||
helperHealthy: true,
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: 'v1.2.3',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
candidate: {
|
||||
normalizedVersion: '1.2.3',
|
||||
tagName: '1.2.3',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.kind).toBe('new-digest');
|
||||
expect(state.canDeploy).toBe(true);
|
||||
});
|
||||
|
||||
it('treats alias tags like latest as a new digest deploy target when the digest changes', () => {
|
||||
const state = describeDockerDeployState({
|
||||
enabled: true,
|
||||
helperHealthy: true,
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: 'latest',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
candidate: {
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
},
|
||||
});
|
||||
|
||||
expect(state.kind).toBe('new-digest');
|
||||
expect(state.canDeploy).toBe(true);
|
||||
expect(state.badgeLabel).toBe('发现新 digest');
|
||||
});
|
||||
|
||||
it('does not advertise an older GitHub reminder when the helper is already ahead', () => {
|
||||
expect(buildUpdateReminder({
|
||||
currentVersion: '1.2.3',
|
||||
helper: {
|
||||
imageTag: '1.4.0',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
tagName: 'v1.3.0',
|
||||
},
|
||||
dockerHubTag: null,
|
||||
})).toEqual({
|
||||
label: '已是最新',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
detail: '当前运行版本与已发现的部署目标没有明显差异。',
|
||||
highlight: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,277 +0,0 @@
|
||||
import {
|
||||
compareStableVersions,
|
||||
isSameImageTarget,
|
||||
resolveUpdateReminderCandidate,
|
||||
type UpdateHelperRuntimeLike,
|
||||
type UpdateVersionCandidateLike,
|
||||
} from '../../../shared/updateCenterReminder.js';
|
||||
|
||||
export type UpdateDeployState = {
|
||||
kind: 'disabled' | 'missing' | 'helper-unhealthy' | 'same-version' | 'same-image' | 'new-version' | 'new-digest' | 'available';
|
||||
badgeClassName: string;
|
||||
badgeLabel: string;
|
||||
reason: string;
|
||||
canDeploy: boolean;
|
||||
highlight: boolean;
|
||||
};
|
||||
|
||||
export type UpdateReminder = {
|
||||
label: string;
|
||||
badgeClassName: string;
|
||||
detail: string;
|
||||
highlight: boolean;
|
||||
};
|
||||
|
||||
function normalizeString(value?: string | null): string {
|
||||
return String(value || '').trim();
|
||||
}
|
||||
|
||||
function normalizeDigest(value?: string | null): string {
|
||||
const digest = normalizeString(value);
|
||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
|
||||
}
|
||||
|
||||
export function describeGitHubDeployState(input: {
|
||||
enabled: boolean;
|
||||
helperHealthy: boolean;
|
||||
helperError?: string | null;
|
||||
currentVersion?: string | null;
|
||||
helperImageTag?: string | null;
|
||||
candidate: UpdateVersionCandidateLike | null | undefined;
|
||||
}): UpdateDeployState {
|
||||
if (!input.enabled) {
|
||||
return {
|
||||
kind: 'disabled',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
badgeLabel: '已停用',
|
||||
reason: '当前来源已停用,开启后才会参与检查和部署。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
const candidateVersion = normalizeString(input.candidate?.normalizedVersion);
|
||||
const candidateTag = normalizeString(input.candidate?.tagName || candidateVersion);
|
||||
if (!candidateVersion && !candidateTag) {
|
||||
return {
|
||||
kind: 'missing',
|
||||
badgeClassName: 'badge badge-warning',
|
||||
badgeLabel: '未发现版本',
|
||||
reason: '当前来源还没有可部署版本。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.helperHealthy) {
|
||||
return {
|
||||
kind: 'helper-unhealthy',
|
||||
badgeClassName: 'badge badge-warning',
|
||||
badgeLabel: '等待 helper',
|
||||
reason: input.helperError || 'Deploy Helper 未健康,先修复 helper 再部署。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
const candidateTargetVersion = candidateVersion || candidateTag;
|
||||
const versionCompare = compareStableVersions(input.currentVersion, candidateTargetVersion);
|
||||
const helperVersionCompare = compareStableVersions(input.helperImageTag, candidateTargetVersion);
|
||||
if (versionCompare === 0 || helperVersionCompare === 0 || helperVersionCompare === 1) {
|
||||
return {
|
||||
kind: 'same-version',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
badgeLabel: '当前运行',
|
||||
reason: helperVersionCompare === 1
|
||||
? 'Deploy Helper 已指向更高版本,无需回退到较旧的 GitHub 稳定版。'
|
||||
: '当前已运行该版本,无需重复部署。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (versionCompare === -1) {
|
||||
return {
|
||||
kind: 'new-version',
|
||||
badgeClassName: 'badge badge-success',
|
||||
badgeLabel: '发现新版本',
|
||||
reason: '检测到比当前运行版本更新的稳定版,可直接发起部署。',
|
||||
canDeploy: true,
|
||||
highlight: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'available',
|
||||
badgeClassName: 'badge badge-info',
|
||||
badgeLabel: '可部署',
|
||||
reason: '版本可用,点击按钮即可通过 helper 发起滚动更新。',
|
||||
canDeploy: true,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function describeDockerDeployState(input: {
|
||||
enabled: boolean;
|
||||
helperHealthy: boolean;
|
||||
helperError?: string | null;
|
||||
currentVersion?: string | null;
|
||||
helper: UpdateHelperRuntimeLike | null | undefined;
|
||||
candidate: UpdateVersionCandidateLike | null | undefined;
|
||||
}): UpdateDeployState {
|
||||
if (!input.enabled) {
|
||||
return {
|
||||
kind: 'disabled',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
badgeLabel: '已停用',
|
||||
reason: '当前来源已停用,开启后才会参与检查和部署。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
const candidateVersion = normalizeString(input.candidate?.normalizedVersion);
|
||||
const candidateTag = normalizeString(input.candidate?.tagName || candidateVersion);
|
||||
const candidateDigest = normalizeDigest(input.candidate?.digest);
|
||||
if (!candidateVersion && !candidateTag) {
|
||||
return {
|
||||
kind: 'missing',
|
||||
badgeClassName: 'badge badge-warning',
|
||||
badgeLabel: '未发现版本',
|
||||
reason: '当前来源还没有可部署版本。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (!input.helperHealthy) {
|
||||
return {
|
||||
kind: 'helper-unhealthy',
|
||||
badgeClassName: 'badge badge-warning',
|
||||
badgeLabel: '等待 helper',
|
||||
reason: input.helperError || 'Deploy Helper 未健康,先修复 helper 再部署。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (isSameImageTarget(input.helper, { tag: candidateTag, digest: candidateDigest })) {
|
||||
return {
|
||||
kind: 'same-image',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
badgeLabel: '当前运行',
|
||||
reason: '当前已运行该镜像,无需重复部署。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
const candidateTargetVersion = candidateVersion || candidateTag;
|
||||
const helperVersionCompare = compareStableVersions(input.helper?.imageTag, candidateTargetVersion);
|
||||
if (helperVersionCompare === 1) {
|
||||
return {
|
||||
kind: 'same-version',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
badgeLabel: '当前运行',
|
||||
reason: 'Deploy Helper 已指向更高版本,无需回退到较旧镜像。',
|
||||
canDeploy: false,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
const versionCompare = compareStableVersions(input.currentVersion, candidateTargetVersion);
|
||||
if (versionCompare === -1 && (helperVersionCompare == null || helperVersionCompare === -1)) {
|
||||
return {
|
||||
kind: 'new-version',
|
||||
badgeClassName: 'badge badge-success',
|
||||
badgeLabel: '发现新版本',
|
||||
reason: 'Docker Hub 已出现更高版本,可直接发起部署。',
|
||||
canDeploy: true,
|
||||
highlight: true,
|
||||
};
|
||||
}
|
||||
|
||||
const helperDigest = normalizeDigest(input.helper?.imageDigest);
|
||||
const hasSameStableTag = isSameImageTarget(
|
||||
{
|
||||
imageTag: input.helper?.imageTag,
|
||||
imageDigest: null,
|
||||
},
|
||||
{
|
||||
tag: candidateTag,
|
||||
digest: null,
|
||||
},
|
||||
);
|
||||
if (candidateDigest && helperDigest && hasSameStableTag && helperDigest !== candidateDigest) {
|
||||
return {
|
||||
kind: 'new-digest',
|
||||
badgeClassName: 'badge badge-success',
|
||||
badgeLabel: '发现新 digest',
|
||||
reason: '标签未变,但镜像 digest 已更新,适合按镜像级别滚动更新。',
|
||||
canDeploy: true,
|
||||
highlight: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'available',
|
||||
badgeClassName: 'badge badge-info',
|
||||
badgeLabel: '可部署',
|
||||
reason: '版本可用,点击按钮即可通过 helper 发起滚动更新。',
|
||||
canDeploy: true,
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUpdateReminder(input: {
|
||||
currentVersion?: string | null;
|
||||
helper: UpdateHelperRuntimeLike | null | undefined;
|
||||
githubRelease: UpdateVersionCandidateLike | null | undefined;
|
||||
dockerHubTag: UpdateVersionCandidateLike | null | undefined;
|
||||
}): UpdateReminder {
|
||||
const hasGitHubCandidate = Boolean(normalizeString(
|
||||
input.githubRelease?.displayVersion
|
||||
|| input.githubRelease?.normalizedVersion
|
||||
|| input.githubRelease?.tagName,
|
||||
));
|
||||
const hasDockerCandidate = Boolean(normalizeString(
|
||||
input.dockerHubTag?.displayVersion
|
||||
|| input.dockerHubTag?.normalizedVersion
|
||||
|| input.dockerHubTag?.tagName
|
||||
|| input.dockerHubTag?.digest,
|
||||
));
|
||||
if (!hasGitHubCandidate && !hasDockerCandidate) {
|
||||
return {
|
||||
label: '无法检查更新',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
detail: '暂未获取到可比较的版本信息。',
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
|
||||
const candidate = resolveUpdateReminderCandidate({
|
||||
currentVersion: input.currentVersion,
|
||||
helper: input.helper,
|
||||
githubRelease: input.githubRelease,
|
||||
dockerHubTag: input.dockerHubTag,
|
||||
});
|
||||
if (candidate) {
|
||||
return {
|
||||
label: candidate.kind === 'new-digest' ? '发现新 digest' : '发现新版本',
|
||||
badgeClassName: 'badge badge-success',
|
||||
detail: candidate.kind === 'new-digest'
|
||||
? 'Docker Hub 的 alias tag 已指向新 digest,可按需部署。'
|
||||
: candidate.source === 'github-release'
|
||||
? `GitHub 稳定版 ${normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion)} 已可部署。`
|
||||
: `Docker Hub ${normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion)} 已可部署。`,
|
||||
highlight: true,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: '已是最新',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
detail: '当前运行版本与已发现的部署目标没有明显差异。',
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,778 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../../components/Toast.js';
|
||||
|
||||
import UpdateCenterSection from './UpdateCenterSection.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getUpdateCenterStatus: vi.fn(),
|
||||
saveUpdateCenterConfig: vi.fn(),
|
||||
checkUpdateCenter: vi.fn(),
|
||||
deployUpdateCenter: vi.fn(),
|
||||
rollbackUpdateCenter: vi.fn(),
|
||||
streamUpdateCenterTaskLogs: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('UpdateCenterSection', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.document = {
|
||||
body: {
|
||||
nodeType: 1,
|
||||
style: {
|
||||
overflow: '',
|
||||
},
|
||||
},
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as Document;
|
||||
apiMock.getUpdateCenterStatus.mockResolvedValue({
|
||||
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: 'github-release',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
normalizedVersion: 'dev',
|
||||
tagName: 'dev',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
publishedAt: '2026-03-30T11:54:35.591877Z',
|
||||
},
|
||||
{
|
||||
normalizedVersion: 'dev-20260417-f67ade2',
|
||||
tagName: 'dev-20260417-f67ade2',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
displayVersion: 'dev-20260417-f67ade2 @ sha256:bbbbbbbbbbbb',
|
||||
publishedAt: '2026-03-30T10:54:35.591877Z',
|
||||
},
|
||||
{
|
||||
normalizedVersion: 'feature-without-raw-tag',
|
||||
digest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||
displayVersion: 'feature-without-raw-tag @ sha256:cccccccccccc',
|
||||
publishedAt: '2026-03-30T09:54:35.591877Z',
|
||||
},
|
||||
],
|
||||
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',
|
||||
status: 'superseded',
|
||||
description: 'Rollback to stable digest',
|
||||
imageRepository: '1467078763/metapi',
|
||||
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',
|
||||
},
|
||||
],
|
||||
},
|
||||
runtime: {
|
||||
lastCheckedAt: '2026-03-30 20:30:00',
|
||||
lastCheckError: null,
|
||||
lastResolvedSource: 'github-release',
|
||||
lastResolvedDisplayVersion: '1.3.0',
|
||||
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
||||
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
||||
lastNotifiedAt: '2026-03-30 20:31:00',
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
});
|
||||
apiMock.saveUpdateCenterConfig.mockResolvedValue({
|
||||
success: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://updated-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: 'github-release',
|
||||
},
|
||||
});
|
||||
apiMock.checkUpdateCenter.mockResolvedValue({
|
||||
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: 'github-release',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
displayVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: 'latest',
|
||||
tagName: 'latest',
|
||||
digest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
publishedAt: '2026-03-29T11:54:35.591877Z',
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
normalizedVersion: 'dev',
|
||||
tagName: 'dev',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
publishedAt: '2026-03-30T11:54:35.591877Z',
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
ok: true,
|
||||
healthy: true,
|
||||
revision: '18',
|
||||
imageTag: 'latest',
|
||||
imageDigest: 'sha256:dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd',
|
||||
history: [],
|
||||
},
|
||||
runtime: {
|
||||
lastCheckedAt: '2026-03-30 20:35:00',
|
||||
lastCheckError: null,
|
||||
lastResolvedSource: 'github-release',
|
||||
lastResolvedDisplayVersion: '1.3.0',
|
||||
lastResolvedCandidateKey: 'github-release:v1.3.0',
|
||||
lastNotifiedCandidateKey: 'github-release:v1.3.0',
|
||||
lastNotifiedAt: '2026-03-30 20:31:00',
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
});
|
||||
apiMock.deployUpdateCenter.mockResolvedValue({
|
||||
success: true,
|
||||
reused: false,
|
||||
task: {
|
||||
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' });
|
||||
handlers.onDone?.({ status: 'succeeded' });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.document = originalDocument;
|
||||
});
|
||||
|
||||
it('loads status, saves config updates, and renders streamed deploy logs', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const helperInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.value === 'http://metapi-deploy-helper.ai.svc.cluster.local:9850'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
helperInput.props.onChange({ target: { value: 'http://updated-helper.ai.svc.cluster.local:9850' } });
|
||||
});
|
||||
const checkboxInputs = root.root.findAll((node) => node.type === 'input' && node.props.type === 'checkbox');
|
||||
|
||||
await act(async () => {
|
||||
checkboxInputs[1].props.onChange({ target: { checked: false } });
|
||||
checkboxInputs[2].props.onChange({ target: { checked: false } });
|
||||
});
|
||||
|
||||
const defaultSourceTrigger = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-trigger')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
defaultSourceTrigger.props.onClick();
|
||||
});
|
||||
|
||||
const dockerHubOption = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
&& collectText(node).includes('Docker Hub Tags')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
dockerHubOption.props.onClick();
|
||||
});
|
||||
|
||||
const saveButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('保存更新中心配置')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await saveButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.saveUpdateCenterConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
helperBaseUrl: 'http://updated-helper.ai.svc.cluster.local:9850',
|
||||
githubReleasesEnabled: false,
|
||||
dockerHubTagsEnabled: false,
|
||||
defaultDeploySource: 'docker-hub-tag',
|
||||
}));
|
||||
|
||||
const deployButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('部署 GitHub 稳定版')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await deployButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||
source: 'github-release',
|
||||
targetTag: '1.3.0',
|
||||
targetDigest: null,
|
||||
});
|
||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||
expect(apiMock.checkUpdateCenter).toHaveBeenCalledTimes(1);
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('latest @ sha256:efb2ee655386');
|
||||
expect(text).toContain('发现新版本');
|
||||
expect(text).toContain('后台检查');
|
||||
expect(text).toContain('1.3.0');
|
||||
expect(text).toContain('Running helm upgrade');
|
||||
expect(text).toContain('Waiting for rollout');
|
||||
expect(text).toContain('任务状态 · 已完成');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('disables deploy actions when the helper is unhealthy', 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: 'github-release',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: 'latest',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
},
|
||||
helper: {
|
||||
ok: false,
|
||||
healthy: false,
|
||||
error: 'helper unavailable',
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const githubDeployButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn')
|
||||
&& collectText(node).includes('部署 GitHub 稳定版')
|
||||
));
|
||||
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(githubDeployButton.props.disabled).toBe(true);
|
||||
expect(dockerDeployButton.props.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
githubDeployButton.props.onClick?.();
|
||||
dockerDeployButton.props.onClick?.();
|
||||
});
|
||||
|
||||
expect(apiMock.deployUpdateCenter).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('deploys manual Docker Hub tags so dev and branch images are reachable from the UI', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const manualTagInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === 'dev / dev-20260417-f67ade2 / sha-f67ade2'
|
||||
));
|
||||
const manualDigestInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.placeholder === '可选 digest:sha256:...'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
manualTagInput.props.onChange({ target: { value: 'dev-20260417-f67ade2' } });
|
||||
manualDigestInput.props.onChange({
|
||||
target: {
|
||||
value: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const customDeployButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('部署自定义 Docker 标签')
|
||||
));
|
||||
|
||||
expect(customDeployButton.props.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await customDeployButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||
source: 'docker-hub-tag',
|
||||
targetTag: 'dev-20260417-f67ade2',
|
||||
targetDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
});
|
||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('deploys auto-discovered recent non-stable Docker Hub tags without manual input', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root.root)).toContain('Digest:sha256:bbbbbbbbbbbb');
|
||||
expect(collectText(root.root)).not.toContain('feature-without-raw-tag');
|
||||
expect(collectText(root.root)).toContain('dev-20260417-f67ade2 @ sha256:bbbbbbbbbbbb');
|
||||
|
||||
const deployRecentButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('部署 dev-20260417-f67ade2')
|
||||
));
|
||||
|
||||
expect(deployRecentButton.props.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await deployRecentButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||
source: 'docker-hub-tag',
|
||||
targetTag: 'dev-20260417-f67ade2',
|
||||
targetDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
});
|
||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('disables only the recent Docker candidate that already matches the running helper image', 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',
|
||||
},
|
||||
dockerHubRecentTags: [
|
||||
{
|
||||
normalizedVersion: 'dev',
|
||||
tagName: 'dev',
|
||||
digest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
displayVersion: 'dev @ sha256:aaaaaaaaaaaa',
|
||||
publishedAt: '2026-03-30T11:54:35.591877Z',
|
||||
},
|
||||
{
|
||||
normalizedVersion: 'dev-20260417-next',
|
||||
tagName: 'dev-20260417-next',
|
||||
digest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
displayVersion: 'dev-20260417-next @ sha256:bbbbbbbbbbbb',
|
||||
publishedAt: '2026-03-30T10:54:35.591877Z',
|
||||
},
|
||||
],
|
||||
helper: {
|
||||
ok: true,
|
||||
healthy: true,
|
||||
revision: '17',
|
||||
imageTag: 'dev',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
history: [],
|
||||
},
|
||||
runtime: {
|
||||
lastCheckedAt: '2026-03-30 20:30:00',
|
||||
lastCheckError: null,
|
||||
lastResolvedSource: 'docker-hub-tag',
|
||||
lastResolvedDisplayVersion: 'dev',
|
||||
lastResolvedCandidateKey: 'docker-hub-tag:dev@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
lastNotifiedCandidateKey: null,
|
||||
lastNotifiedAt: null,
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const runningCandidateButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node) === '部署 dev'
|
||||
));
|
||||
const nextCandidateButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node) === '部署 dev-20260417-next'
|
||||
));
|
||||
|
||||
expect(runningCandidateButton.props.disabled).toBe(true);
|
||||
expect(runningCandidateButton.props.title).toContain('当前已运行该镜像');
|
||||
expect(nextCandidateButton.props.disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
await nextCandidateButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||
source: 'docker-hub-tag',
|
||||
targetTag: 'dev-20260417-next',
|
||||
targetDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
});
|
||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
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('parses timezone-less SQL runtime timestamps as UTC before formatting', async () => {
|
||||
const parseSpy = vi.spyOn(Date, 'parse');
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const parseInputs = parseSpy.mock.calls.map(([value]) => String(value));
|
||||
expect(parseInputs).toContain('2026-03-30T20:30:00Z');
|
||||
expect(parseInputs).not.toContain('2026-03-30 20:30:00');
|
||||
} finally {
|
||||
parseSpy.mockRestore();
|
||||
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));
|
||||
expect(apiMock.checkUpdateCenter).toHaveBeenCalledTimes(1);
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('最近状态:succeeded');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user