1
0
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:
Cita
2026-03-30 13:39:20 +08:00
committed by GitHub
parent 71be4cb63a
commit 26d87db2cb
16 changed files with 1252 additions and 134 deletions
+104
View File
@@ -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();
+20 -2
View File
@@ -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;
}
+9 -5
View File
@@ -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;
}
+31 -1
View File
@@ -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
View File
@@ -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>
+13 -1
View File
@@ -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'
+99 -43
View File
@@ -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>
{selectedDebugTraceDetail.data.attempts.length === 0 ? (
<div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>暂无 attempt 记录</div>
) : (
selectedDebugTraceDetail.data.attempts.map(renderAttemptDetail)
)}
</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 {
+95 -77
View File
@@ -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 style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
<span className="badge badge-muted">最近 revision</span>
{helperHistory.length > historyPreview.length ? (
<button
type="button"
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)', padding: '4px 10px', minHeight: 0 }}
onClick={() => setHistoryModalOpen(true)}
>
展开全部 {helperHistory.length} 条
</button>
) : null}
</div>
</div>
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
这里展示 helper 读到的最近 Helm revision。支持直接回退到某个旧 revision,适合快速滚回上一次稳定镜像。
页面里默认只保留最近 revision 预览,完整历史放进弹窗,避免设置页被长回退列表拖得过长。
</div>
{status?.helper?.history && status.helper.history.length > 0 ? (
{helperHistory.length > 0 ? (
<div style={{ display: 'grid', gap: 10 }}>
{status.helper.history.map((entry) => {
{historyPreview.map((entry) => {
const revision = String(entry?.revision || '').trim();
const isCurrentRevision = revision && revision === String(status?.helper?.revision || '').trim();
return (
<div
<UpdateCenterHistoryEntryCard
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)',
entry={entry}
currentRevision={currentRevision}
helperHealthy={helperHealthy}
deploying={deploying}
compact
formatTaskTime={formatTaskTime}
formatImageTarget={formatImageTarget}
onRollback={(nextRevision) => {
void runRollback(nextRevision);
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, flexWrap: 'wrap' }}>
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>
revision {revision || '-'}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
{entry?.status ? (
<span className="badge badge-muted">{entry.status}</span>
) : null}
{isCurrentRevision ? (
<span className="badge badge-info">当前运行</span>
) : null}
</div>
</div>
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)', fontSize: 13 }}>
{formatImageTarget(entry?.imageTag, entry?.imageDigest) || '未记录镜像信息'}
</div>
{entry?.description ? (
<div style={fieldHintStyle}>{entry.description}</div>
) : null}
<div style={fieldHintStyle}>更新时间:{formatTaskTime(entry?.updatedAt)}</div>
<div>
<button
type="button"
onClick={() => {
if (isCurrentRevision) return;
void runRollback(revision);
}}
disabled={!helperHealthy || deploying || isCurrentRevision || !revision}
className="btn btn-ghost"
style={{ border: '1px solid var(--color-border)' }}
>
回退到 revision {revision}
</button>
</div>
</div>
/>
);
})}
</div>
@@ -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>
);
}