forked from boos/BoosAPI
[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:
@@ -409,6 +409,110 @@ describe('update center routes', () => {
|
||||
}), expect.any(Function));
|
||||
});
|
||||
|
||||
it('rejects deploy requests when the target image is already running', async () => {
|
||||
await saveValidConfig();
|
||||
|
||||
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
namespace: 'ai',
|
||||
revision: '17',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: 'latest',
|
||||
imageDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
healthy: true,
|
||||
});
|
||||
|
||||
const deployResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/update-center/deploy',
|
||||
payload: {
|
||||
source: 'docker-hub-tag',
|
||||
targetTag: 'latest',
|
||||
targetDigest: 'sha256:efb2ee6553866bd3268dcc54c02fa5f9789728c51ed4af63328aaba6da67df35',
|
||||
},
|
||||
});
|
||||
|
||||
expect(deployResponse.statusCode).toBe(409);
|
||||
expect(deployResponse.json()).toMatchObject({
|
||||
success: false,
|
||||
message: 'target image is already running',
|
||||
});
|
||||
expect(streamUpdateCenterDeployMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not reject a same-version deploy when the requested digest differs from the running image', async () => {
|
||||
await saveValidConfig();
|
||||
|
||||
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
||||
ok: true,
|
||||
releaseName: 'metapi',
|
||||
namespace: 'ai',
|
||||
revision: '17',
|
||||
imageRepository: '1467078763/metapi',
|
||||
imageTag: '1.2.3',
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
healthy: true,
|
||||
});
|
||||
streamUpdateCenterDeployMock.mockResolvedValue({
|
||||
success: true,
|
||||
targetSource: 'docker-hub-tag',
|
||||
targetTag: '1.2.3',
|
||||
targetDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
previousRevision: '17',
|
||||
finalRevision: '18',
|
||||
rolledBack: false,
|
||||
logLines: ['Running helm upgrade'],
|
||||
});
|
||||
|
||||
const deployResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/update-center/deploy',
|
||||
payload: {
|
||||
source: 'docker-hub-tag',
|
||||
targetTag: '1.2.3',
|
||||
targetDigest: 'sha256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
|
||||
},
|
||||
});
|
||||
|
||||
expect(deployResponse.statusCode).toBe(202);
|
||||
expect(streamUpdateCenterDeployMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
targetTag: '1.2.3',
|
||||
targetDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
||||
}), expect.any(Function));
|
||||
});
|
||||
|
||||
it('normalizes invalid target digests to null before forwarding the deploy request', async () => {
|
||||
await saveValidConfig();
|
||||
|
||||
streamUpdateCenterDeployMock.mockResolvedValue({
|
||||
success: true,
|
||||
targetSource: 'docker-hub-tag',
|
||||
targetTag: 'latest',
|
||||
targetDigest: null,
|
||||
previousRevision: '13',
|
||||
finalRevision: '14',
|
||||
rolledBack: false,
|
||||
logLines: ['Running helm upgrade'],
|
||||
});
|
||||
|
||||
const deployResponse = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/update-center/deploy',
|
||||
payload: {
|
||||
source: 'docker-hub-tag',
|
||||
targetTag: 'latest',
|
||||
targetDigest: 'not-a-real-digest',
|
||||
},
|
||||
});
|
||||
|
||||
expect(deployResponse.statusCode).toBe(202);
|
||||
expect(streamUpdateCenterDeployMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
targetTag: 'latest',
|
||||
targetDigest: null,
|
||||
}), expect.any(Function));
|
||||
});
|
||||
|
||||
it('starts rollback tasks for explicit revision requests', async () => {
|
||||
await saveValidConfig();
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ import {
|
||||
type UpdateCenterVersionCandidate,
|
||||
type UpdateCenterVersionSource,
|
||||
} from '../../services/updateCenterVersionService.js';
|
||||
import {
|
||||
getUpdateCenterDeployBlockMessage,
|
||||
normalizeUpdateCenterTargetDigest,
|
||||
} from '../../services/updateCenterDeployGuardService.js';
|
||||
import {
|
||||
getDefaultUpdateCenterConfig,
|
||||
loadUpdateCenterConfig,
|
||||
@@ -168,6 +172,7 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
||||
message: summarizeHelperError(error),
|
||||
});
|
||||
}
|
||||
const helperToken = getUpdateCenterHelperToken();
|
||||
|
||||
const source = request.body?.source === 'docker-hub-tag'
|
||||
? 'docker-hub-tag'
|
||||
@@ -175,7 +180,7 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
||||
? 'github-release'
|
||||
: config.defaultDeploySource;
|
||||
const targetTag = String(request.body?.targetTag || request.body?.targetVersion || '').trim();
|
||||
const targetDigest = String(request.body?.targetDigest || '').trim() || null;
|
||||
const targetDigest = normalizeUpdateCenterTargetDigest(request.body?.targetDigest);
|
||||
if (!targetTag) {
|
||||
return reply.code(400).send({
|
||||
success: false,
|
||||
@@ -183,6 +188,19 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
||||
});
|
||||
}
|
||||
|
||||
const deployBlockMessage = await getUpdateCenterDeployBlockMessage({
|
||||
config,
|
||||
helperToken,
|
||||
targetTag,
|
||||
targetDigest,
|
||||
});
|
||||
if (deployBlockMessage) {
|
||||
return reply.code(409).send({
|
||||
success: false,
|
||||
message: deployBlockMessage,
|
||||
});
|
||||
}
|
||||
|
||||
let taskId = '';
|
||||
const { task, reused } = startBackgroundTask(
|
||||
{
|
||||
@@ -200,7 +218,7 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
||||
const result = await streamUpdateCenterDeploy(
|
||||
{
|
||||
config,
|
||||
helperToken: getUpdateCenterHelperToken(),
|
||||
helperToken,
|
||||
source,
|
||||
targetTag,
|
||||
targetDigest,
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const {
|
||||
getUpdateCenterHelperStatusMock,
|
||||
getCurrentRuntimeVersionMock,
|
||||
parseStableSemVerMock,
|
||||
} = vi.hoisted(() => ({
|
||||
getUpdateCenterHelperStatusMock: vi.fn(),
|
||||
getCurrentRuntimeVersionMock: vi.fn(),
|
||||
parseStableSemVerMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('./updateCenterHelperClient.js', () => ({
|
||||
getUpdateCenterHelperStatus: (...args: unknown[]) => getUpdateCenterHelperStatusMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock('./updateCenterVersionService.js', () => ({
|
||||
getCurrentRuntimeVersion: () => getCurrentRuntimeVersionMock(),
|
||||
parseStableSemVer: (...args: unknown[]) => parseStableSemVerMock(...args),
|
||||
}));
|
||||
|
||||
import {
|
||||
getUpdateCenterDeployBlockMessage,
|
||||
normalizeUpdateCenterTargetDigest,
|
||||
} from './updateCenterDeployGuardService.js';
|
||||
|
||||
describe('updateCenterDeployGuardService', () => {
|
||||
beforeEach(() => {
|
||||
getUpdateCenterHelperStatusMock.mockReset();
|
||||
getCurrentRuntimeVersionMock.mockReset();
|
||||
parseStableSemVerMock.mockReset();
|
||||
getCurrentRuntimeVersionMock.mockReturnValue('1.2.3');
|
||||
parseStableSemVerMock.mockImplementation((value: string | null | undefined) => {
|
||||
const normalized = String(value || '').trim().replace(/^v/i, '');
|
||||
const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||
if (!match) return null;
|
||||
return {
|
||||
raw: String(value || ''),
|
||||
normalized,
|
||||
major: Number.parseInt(match[1], 10),
|
||||
minor: Number.parseInt(match[2], 10),
|
||||
patch: Number.parseInt(match[3], 10),
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes valid digests and drops invalid ones', () => {
|
||||
expect(normalizeUpdateCenterTargetDigest(' sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA '))
|
||||
.toBe('sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
expect(normalizeUpdateCenterTargetDigest('not-a-real-digest')).toBeNull();
|
||||
});
|
||||
|
||||
it('treats digest equality as the source of truth when a digest is supplied', async () => {
|
||||
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
||||
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||
});
|
||||
|
||||
await expect(getUpdateCenterDeployBlockMessage({
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://helper.local',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
helperToken: 'helper-token',
|
||||
targetTag: '1.2.3',
|
||||
targetDigest: 'sha256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
|
||||
})).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('falls back to semver equality only when no digest is provided', async () => {
|
||||
await expect(getUpdateCenterDeployBlockMessage({
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://helper.local',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
helperToken: 'helper-token',
|
||||
targetTag: '1.2.3',
|
||||
targetDigest: null,
|
||||
})).resolves.toBe('target version is already running');
|
||||
expect(getUpdateCenterHelperStatusMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { UpdateCenterConfig } from './updateCenterConfigService.js';
|
||||
import { getUpdateCenterHelperStatus } from './updateCenterHelperClient.js';
|
||||
import { getCurrentRuntimeVersion, parseStableSemVer } from './updateCenterVersionService.js';
|
||||
|
||||
function normalizeDigest(value: string | null | undefined) {
|
||||
const digest = String(value || '').trim();
|
||||
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
|
||||
}
|
||||
|
||||
function isSameTargetVersion(currentVersion: string, targetTag: string) {
|
||||
const current = parseStableSemVer(currentVersion);
|
||||
const target = parseStableSemVer(targetTag);
|
||||
return !!current && !!target && current.normalized === target.normalized;
|
||||
}
|
||||
|
||||
export function normalizeUpdateCenterTargetDigest(value: string | null | undefined): string | null {
|
||||
return normalizeDigest(value) || null;
|
||||
}
|
||||
|
||||
export async function getUpdateCenterDeployBlockMessage(input: {
|
||||
config: UpdateCenterConfig;
|
||||
helperToken: string;
|
||||
targetTag: string;
|
||||
targetDigest: string | null;
|
||||
}): Promise<string | null> {
|
||||
const normalizedTargetDigest = normalizeDigest(input.targetDigest);
|
||||
if (normalizedTargetDigest) {
|
||||
try {
|
||||
const helperStatus = await getUpdateCenterHelperStatus(input.config, input.helperToken);
|
||||
const currentDigest = normalizeDigest(helperStatus?.imageDigest);
|
||||
if (currentDigest && currentDigest === normalizedTargetDigest) {
|
||||
return 'target image is already running';
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
if (isSameTargetVersion(getCurrentRuntimeVersion(), input.targetTag)) {
|
||||
return 'target version is already running';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -28,18 +28,22 @@ export default function CenteredModal({
|
||||
showCloseButton = true,
|
||||
}: CenteredModalProps) {
|
||||
const presence = useAnimatedVisibility(open, 220);
|
||||
const canUsePortal = typeof document !== 'undefined'
|
||||
&& !!document.body
|
||||
&& typeof document.body.appendChild === 'function'
|
||||
&& typeof document.body.removeChild === 'function';
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || typeof document === 'undefined') return;
|
||||
if (!open || !canUsePortal) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
};
|
||||
}, [open]);
|
||||
}, [canUsePortal, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !closeOnEscape || typeof document === 'undefined') return;
|
||||
if (!open || !closeOnEscape || !canUsePortal) return;
|
||||
const handleKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
@@ -47,7 +51,7 @@ export default function CenteredModal({
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeydown);
|
||||
};
|
||||
}, [closeOnEscape, open, onClose]);
|
||||
}, [canUsePortal, closeOnEscape, open, onClose]);
|
||||
|
||||
if (!presence.shouldRender) return null;
|
||||
|
||||
@@ -82,5 +86,5 @@ export default function CenteredModal({
|
||||
</div>
|
||||
);
|
||||
|
||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
||||
return canUsePortal ? createPortal(modal, document.body) : modal;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create } from 'react-test-renderer';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import CenteredModal from './CenteredModal.js';
|
||||
|
||||
describe('CenteredModal component', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.document = originalDocument;
|
||||
});
|
||||
|
||||
it('does not close on backdrop click by default and exposes an explicit close button', async () => {
|
||||
const onClose = vi.fn();
|
||||
let root!: WebTestRenderer;
|
||||
@@ -49,4 +55,28 @@ describe('CenteredModal component', () => {
|
||||
expect(source).toContain('useAnimatedVisibility');
|
||||
expect(source).toContain('createPortal');
|
||||
});
|
||||
|
||||
it('renders safely without touching document.body or listeners when no usable body exists', async () => {
|
||||
globalThis.document = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as Document;
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<CenteredModal open onClose={() => {}} title="测试弹框" closeOnEscape>
|
||||
<div>content</div>
|
||||
</CenteredModal>,
|
||||
);
|
||||
});
|
||||
|
||||
expect(root.toJSON()).toBeTruthy();
|
||||
expect(globalThis.document.addEventListener).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
+26
-5
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
|
||||
import { api } from '../api.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { SITE_DOCS_URL } from '../docsLink.js';
|
||||
import { buildUpdateReminder } from './helpers/updateCenterPresentation.js';
|
||||
|
||||
const VERSION = '1.2.3';
|
||||
|
||||
@@ -37,6 +38,12 @@ export default function About() {
|
||||
const [currentVersion, setCurrentVersion] = useState(`v${VERSION}`);
|
||||
const [latestGitHubVersion, setLatestGitHubVersion] = useState('');
|
||||
const [latestDockerHubVersion, setLatestDockerHubVersion] = useState('');
|
||||
const [updateReminder, setUpdateReminder] = useState(() => buildUpdateReminder({
|
||||
currentVersion: VERSION,
|
||||
helper: null,
|
||||
githubRelease: null,
|
||||
dockerHubTag: null,
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@@ -45,15 +52,21 @@ export default function About() {
|
||||
try {
|
||||
const status = await api.getUpdateCenterStatus() as {
|
||||
currentVersion?: string;
|
||||
githubRelease?: { normalizedVersion?: string; displayVersion?: string } | null;
|
||||
dockerHubTag?: { normalizedVersion?: string; displayVersion?: string } | null;
|
||||
githubRelease?: { normalizedVersion?: string; displayVersion?: string; tagName?: string | null; digest?: string | null } | null;
|
||||
dockerHubTag?: { normalizedVersion?: string; displayVersion?: string; tagName?: string | null; digest?: string | null } | null;
|
||||
helper?: { imageTag?: string | null; imageDigest?: string | null } | null;
|
||||
};
|
||||
const resolvedCurrentVersion = String(status.currentVersion || VERSION);
|
||||
if (cancelled) return;
|
||||
if (status.currentVersion) {
|
||||
setCurrentVersion(`v${status.currentVersion}`);
|
||||
}
|
||||
setCurrentVersion(`v${resolvedCurrentVersion}`);
|
||||
setLatestGitHubVersion(String(status.githubRelease?.displayVersion || status.githubRelease?.normalizedVersion || ''));
|
||||
setLatestDockerHubVersion(String(status.dockerHubTag?.displayVersion || status.dockerHubTag?.normalizedVersion || ''));
|
||||
setUpdateReminder(buildUpdateReminder({
|
||||
currentVersion: resolvedCurrentVersion,
|
||||
helper: status.helper,
|
||||
githubRelease: status.githubRelease,
|
||||
dockerHubTag: status.dockerHubTag,
|
||||
}));
|
||||
} catch {
|
||||
// ignore update-center lookup failures on about page
|
||||
}
|
||||
@@ -92,6 +105,14 @@ export default function About() {
|
||||
|
||||
<div className="card animate-slide-up stagger-1" style={{ padding: 22, marginBottom: 14 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>更新提醒</h3>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 8, flexWrap: 'wrap', marginBottom: 10 }}>
|
||||
<span className={`${updateReminder.badgeClassName} ${updateReminder.highlight ? 'stat-value-glow' : ''}`.trim()}>
|
||||
{updateReminder.label}
|
||||
</span>
|
||||
<span className={updateReminder.highlight ? 'stat-value-glow' : ''} style={{ fontSize: 13, color: 'var(--color-text-primary)', fontWeight: 600 }}>
|
||||
{updateReminder.detail}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: 8, fontSize: 13 }}>
|
||||
<div>GitHub 稳定版:{latestGitHubVersion || '暂无数据'}</div>
|
||||
<div>Docker Hub:{latestDockerHubVersion || '暂无数据'}</div>
|
||||
|
||||
@@ -42,6 +42,10 @@ describe('About update center', () => {
|
||||
normalizedVersion: 'latest',
|
||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||
},
|
||||
helper: {
|
||||
imageTag: 'latest',
|
||||
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,7 +53,7 @@ describe('About update center', () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows current version and newer release summaries', async () => {
|
||||
it('shows current version, newer release summaries, and a highlighted update reminder', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
@@ -67,7 +71,15 @@ describe('About update center', () => {
|
||||
expect(text).toContain('1.3.0');
|
||||
expect(text).toContain('Docker Hub');
|
||||
expect(text).toContain('latest @ sha256:efb2ee655386');
|
||||
expect(text).toContain('发现新版本');
|
||||
expect(text).toContain('前往更新中心');
|
||||
|
||||
const highlightedReminder = root.root.find((node) => (
|
||||
typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('stat-value-glow')
|
||||
&& collectText(node).includes('发现新版本')
|
||||
));
|
||||
expect(collectText(highlightedReminder)).toContain('发现新版本');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
|
||||
@@ -333,6 +333,63 @@ describe('ProxyLogs server-driven page', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('paginates debug traces in groups of five instead of rendering the whole trace list at once', async () => {
|
||||
apiMock.getProxyDebugTraces.mockResolvedValue({
|
||||
items: Array.from({ length: 7 }, (_, index) => ({
|
||||
id: 701 + index,
|
||||
createdAt: `2026-03-28 18:0${index}:00`,
|
||||
requestedModel: `gpt-4o-mini-${index + 1}`,
|
||||
downstreamPath: '/v1/responses',
|
||||
finalStatus: index % 2 === 0 ? 'failed' : 'success',
|
||||
finalUpstreamPath: '/responses',
|
||||
clientKind: 'codex',
|
||||
sessionId: `sess-debug-${index + 1}`,
|
||||
})),
|
||||
});
|
||||
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter initialEntries={['/logs']}>
|
||||
<ToastProvider>
|
||||
<ProxyLogs />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root.root)).toContain('显示第 1 - 5 条,共 7 条');
|
||||
expect(collectText(root.root)).toContain('sess-debug-1');
|
||||
expect(collectText(root.root)).not.toContain('sess-debug-6');
|
||||
|
||||
const detailButtons = root.root.findAll((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '查看详情'
|
||||
));
|
||||
expect(detailButtons).toHaveLength(5);
|
||||
|
||||
const nextPageButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& node.props['aria-label'] === '调试追踪下一页'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
nextPageButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root.root)).toContain('显示第 6 - 7 条,共 7 条');
|
||||
expect(collectText(root.root)).toContain('sess-debug-6');
|
||||
expect(collectText(root.root)).not.toContain('sess-debug-1');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('opens debug trace detail on demand instead of preloading the first trace inline', async () => {
|
||||
let root!: WebTestRenderer;
|
||||
|
||||
@@ -529,6 +586,21 @@ describe('ProxyLogs server-driven page', () => {
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root.root)).not.toContain('Bearer demo');
|
||||
|
||||
const expandHeadersButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& node.props['aria-label'] === '展开原始下游请求头'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
expandHeadersButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(collectText(root.root)).toContain('Bearer demo');
|
||||
|
||||
const copyButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
|
||||
+93
-37
@@ -74,6 +74,7 @@ type StoredDebugPreviewPayload = {
|
||||
const PAGE_SIZES = [20, 50, 100];
|
||||
const DEFAULT_PAGE_SIZE = 50;
|
||||
const TRACE_TABLE_LIMIT = 20;
|
||||
const DEBUG_TRACE_PAGE_SIZE = 5;
|
||||
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
|
||||
codex: 'Codex',
|
||||
claude_code: 'Claude Code',
|
||||
@@ -181,8 +182,11 @@ const detailExpandableCardStyle: React.CSSProperties = {
|
||||
overflow: 'hidden',
|
||||
};
|
||||
const detailExpandableSummaryStyle: React.CSSProperties = {
|
||||
cursor: 'pointer',
|
||||
listStyle: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: 10,
|
||||
width: '100%',
|
||||
padding: '10px 12px',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
@@ -191,6 +195,33 @@ const detailExpandableSummaryStyle: React.CSSProperties = {
|
||||
background: 'color-mix(in srgb, var(--color-bg-card) 86%, var(--color-bg) 14%)',
|
||||
};
|
||||
|
||||
type DetailDisclosureCardProps = {
|
||||
title: string;
|
||||
defaultOpen?: boolean;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function DetailDisclosureCard({ title, defaultOpen = false, children }: DetailDisclosureCardProps) {
|
||||
const [open, setOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<div style={detailExpandableCardStyle}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${open ? '收起' : '展开'}${title}`}
|
||||
style={{ ...detailExpandableSummaryStyle, border: 'none', cursor: 'pointer' }}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
>
|
||||
<span>{title}</span>
|
||||
<span style={{ fontSize: 12, color: 'var(--color-text-muted)', flexShrink: 0 }}>
|
||||
{open ? '收起' : '展开'}
|
||||
</span>
|
||||
</button>
|
||||
{open ? children : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text);
|
||||
@@ -594,6 +625,7 @@ export default function ProxyLogs() {
|
||||
const [debugSettings, setDebugSettings] = useState<ProxyDebugSettingsState>(DEFAULT_PROXY_DEBUG_SETTINGS);
|
||||
const [debugDraftSettings, setDebugDraftSettings] = useState<ProxyDebugSettingsState>(DEFAULT_PROXY_DEBUG_SETTINGS);
|
||||
const [debugTraces, setDebugTraces] = useState<ProxyDebugTraceListItem[]>([]);
|
||||
const [debugTracePage, setDebugTracePage] = useState(1);
|
||||
const [selectedDebugTraceId, setSelectedDebugTraceId] = useState<number | null>(null);
|
||||
const [showDebugTraceDetailModal, setShowDebugTraceDetailModal] = useState(false);
|
||||
const [debugDetailById, setDebugDetailById] = useState<Record<number, ProxyDebugTraceDetailState>>({});
|
||||
@@ -674,6 +706,12 @@ export default function ProxyLogs() {
|
||||
const currentOffset = (safePage - 1) * pageSize;
|
||||
const displayedStart = total === 0 ? 0 : currentOffset + 1;
|
||||
const displayedEnd = total === 0 ? 0 : Math.min(currentOffset + logs.length, total);
|
||||
const debugTraceTotalPages = Math.max(1, Math.ceil(debugTraces.length / DEBUG_TRACE_PAGE_SIZE));
|
||||
const safeDebugTracePage = Math.min(debugTracePage, debugTraceTotalPages);
|
||||
const debugTraceOffset = (safeDebugTracePage - 1) * DEBUG_TRACE_PAGE_SIZE;
|
||||
const visibleDebugTraces = debugTraces.slice(debugTraceOffset, debugTraceOffset + DEBUG_TRACE_PAGE_SIZE);
|
||||
const debugTraceDisplayedStart = debugTraces.length === 0 ? 0 : debugTraceOffset + 1;
|
||||
const debugTraceDisplayedEnd = debugTraces.length === 0 ? 0 : Math.min(debugTraceOffset + visibleDebugTraces.length, debugTraces.length);
|
||||
|
||||
const pageNumbers = useMemo(() => (
|
||||
Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||
@@ -780,6 +818,11 @@ export default function ProxyLogs() {
|
||||
setPage(totalPages);
|
||||
}, [page, totalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (debugTracePage <= debugTraceTotalPages) return;
|
||||
setDebugTracePage(debugTraceTotalPages);
|
||||
}, [debugTracePage, debugTraceTotalPages]);
|
||||
|
||||
useEffect(() => {
|
||||
setExpanded((current) => (
|
||||
current !== null && logs.some((log) => log.id === current)
|
||||
@@ -1057,10 +1100,10 @@ export default function ProxyLogs() {
|
||||
].join('\n');
|
||||
|
||||
return (
|
||||
<details key={attempt.id} style={detailExpandableCardStyle}>
|
||||
<summary style={detailExpandableSummaryStyle}>
|
||||
#{attempt.attemptIndex + 1} · {attempt.endpoint} · {attempt.responseStatus ?? '-'} · {attempt.requestPath}
|
||||
</summary>
|
||||
<DetailDisclosureCard
|
||||
key={attempt.id}
|
||||
title={`#${attempt.attemptIndex + 1} · ${attempt.endpoint} · ${attempt.responseStatus ?? '-'} · ${attempt.requestPath}`}
|
||||
>
|
||||
<div style={{ padding: 12, display: 'grid', gap: 12 }}>
|
||||
<div style={detailInfoGridStyle}>
|
||||
<div style={detailInfoItemStyle}>
|
||||
@@ -1087,7 +1130,7 @@ export default function ProxyLogs() {
|
||||
) : null}
|
||||
<pre style={debugCodeBlockStyle}>{serializedAttempt}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</DetailDisclosureCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1096,10 +1139,7 @@ export default function ProxyLogs() {
|
||||
const copyLabel = options?.copyLabel || title;
|
||||
|
||||
return (
|
||||
<details open={options?.defaultOpen} style={detailExpandableCardStyle}>
|
||||
<summary style={detailExpandableSummaryStyle}>
|
||||
<span>{title}</span>
|
||||
</summary>
|
||||
<DetailDisclosureCard title={title} defaultOpen={options?.defaultOpen}>
|
||||
<div style={{ padding: 12, display: 'grid', gap: 10 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
@@ -1121,7 +1161,7 @@ export default function ProxyLogs() {
|
||||
) : null}
|
||||
<pre style={debugCodeBlockStyle}>{normalized.displayText}</pre>
|
||||
</div>
|
||||
</details>
|
||||
</DetailDisclosureCard>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1169,31 +1209,14 @@ export default function ProxyLogs() {
|
||||
<div style={detailInfoLabelStyle}>最终上游路径</div>
|
||||
<div style={detailInfoValueStyle}>{traceDetail.finalUpstreamPath || '-'}</div>
|
||||
</div>
|
||||
<div style={{ ...detailInfoItemStyle, gridColumn: '1 / -1' }}>
|
||||
<div style={detailInfoLabelStyle}>候选 endpoint</div>
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)', padding: '6px 12px' }}
|
||||
aria-label="复制候选 endpoint"
|
||||
onClick={() => void handleCopyStoredDebugValue('候选 endpoint', traceDetail.endpointCandidatesJson)}
|
||||
>
|
||||
复制当前保存内容
|
||||
</button>
|
||||
</div>
|
||||
<code style={{ ...debugCodeBlockStyle, margin: 0, fontSize: 11 }}>
|
||||
{parseStoredDebugPreview(traceDetail.endpointCandidatesJson).displayText}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{renderStoredDebugDetails('候选 endpoint', traceDetail.endpointCandidatesJson, {
|
||||
copyLabel: '候选 endpoint',
|
||||
})}
|
||||
{renderStoredDebugDetails('原始下游请求头', traceDetail.requestHeadersJson, {
|
||||
defaultOpen: true,
|
||||
copyLabel: '原始下游请求头',
|
||||
})}
|
||||
{renderStoredDebugDetails('原始下游请求体', traceDetail.requestBodyJson, {
|
||||
@@ -1204,14 +1227,15 @@ export default function ProxyLogs() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'grid', gap: 8 }}>
|
||||
<div style={detailSectionTitleStyle}>Attempt 记录</div>
|
||||
<DetailDisclosureCard title={`Attempt 记录 (${selectedDebugTraceDetail.data.attempts.length})`}>
|
||||
<div style={{ padding: 12, display: 'grid', gap: 8 }}>
|
||||
{selectedDebugTraceDetail.data.attempts.length === 0 ? (
|
||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>暂无 attempt 记录</div>
|
||||
) : (
|
||||
selectedDebugTraceDetail.data.attempts.map(renderAttemptDetail)
|
||||
)}
|
||||
</div>
|
||||
</DetailDisclosureCard>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1567,7 +1591,7 @@ export default function ProxyLogs() {
|
||||
<div>
|
||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>最近调试追踪</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||
按最新 20 条展示,点击查看详情会打开完整请求与 attempt 链路。
|
||||
最多抓最近 20 条,列表分页每页 5 条;打开详情后各段内容可按需展开和收起。
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
@@ -1595,7 +1619,7 @@ export default function ProxyLogs() {
|
||||
</div>
|
||||
) : isMobile ? (
|
||||
<div className="mobile-card-list">
|
||||
{debugTraces.map((trace) => (
|
||||
{visibleDebugTraces.map((trace) => (
|
||||
<MobileCard
|
||||
key={trace.id}
|
||||
title={trace.sessionId || `trace-${trace.id}`}
|
||||
@@ -1634,7 +1658,7 @@ export default function ProxyLogs() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{debugTraces.map((trace) => (
|
||||
{visibleDebugTraces.map((trace) => (
|
||||
<tr key={trace.id}>
|
||||
<td style={{ fontSize: 12, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
||||
{formatDateTimeLocal(trace.createdAt)}
|
||||
@@ -1659,6 +1683,38 @@ export default function ProxyLogs() {
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
{debugTraces.length > 0 ? (
|
||||
<div className="pagination" style={{ marginTop: 12 }}>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginRight: 'auto' }}>
|
||||
显示第 {debugTraceDisplayedStart} - {debugTraceDisplayedEnd} 条,共 {debugTraces.length} 条
|
||||
</div>
|
||||
<button
|
||||
className="pagination-btn"
|
||||
aria-label="调试追踪上一页"
|
||||
disabled={safeDebugTracePage <= 1}
|
||||
onClick={() => setDebugTracePage((current) => current - 1)}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" /></svg>
|
||||
</button>
|
||||
{Array.from({ length: debugTraceTotalPages }, (_, index) => index + 1).map((num) => (
|
||||
<button
|
||||
key={`debug-trace-page-${num}`}
|
||||
className={`pagination-btn ${safeDebugTracePage === num ? 'active' : ''}`}
|
||||
onClick={() => setDebugTracePage(num)}
|
||||
>
|
||||
{num}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="pagination-btn"
|
||||
aria-label="调试追踪下一页"
|
||||
disabled={safeDebugTracePage >= debugTraceTotalPages}
|
||||
onClick={() => setDebugTracePage((current) => current + 1)}
|
||||
>
|
||||
<svg width="14" height="14" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{isMobile ? (
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,320 @@
|
||||
export type UpdateVersionCandidateLike = {
|
||||
normalizedVersion?: string | null;
|
||||
displayVersion?: string | null;
|
||||
tagName?: string | null;
|
||||
digest?: string | null;
|
||||
};
|
||||
|
||||
export type UpdateHelperRuntimeLike = {
|
||||
imageTag?: string | null;
|
||||
imageDigest?: string | null;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
const STABLE_SEMVER_PATTERN = /^v?(\d+)\.(\d+)\.(\d+)(?:\+[\w.-]+)?$/i;
|
||||
|
||||
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 normalizeStableVersion(value?: string | null): string {
|
||||
const raw = normalizeString(value);
|
||||
if (!raw) return '';
|
||||
const match = raw.match(STABLE_SEMVER_PATTERN);
|
||||
if (!match) return '';
|
||||
return [
|
||||
Number.parseInt(match[1], 10),
|
||||
Number.parseInt(match[2], 10),
|
||||
Number.parseInt(match[3], 10),
|
||||
].join('.');
|
||||
}
|
||||
|
||||
export function compareStableVersions(left?: string | null, right?: string | null): number | null {
|
||||
const normalizedLeft = normalizeStableVersion(left);
|
||||
const normalizedRight = normalizeStableVersion(right);
|
||||
if (!normalizedLeft || !normalizedRight) return null;
|
||||
|
||||
const leftParts = normalizedLeft.split('.').map((item) => Number.parseInt(item, 10));
|
||||
const rightParts = normalizedRight.split('.').map((item) => Number.parseInt(item, 10));
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
if (leftParts[index] === rightParts[index]) continue;
|
||||
return leftParts[index] < rightParts[index] ? -1 : 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function isSameImageTarget(
|
||||
current: UpdateHelperRuntimeLike | null | undefined,
|
||||
target: { tag?: string | null; digest?: string | null },
|
||||
): boolean {
|
||||
const currentDigest = normalizeDigest(current?.imageDigest);
|
||||
const targetDigest = normalizeDigest(target.digest);
|
||||
if (currentDigest && targetDigest) {
|
||||
return currentDigest === targetDigest;
|
||||
}
|
||||
|
||||
const currentTag = normalizeString(current?.imageTag);
|
||||
const targetTag = normalizeString(target.tag);
|
||||
if (!currentTag || !targetTag || currentTag !== targetTag) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const currentVersion = normalizeStableVersion(currentTag);
|
||||
const targetVersion = normalizeStableVersion(targetTag);
|
||||
return !!currentVersion && currentVersion === targetVersion;
|
||||
}
|
||||
|
||||
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 versionCompare = compareStableVersions(input.currentVersion, candidateVersion || candidateTag);
|
||||
const helperVersionCompare = compareStableVersions(input.helperImageTag, candidateTag);
|
||||
if (versionCompare === 0 || helperVersionCompare === 0) {
|
||||
return {
|
||||
kind: 'same-version',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
badgeLabel: '当前运行',
|
||||
reason: '当前已运行该版本,无需重复部署。',
|
||||
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 versionCompare = compareStableVersions(input.currentVersion, candidateVersion || candidateTag);
|
||||
if (versionCompare === -1) {
|
||||
return {
|
||||
kind: 'new-version',
|
||||
badgeClassName: 'badge badge-success',
|
||||
badgeLabel: '发现新版本',
|
||||
reason: 'Docker Hub 已出现更高版本,可直接发起部署。',
|
||||
canDeploy: true,
|
||||
highlight: true,
|
||||
};
|
||||
}
|
||||
|
||||
const helperTag = normalizeString(input.helper?.imageTag);
|
||||
const helperDigest = normalizeDigest(input.helper?.imageDigest);
|
||||
if (candidateDigest && helperTag && helperTag === candidateTag && helperDigest && 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 githubState = describeGitHubDeployState({
|
||||
enabled: true,
|
||||
helperHealthy: true,
|
||||
currentVersion: input.currentVersion,
|
||||
helperImageTag: input.helper?.imageTag,
|
||||
candidate: input.githubRelease,
|
||||
});
|
||||
if (githubState.kind === 'new-version') {
|
||||
return {
|
||||
label: '发现新版本',
|
||||
badgeClassName: githubState.badgeClassName,
|
||||
detail: `GitHub 稳定版 ${normalizeString(input.githubRelease?.displayVersion || input.githubRelease?.normalizedVersion)} 已可部署。`,
|
||||
highlight: true,
|
||||
};
|
||||
}
|
||||
|
||||
const dockerState = describeDockerDeployState({
|
||||
enabled: true,
|
||||
helperHealthy: true,
|
||||
currentVersion: input.currentVersion,
|
||||
helper: input.helper,
|
||||
candidate: input.dockerHubTag,
|
||||
});
|
||||
if (dockerState.kind === 'new-version' || dockerState.kind === 'new-digest') {
|
||||
return {
|
||||
label: dockerState.badgeLabel,
|
||||
badgeClassName: dockerState.badgeClassName,
|
||||
detail: dockerState.kind === 'new-digest'
|
||||
? 'Docker Hub 的 alias tag 已指向新 digest,可按需部署。'
|
||||
: `Docker Hub ${normalizeString(input.dockerHubTag?.displayVersion || input.dockerHubTag?.normalizedVersion)} 已可部署。`,
|
||||
highlight: dockerState.highlight,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
label: '已是最新',
|
||||
badgeClassName: 'badge badge-muted',
|
||||
detail: '当前运行版本与已发现的部署目标没有明显差异。',
|
||||
highlight: false,
|
||||
};
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<span className="badge badge-muted">最近 revision</span>
|
||||
{helperHistory.length > historyPreview.length ? (
|
||||
<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)' }}
|
||||
style={{ border: '1px solid var(--color-border)', padding: '4px 10px', minHeight: 0 }}
|
||||
onClick={() => setHistoryModalOpen(true)}
|
||||
>
|
||||
回退到 revision {revision}
|
||||
展开全部 {helperHistory.length} 条
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||
页面里默认只保留最近 revision 预览,完整历史放进弹窗,避免设置页被长回退列表拖得过长。
|
||||
</div>
|
||||
{helperHistory.length > 0 ? (
|
||||
<div style={{ display: 'grid', gap: 10 }}>
|
||||
{historyPreview.map((entry) => {
|
||||
const revision = String(entry?.revision || '').trim();
|
||||
return (
|
||||
<UpdateCenterHistoryEntryCard
|
||||
key={revision || 'unknown-revision'}
|
||||
entry={entry}
|
||||
currentRevision={currentRevision}
|
||||
helperHealthy={helperHealthy}
|
||||
deploying={deploying}
|
||||
compact
|
||||
formatTaskTime={formatTaskTime}
|
||||
formatImageTarget={formatImageTarget}
|
||||
onRollback={(nextRevision) => {
|
||||
void runRollback(nextRevision);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user