From 26d87db2cbde2456025abadd5e5743e05aee8231 Mon Sep 17 00:00:00 2001 From: Cita Date: Mon, 30 Mar 2026 13:39:20 +0800 Subject: [PATCH] [codex] tighten update center and proxy debug UX (#325) * tighten update center and proxy debug UX * Address update center review feedback --- src/server/routes/api/updateCenter.test.ts | 104 ++++++ src/server/routes/api/updateCenter.ts | 22 +- .../updateCenterDeployGuardService.test.ts | 95 ++++++ .../updateCenterDeployGuardService.ts | 46 +++ src/web/components/CenteredModal.tsx | 14 +- src/web/components/centered-modal.test.tsx | 32 +- src/web/pages/About.tsx | 31 +- src/web/pages/About.update-center.test.tsx | 14 +- .../pages/ProxyLogs.server-driven.test.tsx | 72 ++++ src/web/pages/ProxyLogs.tsx | 142 +++++--- .../helpers/updateCenterPresentation.test.ts | 43 +++ .../pages/helpers/updateCenterPresentation.ts | 320 ++++++++++++++++++ .../settings/UpdateCenterHistoryEntryCard.tsx | 83 +++++ .../settings/UpdateCenterHistoryModal.tsx | 65 ++++ .../settings/UpdateCenterSection.test.tsx | 131 +++++++ .../pages/settings/UpdateCenterSection.tsx | 172 +++++----- 16 files changed, 1252 insertions(+), 134 deletions(-) create mode 100644 src/server/services/updateCenterDeployGuardService.test.ts create mode 100644 src/server/services/updateCenterDeployGuardService.ts create mode 100644 src/web/pages/helpers/updateCenterPresentation.test.ts create mode 100644 src/web/pages/helpers/updateCenterPresentation.ts create mode 100644 src/web/pages/settings/UpdateCenterHistoryEntryCard.tsx create mode 100644 src/web/pages/settings/UpdateCenterHistoryModal.tsx diff --git a/src/server/routes/api/updateCenter.test.ts b/src/server/routes/api/updateCenter.test.ts index a729982..c0237b8 100644 --- a/src/server/routes/api/updateCenter.test.ts +++ b/src/server/routes/api/updateCenter.test.ts @@ -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(); diff --git a/src/server/routes/api/updateCenter.ts b/src/server/routes/api/updateCenter.ts index 831ebb7..27ca74d 100644 --- a/src/server/routes/api/updateCenter.ts +++ b/src/server/routes/api/updateCenter.ts @@ -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, diff --git a/src/server/services/updateCenterDeployGuardService.test.ts b/src/server/services/updateCenterDeployGuardService.test.ts new file mode 100644 index 0000000..f1d7ae4 --- /dev/null +++ b/src/server/services/updateCenterDeployGuardService.test.ts @@ -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(); + }); +}); diff --git a/src/server/services/updateCenterDeployGuardService.ts b/src/server/services/updateCenterDeployGuardService.ts new file mode 100644 index 0000000..1b8b11e --- /dev/null +++ b/src/server/services/updateCenterDeployGuardService.ts @@ -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 { + 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; +} diff --git a/src/web/components/CenteredModal.tsx b/src/web/components/CenteredModal.tsx index f8dffb3..e356472 100644 --- a/src/web/components/CenteredModal.tsx +++ b/src/web/components/CenteredModal.tsx @@ -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({ ); - return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal; + return canUsePortal ? createPortal(modal, document.body) : modal; } diff --git a/src/web/components/centered-modal.test.tsx b/src/web/components/centered-modal.test.tsx index 4a25cfc..9455e5f 100644 --- a/src/web/components/centered-modal.test.tsx +++ b/src/web/components/centered-modal.test.tsx @@ -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( + {}} title="测试弹框" closeOnEscape> +
content
+
, + ); + }); + + expect(root.toJSON()).toBeTruthy(); + expect(globalThis.document.addEventListener).not.toHaveBeenCalled(); + } finally { + root?.unmount(); + } + }); }); diff --git a/src/web/pages/About.tsx b/src/web/pages/About.tsx index 1ff16e9..2f8ce77 100644 --- a/src/web/pages/About.tsx +++ b/src/web/pages/About.tsx @@ -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() {

更新提醒

+
+ + {updateReminder.label} + + + {updateReminder.detail} + +
GitHub 稳定版:{latestGitHubVersion || '暂无数据'}
Docker Hub:{latestDockerHubVersion || '暂无数据'}
diff --git a/src/web/pages/About.update-center.test.tsx b/src/web/pages/About.update-center.test.tsx index c480a1c..a210696 100644 --- a/src/web/pages/About.update-center.test.tsx +++ b/src/web/pages/About.update-center.test.tsx @@ -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(); } diff --git a/src/web/pages/ProxyLogs.server-driven.test.tsx b/src/web/pages/ProxyLogs.server-driven.test.tsx index f26a7d0..7b66ccd 100644 --- a/src/web/pages/ProxyLogs.server-driven.test.tsx +++ b/src/web/pages/ProxyLogs.server-driven.test.tsx @@ -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( + + + + + , + ); + }); + 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' diff --git a/src/web/pages/ProxyLogs.tsx b/src/web/pages/ProxyLogs.tsx index 2234675..c3fa534 100644 --- a/src/web/pages/ProxyLogs.tsx +++ b/src/web/pages/ProxyLogs.tsx @@ -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 = { 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 ( +
+ + {open ? children : null} +
+ ); +} + 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(DEFAULT_PROXY_DEBUG_SETTINGS); const [debugDraftSettings, setDebugDraftSettings] = useState(DEFAULT_PROXY_DEBUG_SETTINGS); const [debugTraces, setDebugTraces] = useState([]); + const [debugTracePage, setDebugTracePage] = useState(1); const [selectedDebugTraceId, setSelectedDebugTraceId] = useState(null); const [showDebugTraceDetailModal, setShowDebugTraceDetailModal] = useState(false); const [debugDetailById, setDebugDetailById] = useState>({}); @@ -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 ( -
- - #{attempt.attemptIndex + 1} · {attempt.endpoint} · {attempt.responseStatus ?? '-'} · {attempt.requestPath} - +
@@ -1087,7 +1130,7 @@ export default function ProxyLogs() { ) : null}
{serializedAttempt}
-
+ ); } @@ -1096,10 +1139,7 @@ export default function ProxyLogs() { const copyLabel = options?.copyLabel || title; return ( -
- - {title} - +
-
+ ); } @@ -1169,31 +1209,14 @@ export default function ProxyLogs() {
最终上游路径
{traceDetail.finalUpstreamPath || '-'}
-
-
候选 endpoint
-
-
- -
- - {parseStoredDebugPreview(traceDetail.endpointCandidatesJson).displayText} - -
-
+ {renderStoredDebugDetails('候选 endpoint', traceDetail.endpointCandidatesJson, { + copyLabel: '候选 endpoint', + })} {renderStoredDebugDetails('原始下游请求头', traceDetail.requestHeadersJson, { - defaultOpen: true, copyLabel: '原始下游请求头', })} {renderStoredDebugDetails('原始下游请求体', traceDetail.requestBodyJson, { @@ -1204,14 +1227,15 @@ export default function ProxyLogs() { })}
-
-
Attempt 记录
- {selectedDebugTraceDetail.data.attempts.length === 0 ? ( -
暂无 attempt 记录
- ) : ( - selectedDebugTraceDetail.data.attempts.map(renderAttemptDetail) - )} -
+ +
+ {selectedDebugTraceDetail.data.attempts.length === 0 ? ( +
暂无 attempt 记录
+ ) : ( + selectedDebugTraceDetail.data.attempts.map(renderAttemptDetail) + )} +
+
); } @@ -1567,7 +1591,7 @@ export default function ProxyLogs() {
最近调试追踪
- 按最新 20 条展示,点击查看详情会打开完整请求与 attempt 链路。 + 最多抓最近 20 条,列表分页每页 5 条;打开详情后各段内容可按需展开和收起。
@@ -1595,7 +1619,7 @@ export default function ProxyLogs() {
) : isMobile ? (
- {debugTraces.map((trace) => ( + {visibleDebugTraces.map((trace) => ( - {debugTraces.map((trace) => ( + {visibleDebugTraces.map((trace) => ( {formatDateTimeLocal(trace.createdAt)} @@ -1659,6 +1683,38 @@ export default function ProxyLogs() { )} + {debugTraces.length > 0 ? ( +
+
+ 显示第 {debugTraceDisplayedStart} - {debugTraceDisplayedEnd} 条,共 {debugTraces.length} 条 +
+ + {Array.from({ length: debugTraceTotalPages }, (_, index) => index + 1).map((num) => ( + + ))} + +
+ ) : null}
{isMobile ? ( diff --git a/src/web/pages/helpers/updateCenterPresentation.test.ts b/src/web/pages/helpers/updateCenterPresentation.test.ts new file mode 100644 index 0000000..ccb714f --- /dev/null +++ b/src/web/pages/helpers/updateCenterPresentation.test.ts @@ -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'); + }); +}); diff --git a/src/web/pages/helpers/updateCenterPresentation.ts b/src/web/pages/helpers/updateCenterPresentation.ts new file mode 100644 index 0000000..74f8645 --- /dev/null +++ b/src/web/pages/helpers/updateCenterPresentation.ts @@ -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, + }; +} diff --git a/src/web/pages/settings/UpdateCenterHistoryEntryCard.tsx b/src/web/pages/settings/UpdateCenterHistoryEntryCard.tsx new file mode 100644 index 0000000..518d3e5 --- /dev/null +++ b/src/web/pages/settings/UpdateCenterHistoryEntryCard.tsx @@ -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 ( +
+
+
+ revision {revision || '-'} +
+
+ {entry?.status ? {entry.status} : null} + {isCurrentRevision ? 当前运行 : null} +
+
+
+ {formatImageTarget(entry?.imageTag, entry?.imageDigest) || '未记录镜像信息'} +
+ {entry?.description ? ( +
+ {entry.description} +
+ ) : null} +
+ 更新时间:{formatTaskTime(entry?.updatedAt)} +
+
+ +
+
+ ); +} diff --git a/src/web/pages/settings/UpdateCenterHistoryModal.tsx b/src/web/pages/settings/UpdateCenterHistoryModal.tsx new file mode 100644 index 0000000..04c612c --- /dev/null +++ b/src/web/pages/settings/UpdateCenterHistoryModal.tsx @@ -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 ( + + 关闭 + + )} + > +
+
+ 这里保留 helper 读到的全部 Helm revision。默认列表只显示最近几条,弹窗里再展开全部回退记录,避免设置页被历史卡片拉得过长。 +
+
+ {history.map((entry) => { + const revision = String(entry?.revision || '').trim(); + return ( + + ); + })} +
+
+
+ ); +} diff --git a/src/web/pages/settings/UpdateCenterSection.test.tsx b/src/web/pages/settings/UpdateCenterSection.test.tsx index 580f9f3..b424150 100644 --- a/src/web/pages/settings/UpdateCenterSection.test.tsx +++ b/src/web/pages/settings/UpdateCenterSection.test.tsx @@ -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( + + + + + , + ); + }); + 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( + + + + + , + ); + }); + 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 { diff --git a/src/web/pages/settings/UpdateCenterSection.tsx b/src/web/pages/settings/UpdateCenterSection.tsx index 55989d2..e5ca7cc 100644 --- a/src/web/pages/settings/UpdateCenterSection.tsx +++ b/src/web/pages/settings/UpdateCenterSection.tsx @@ -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([]); const [taskStatus, setTaskStatus] = useState(''); + const [historyModalOpen, setHistoryModalOpen] = useState(false); const streamAbortRef = useRef(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 (
-
更新中心
+
+
更新中心
+ + {updateReminder.label} + +
在设置页里统一查看 GitHub Releases、Docker Hub 版本和 K3s helper 状态,避免部署信息散落在多个入口。
+
+ {updateReminder.detail} +
{getSourceBadge(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion).label} + + {githubDeployState.badgeLabel} + {config.defaultDeploySource === 'github-release' ? ( 默认来源 ) : null}
优先使用仓库稳定版 release,适合保留语义化版本节奏。
-
+
{status?.githubRelease?.displayVersion || status?.githubRelease?.normalizedVersion || '未发现'}
- {renderDeployReason(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion, status?.helper?.error)} + {githubDeployState.reason}
适合镜像标签领先于 release 的场景,直接跟随容器分发节奏。
-
+
{status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'}
- {renderDeployReason(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion, status?.helper?.error)} + {dockerDeployState.reason}
最近推送:{formatTaskTime(status?.dockerHubTag?.publishedAt)} @@ -730,63 +756,41 @@ export default function UpdateCenterSection() {
回退历史
- 最近 revision +
+ 最近 revision + {helperHistory.length > historyPreview.length ? ( + + ) : null} +
- 这里展示 helper 读到的最近 Helm revision。支持直接回退到某个旧 revision,适合快速滚回上一次稳定镜像。 + 页面里默认只保留最近 revision 预览,完整历史放进弹窗,避免设置页被长回退列表拖得过长。
- {status?.helper?.history && status.helper.history.length > 0 ? ( + {helperHistory.length > 0 ? (
- {status.helper.history.map((entry) => { + {historyPreview.map((entry) => { const revision = String(entry?.revision || '').trim(); - const isCurrentRevision = revision && revision === String(status?.helper?.revision || '').trim(); return ( -
{ + void runRollback(nextRevision); }} - > -
-
- revision {revision || '-'} -
-
- {entry?.status ? ( - {entry.status} - ) : null} - {isCurrentRevision ? ( - 当前运行 - ) : null} -
-
-
- {formatImageTarget(entry?.imageTag, entry?.imageDigest) || '未记录镜像信息'} -
- {entry?.description ? ( -
{entry.description}
- ) : null} -
更新时间:{formatTaskTime(entry?.updatedAt)}
-
- -
-
+ /> ); })}
@@ -845,6 +849,20 @@ export default function UpdateCenterSection() {
最近状态:{visibleTaskStatus}
+ setHistoryModalOpen(false)} + onRollback={(revision) => { + setHistoryModalOpen(false); + void runRollback(revision); + }} + />
); }