[codex] tighten update center and proxy debug UX (#325)
* tighten update center and proxy debug UX * Address update center review feedback
This commit is contained in:
@@ -409,6 +409,110 @@ describe('update center routes', () => {
|
|||||||
}), expect.any(Function));
|
}), 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 () => {
|
it('starts rollback tasks for explicit revision requests', async () => {
|
||||||
await saveValidConfig();
|
await saveValidConfig();
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ import {
|
|||||||
type UpdateCenterVersionCandidate,
|
type UpdateCenterVersionCandidate,
|
||||||
type UpdateCenterVersionSource,
|
type UpdateCenterVersionSource,
|
||||||
} from '../../services/updateCenterVersionService.js';
|
} from '../../services/updateCenterVersionService.js';
|
||||||
|
import {
|
||||||
|
getUpdateCenterDeployBlockMessage,
|
||||||
|
normalizeUpdateCenterTargetDigest,
|
||||||
|
} from '../../services/updateCenterDeployGuardService.js';
|
||||||
import {
|
import {
|
||||||
getDefaultUpdateCenterConfig,
|
getDefaultUpdateCenterConfig,
|
||||||
loadUpdateCenterConfig,
|
loadUpdateCenterConfig,
|
||||||
@@ -168,6 +172,7 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
|||||||
message: summarizeHelperError(error),
|
message: summarizeHelperError(error),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const helperToken = getUpdateCenterHelperToken();
|
||||||
|
|
||||||
const source = request.body?.source === 'docker-hub-tag'
|
const source = request.body?.source === 'docker-hub-tag'
|
||||||
? 'docker-hub-tag'
|
? 'docker-hub-tag'
|
||||||
@@ -175,7 +180,7 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
|||||||
? 'github-release'
|
? 'github-release'
|
||||||
: config.defaultDeploySource;
|
: config.defaultDeploySource;
|
||||||
const targetTag = String(request.body?.targetTag || request.body?.targetVersion || '').trim();
|
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) {
|
if (!targetTag) {
|
||||||
return reply.code(400).send({
|
return reply.code(400).send({
|
||||||
success: false,
|
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 = '';
|
let taskId = '';
|
||||||
const { task, reused } = startBackgroundTask(
|
const { task, reused } = startBackgroundTask(
|
||||||
{
|
{
|
||||||
@@ -200,7 +218,7 @@ export async function updateCenterRoutes(app: FastifyInstance) {
|
|||||||
const result = await streamUpdateCenterDeploy(
|
const result = await streamUpdateCenterDeploy(
|
||||||
{
|
{
|
||||||
config,
|
config,
|
||||||
helperToken: getUpdateCenterHelperToken(),
|
helperToken,
|
||||||
source,
|
source,
|
||||||
targetTag,
|
targetTag,
|
||||||
targetDigest,
|
targetDigest,
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const {
|
||||||
|
getUpdateCenterHelperStatusMock,
|
||||||
|
getCurrentRuntimeVersionMock,
|
||||||
|
parseStableSemVerMock,
|
||||||
|
} = vi.hoisted(() => ({
|
||||||
|
getUpdateCenterHelperStatusMock: vi.fn(),
|
||||||
|
getCurrentRuntimeVersionMock: vi.fn(),
|
||||||
|
parseStableSemVerMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./updateCenterHelperClient.js', () => ({
|
||||||
|
getUpdateCenterHelperStatus: (...args: unknown[]) => getUpdateCenterHelperStatusMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('./updateCenterVersionService.js', () => ({
|
||||||
|
getCurrentRuntimeVersion: () => getCurrentRuntimeVersionMock(),
|
||||||
|
parseStableSemVer: (...args: unknown[]) => parseStableSemVerMock(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
getUpdateCenterDeployBlockMessage,
|
||||||
|
normalizeUpdateCenterTargetDigest,
|
||||||
|
} from './updateCenterDeployGuardService.js';
|
||||||
|
|
||||||
|
describe('updateCenterDeployGuardService', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
getUpdateCenterHelperStatusMock.mockReset();
|
||||||
|
getCurrentRuntimeVersionMock.mockReset();
|
||||||
|
parseStableSemVerMock.mockReset();
|
||||||
|
getCurrentRuntimeVersionMock.mockReturnValue('1.2.3');
|
||||||
|
parseStableSemVerMock.mockImplementation((value: string | null | undefined) => {
|
||||||
|
const normalized = String(value || '').trim().replace(/^v/i, '');
|
||||||
|
const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)$/);
|
||||||
|
if (!match) return null;
|
||||||
|
return {
|
||||||
|
raw: String(value || ''),
|
||||||
|
normalized,
|
||||||
|
major: Number.parseInt(match[1], 10),
|
||||||
|
minor: Number.parseInt(match[2], 10),
|
||||||
|
patch: Number.parseInt(match[3], 10),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes valid digests and drops invalid ones', () => {
|
||||||
|
expect(normalizeUpdateCenterTargetDigest(' sha256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA '))
|
||||||
|
.toBe('sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||||
|
expect(normalizeUpdateCenterTargetDigest('not-a-real-digest')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats digest equality as the source of truth when a digest is supplied', async () => {
|
||||||
|
getUpdateCenterHelperStatusMock.mockResolvedValue({
|
||||||
|
imageDigest: 'sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(getUpdateCenterDeployBlockMessage({
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
helperBaseUrl: 'http://helper.local',
|
||||||
|
namespace: 'ai',
|
||||||
|
releaseName: 'metapi',
|
||||||
|
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||||
|
imageRepository: '1467078763/metapi',
|
||||||
|
githubReleasesEnabled: true,
|
||||||
|
dockerHubTagsEnabled: true,
|
||||||
|
defaultDeploySource: 'github-release',
|
||||||
|
},
|
||||||
|
helperToken: 'helper-token',
|
||||||
|
targetTag: '1.2.3',
|
||||||
|
targetDigest: 'sha256:BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB',
|
||||||
|
})).resolves.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to semver equality only when no digest is provided', async () => {
|
||||||
|
await expect(getUpdateCenterDeployBlockMessage({
|
||||||
|
config: {
|
||||||
|
enabled: true,
|
||||||
|
helperBaseUrl: 'http://helper.local',
|
||||||
|
namespace: 'ai',
|
||||||
|
releaseName: 'metapi',
|
||||||
|
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||||
|
imageRepository: '1467078763/metapi',
|
||||||
|
githubReleasesEnabled: true,
|
||||||
|
dockerHubTagsEnabled: true,
|
||||||
|
defaultDeploySource: 'github-release',
|
||||||
|
},
|
||||||
|
helperToken: 'helper-token',
|
||||||
|
targetTag: '1.2.3',
|
||||||
|
targetDigest: null,
|
||||||
|
})).resolves.toBe('target version is already running');
|
||||||
|
expect(getUpdateCenterHelperStatusMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import type { UpdateCenterConfig } from './updateCenterConfigService.js';
|
||||||
|
import { getUpdateCenterHelperStatus } from './updateCenterHelperClient.js';
|
||||||
|
import { getCurrentRuntimeVersion, parseStableSemVer } from './updateCenterVersionService.js';
|
||||||
|
|
||||||
|
function normalizeDigest(value: string | null | undefined) {
|
||||||
|
const digest = String(value || '').trim();
|
||||||
|
return /^sha256:[a-f0-9]{64}$/i.test(digest) ? digest.toLowerCase() : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSameTargetVersion(currentVersion: string, targetTag: string) {
|
||||||
|
const current = parseStableSemVer(currentVersion);
|
||||||
|
const target = parseStableSemVer(targetTag);
|
||||||
|
return !!current && !!target && current.normalized === target.normalized;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeUpdateCenterTargetDigest(value: string | null | undefined): string | null {
|
||||||
|
return normalizeDigest(value) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getUpdateCenterDeployBlockMessage(input: {
|
||||||
|
config: UpdateCenterConfig;
|
||||||
|
helperToken: string;
|
||||||
|
targetTag: string;
|
||||||
|
targetDigest: string | null;
|
||||||
|
}): Promise<string | null> {
|
||||||
|
const normalizedTargetDigest = normalizeDigest(input.targetDigest);
|
||||||
|
if (normalizedTargetDigest) {
|
||||||
|
try {
|
||||||
|
const helperStatus = await getUpdateCenterHelperStatus(input.config, input.helperToken);
|
||||||
|
const currentDigest = normalizeDigest(helperStatus?.imageDigest);
|
||||||
|
if (currentDigest && currentDigest === normalizedTargetDigest) {
|
||||||
|
return 'target image is already running';
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isSameTargetVersion(getCurrentRuntimeVersion(), input.targetTag)) {
|
||||||
|
return 'target version is already running';
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -28,18 +28,22 @@ export default function CenteredModal({
|
|||||||
showCloseButton = true,
|
showCloseButton = true,
|
||||||
}: CenteredModalProps) {
|
}: CenteredModalProps) {
|
||||||
const presence = useAnimatedVisibility(open, 220);
|
const presence = useAnimatedVisibility(open, 220);
|
||||||
|
const canUsePortal = typeof document !== 'undefined'
|
||||||
|
&& !!document.body
|
||||||
|
&& typeof document.body.appendChild === 'function'
|
||||||
|
&& typeof document.body.removeChild === 'function';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || typeof document === 'undefined') return;
|
if (!open || !canUsePortal) return;
|
||||||
const previousOverflow = document.body.style.overflow;
|
const previousOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = 'hidden';
|
document.body.style.overflow = 'hidden';
|
||||||
return () => {
|
return () => {
|
||||||
document.body.style.overflow = previousOverflow;
|
document.body.style.overflow = previousOverflow;
|
||||||
};
|
};
|
||||||
}, [open]);
|
}, [canUsePortal, open]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !closeOnEscape || typeof document === 'undefined') return;
|
if (!open || !closeOnEscape || !canUsePortal) return;
|
||||||
const handleKeydown = (event: KeyboardEvent) => {
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
if (event.key === 'Escape') onClose();
|
if (event.key === 'Escape') onClose();
|
||||||
};
|
};
|
||||||
@@ -47,7 +51,7 @@ export default function CenteredModal({
|
|||||||
return () => {
|
return () => {
|
||||||
document.removeEventListener('keydown', handleKeydown);
|
document.removeEventListener('keydown', handleKeydown);
|
||||||
};
|
};
|
||||||
}, [closeOnEscape, open, onClose]);
|
}, [canUsePortal, closeOnEscape, open, onClose]);
|
||||||
|
|
||||||
if (!presence.shouldRender) return null;
|
if (!presence.shouldRender) return null;
|
||||||
|
|
||||||
@@ -82,5 +86,5 @@ export default function CenteredModal({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
return typeof document !== 'undefined' ? createPortal(modal, document.body) : modal;
|
return canUsePortal ? createPortal(modal, document.body) : modal;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { act, create } from 'react-test-renderer';
|
import { act, create } from 'react-test-renderer';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { resolve } from 'node:path';
|
import { resolve } from 'node:path';
|
||||||
import CenteredModal from './CenteredModal.js';
|
import CenteredModal from './CenteredModal.js';
|
||||||
|
|
||||||
describe('CenteredModal component', () => {
|
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 () => {
|
it('does not close on backdrop click by default and exposes an explicit close button', async () => {
|
||||||
const onClose = vi.fn();
|
const onClose = vi.fn();
|
||||||
let root!: WebTestRenderer;
|
let root!: WebTestRenderer;
|
||||||
@@ -49,4 +55,28 @@ describe('CenteredModal component', () => {
|
|||||||
expect(source).toContain('useAnimatedVisibility');
|
expect(source).toContain('useAnimatedVisibility');
|
||||||
expect(source).toContain('createPortal');
|
expect(source).toContain('createPortal');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renders safely without touching document.body or listeners when no usable body exists', async () => {
|
||||||
|
globalThis.document = {
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
} as unknown as Document;
|
||||||
|
|
||||||
|
let root!: WebTestRenderer;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await act(async () => {
|
||||||
|
root = create(
|
||||||
|
<CenteredModal open onClose={() => {}} title="测试弹框" closeOnEscape>
|
||||||
|
<div>content</div>
|
||||||
|
</CenteredModal>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(root.toJSON()).toBeTruthy();
|
||||||
|
expect(globalThis.document.addEventListener).not.toHaveBeenCalled();
|
||||||
|
} finally {
|
||||||
|
root?.unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+26
-5
@@ -4,6 +4,7 @@ import { Link } from 'react-router-dom';
|
|||||||
import { api } from '../api.js';
|
import { api } from '../api.js';
|
||||||
import { tr } from '../i18n.js';
|
import { tr } from '../i18n.js';
|
||||||
import { SITE_DOCS_URL } from '../docsLink.js';
|
import { SITE_DOCS_URL } from '../docsLink.js';
|
||||||
|
import { buildUpdateReminder } from './helpers/updateCenterPresentation.js';
|
||||||
|
|
||||||
const VERSION = '1.2.3';
|
const VERSION = '1.2.3';
|
||||||
|
|
||||||
@@ -37,6 +38,12 @@ export default function About() {
|
|||||||
const [currentVersion, setCurrentVersion] = useState(`v${VERSION}`);
|
const [currentVersion, setCurrentVersion] = useState(`v${VERSION}`);
|
||||||
const [latestGitHubVersion, setLatestGitHubVersion] = useState('');
|
const [latestGitHubVersion, setLatestGitHubVersion] = useState('');
|
||||||
const [latestDockerHubVersion, setLatestDockerHubVersion] = useState('');
|
const [latestDockerHubVersion, setLatestDockerHubVersion] = useState('');
|
||||||
|
const [updateReminder, setUpdateReminder] = useState(() => buildUpdateReminder({
|
||||||
|
currentVersion: VERSION,
|
||||||
|
helper: null,
|
||||||
|
githubRelease: null,
|
||||||
|
dockerHubTag: null,
|
||||||
|
}));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -45,15 +52,21 @@ export default function About() {
|
|||||||
try {
|
try {
|
||||||
const status = await api.getUpdateCenterStatus() as {
|
const status = await api.getUpdateCenterStatus() as {
|
||||||
currentVersion?: string;
|
currentVersion?: string;
|
||||||
githubRelease?: { normalizedVersion?: string; displayVersion?: string } | null;
|
githubRelease?: { normalizedVersion?: string; displayVersion?: string; tagName?: string | null; digest?: string | null } | null;
|
||||||
dockerHubTag?: { normalizedVersion?: string; displayVersion?: string } | 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 (cancelled) return;
|
||||||
if (status.currentVersion) {
|
setCurrentVersion(`v${resolvedCurrentVersion}`);
|
||||||
setCurrentVersion(`v${status.currentVersion}`);
|
|
||||||
}
|
|
||||||
setLatestGitHubVersion(String(status.githubRelease?.displayVersion || status.githubRelease?.normalizedVersion || ''));
|
setLatestGitHubVersion(String(status.githubRelease?.displayVersion || status.githubRelease?.normalizedVersion || ''));
|
||||||
setLatestDockerHubVersion(String(status.dockerHubTag?.displayVersion || status.dockerHubTag?.normalizedVersion || ''));
|
setLatestDockerHubVersion(String(status.dockerHubTag?.displayVersion || status.dockerHubTag?.normalizedVersion || ''));
|
||||||
|
setUpdateReminder(buildUpdateReminder({
|
||||||
|
currentVersion: resolvedCurrentVersion,
|
||||||
|
helper: status.helper,
|
||||||
|
githubRelease: status.githubRelease,
|
||||||
|
dockerHubTag: status.dockerHubTag,
|
||||||
|
}));
|
||||||
} catch {
|
} catch {
|
||||||
// ignore update-center lookup failures on about page
|
// 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 }}>
|
<div className="card animate-slide-up stagger-1" style={{ padding: 22, marginBottom: 14 }}>
|
||||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 12 }}>更新提醒</h3>
|
<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 style={{ display: 'grid', gap: 8, fontSize: 13 }}>
|
||||||
<div>GitHub 稳定版:{latestGitHubVersion || '暂无数据'}</div>
|
<div>GitHub 稳定版:{latestGitHubVersion || '暂无数据'}</div>
|
||||||
<div>Docker Hub:{latestDockerHubVersion || '暂无数据'}</div>
|
<div>Docker Hub:{latestDockerHubVersion || '暂无数据'}</div>
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ describe('About update center', () => {
|
|||||||
normalizedVersion: 'latest',
|
normalizedVersion: 'latest',
|
||||||
displayVersion: 'latest @ sha256:efb2ee655386',
|
displayVersion: 'latest @ sha256:efb2ee655386',
|
||||||
},
|
},
|
||||||
|
helper: {
|
||||||
|
imageTag: 'latest',
|
||||||
|
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,7 +53,7 @@ describe('About update center', () => {
|
|||||||
vi.clearAllMocks();
|
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;
|
let root!: ReactTestRenderer;
|
||||||
try {
|
try {
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -67,7 +71,15 @@ describe('About update center', () => {
|
|||||||
expect(text).toContain('1.3.0');
|
expect(text).toContain('1.3.0');
|
||||||
expect(text).toContain('Docker Hub');
|
expect(text).toContain('Docker Hub');
|
||||||
expect(text).toContain('latest @ sha256:efb2ee655386');
|
expect(text).toContain('latest @ sha256:efb2ee655386');
|
||||||
|
expect(text).toContain('发现新版本');
|
||||||
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 {
|
} finally {
|
||||||
root?.unmount();
|
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 () => {
|
it('opens debug trace detail on demand instead of preloading the first trace inline', async () => {
|
||||||
let root!: WebTestRenderer;
|
let root!: WebTestRenderer;
|
||||||
|
|
||||||
@@ -529,6 +586,21 @@ describe('ProxyLogs server-driven page', () => {
|
|||||||
});
|
});
|
||||||
await flushMicrotasks();
|
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) => (
|
const copyButton = root.root.find((node) => (
|
||||||
node.type === 'button'
|
node.type === 'button'
|
||||||
&& typeof node.props.onClick === 'function'
|
&& typeof node.props.onClick === 'function'
|
||||||
|
|||||||
+99
-43
@@ -74,6 +74,7 @@ type StoredDebugPreviewPayload = {
|
|||||||
const PAGE_SIZES = [20, 50, 100];
|
const PAGE_SIZES = [20, 50, 100];
|
||||||
const DEFAULT_PAGE_SIZE = 50;
|
const DEFAULT_PAGE_SIZE = 50;
|
||||||
const TRACE_TABLE_LIMIT = 20;
|
const TRACE_TABLE_LIMIT = 20;
|
||||||
|
const DEBUG_TRACE_PAGE_SIZE = 5;
|
||||||
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
|
const PROXY_LOG_CLIENT_FAMILY_LABELS: Record<string, string> = {
|
||||||
codex: 'Codex',
|
codex: 'Codex',
|
||||||
claude_code: 'Claude Code',
|
claude_code: 'Claude Code',
|
||||||
@@ -181,8 +182,11 @@ const detailExpandableCardStyle: React.CSSProperties = {
|
|||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
};
|
};
|
||||||
const detailExpandableSummaryStyle: React.CSSProperties = {
|
const detailExpandableSummaryStyle: React.CSSProperties = {
|
||||||
cursor: 'pointer',
|
display: 'flex',
|
||||||
listStyle: 'none',
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
gap: 10,
|
||||||
|
width: '100%',
|
||||||
padding: '10px 12px',
|
padding: '10px 12px',
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
@@ -191,6 +195,33 @@ const detailExpandableSummaryStyle: React.CSSProperties = {
|
|||||||
background: 'color-mix(in srgb, var(--color-bg-card) 86%, var(--color-bg) 14%)',
|
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) {
|
async function copyTextToClipboard(text: string) {
|
||||||
if (navigator.clipboard?.writeText) {
|
if (navigator.clipboard?.writeText) {
|
||||||
await navigator.clipboard.writeText(text);
|
await navigator.clipboard.writeText(text);
|
||||||
@@ -594,6 +625,7 @@ export default function ProxyLogs() {
|
|||||||
const [debugSettings, setDebugSettings] = useState<ProxyDebugSettingsState>(DEFAULT_PROXY_DEBUG_SETTINGS);
|
const [debugSettings, setDebugSettings] = useState<ProxyDebugSettingsState>(DEFAULT_PROXY_DEBUG_SETTINGS);
|
||||||
const [debugDraftSettings, setDebugDraftSettings] = useState<ProxyDebugSettingsState>(DEFAULT_PROXY_DEBUG_SETTINGS);
|
const [debugDraftSettings, setDebugDraftSettings] = useState<ProxyDebugSettingsState>(DEFAULT_PROXY_DEBUG_SETTINGS);
|
||||||
const [debugTraces, setDebugTraces] = useState<ProxyDebugTraceListItem[]>([]);
|
const [debugTraces, setDebugTraces] = useState<ProxyDebugTraceListItem[]>([]);
|
||||||
|
const [debugTracePage, setDebugTracePage] = useState(1);
|
||||||
const [selectedDebugTraceId, setSelectedDebugTraceId] = useState<number | null>(null);
|
const [selectedDebugTraceId, setSelectedDebugTraceId] = useState<number | null>(null);
|
||||||
const [showDebugTraceDetailModal, setShowDebugTraceDetailModal] = useState(false);
|
const [showDebugTraceDetailModal, setShowDebugTraceDetailModal] = useState(false);
|
||||||
const [debugDetailById, setDebugDetailById] = useState<Record<number, ProxyDebugTraceDetailState>>({});
|
const [debugDetailById, setDebugDetailById] = useState<Record<number, ProxyDebugTraceDetailState>>({});
|
||||||
@@ -674,6 +706,12 @@ export default function ProxyLogs() {
|
|||||||
const currentOffset = (safePage - 1) * pageSize;
|
const currentOffset = (safePage - 1) * pageSize;
|
||||||
const displayedStart = total === 0 ? 0 : currentOffset + 1;
|
const displayedStart = total === 0 ? 0 : currentOffset + 1;
|
||||||
const displayedEnd = total === 0 ? 0 : Math.min(currentOffset + logs.length, total);
|
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(() => (
|
const pageNumbers = useMemo(() => (
|
||||||
Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
Array.from({ length: Math.min(totalPages, 7) }, (_, i) => {
|
||||||
@@ -780,6 +818,11 @@ export default function ProxyLogs() {
|
|||||||
setPage(totalPages);
|
setPage(totalPages);
|
||||||
}, [page, totalPages]);
|
}, [page, totalPages]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (debugTracePage <= debugTraceTotalPages) return;
|
||||||
|
setDebugTracePage(debugTraceTotalPages);
|
||||||
|
}, [debugTracePage, debugTraceTotalPages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setExpanded((current) => (
|
setExpanded((current) => (
|
||||||
current !== null && logs.some((log) => log.id === current)
|
current !== null && logs.some((log) => log.id === current)
|
||||||
@@ -1057,10 +1100,10 @@ export default function ProxyLogs() {
|
|||||||
].join('\n');
|
].join('\n');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details key={attempt.id} style={detailExpandableCardStyle}>
|
<DetailDisclosureCard
|
||||||
<summary style={detailExpandableSummaryStyle}>
|
key={attempt.id}
|
||||||
#{attempt.attemptIndex + 1} · {attempt.endpoint} · {attempt.responseStatus ?? '-'} · {attempt.requestPath}
|
title={`#${attempt.attemptIndex + 1} · ${attempt.endpoint} · ${attempt.responseStatus ?? '-'} · ${attempt.requestPath}`}
|
||||||
</summary>
|
>
|
||||||
<div style={{ padding: 12, display: 'grid', gap: 12 }}>
|
<div style={{ padding: 12, display: 'grid', gap: 12 }}>
|
||||||
<div style={detailInfoGridStyle}>
|
<div style={detailInfoGridStyle}>
|
||||||
<div style={detailInfoItemStyle}>
|
<div style={detailInfoItemStyle}>
|
||||||
@@ -1087,7 +1130,7 @@ export default function ProxyLogs() {
|
|||||||
) : null}
|
) : null}
|
||||||
<pre style={debugCodeBlockStyle}>{serializedAttempt}</pre>
|
<pre style={debugCodeBlockStyle}>{serializedAttempt}</pre>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</DetailDisclosureCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1096,10 +1139,7 @@ export default function ProxyLogs() {
|
|||||||
const copyLabel = options?.copyLabel || title;
|
const copyLabel = options?.copyLabel || title;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<details open={options?.defaultOpen} style={detailExpandableCardStyle}>
|
<DetailDisclosureCard title={title} defaultOpen={options?.defaultOpen}>
|
||||||
<summary style={detailExpandableSummaryStyle}>
|
|
||||||
<span>{title}</span>
|
|
||||||
</summary>
|
|
||||||
<div style={{ padding: 12, display: 'grid', gap: 10 }}>
|
<div style={{ padding: 12, display: 'grid', gap: 10 }}>
|
||||||
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
<div style={{ display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
<button
|
<button
|
||||||
@@ -1121,7 +1161,7 @@ export default function ProxyLogs() {
|
|||||||
) : null}
|
) : null}
|
||||||
<pre style={debugCodeBlockStyle}>{normalized.displayText}</pre>
|
<pre style={debugCodeBlockStyle}>{normalized.displayText}</pre>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</DetailDisclosureCard>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1169,31 +1209,14 @@ export default function ProxyLogs() {
|
|||||||
<div style={detailInfoLabelStyle}>最终上游路径</div>
|
<div style={detailInfoLabelStyle}>最终上游路径</div>
|
||||||
<div style={detailInfoValueStyle}>{traceDetail.finalUpstreamPath || '-'}</div>
|
<div style={detailInfoValueStyle}>{traceDetail.finalUpstreamPath || '-'}</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: 10 }}>
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
|
{renderStoredDebugDetails('候选 endpoint', traceDetail.endpointCandidatesJson, {
|
||||||
|
copyLabel: '候选 endpoint',
|
||||||
|
})}
|
||||||
{renderStoredDebugDetails('原始下游请求头', traceDetail.requestHeadersJson, {
|
{renderStoredDebugDetails('原始下游请求头', traceDetail.requestHeadersJson, {
|
||||||
defaultOpen: true,
|
|
||||||
copyLabel: '原始下游请求头',
|
copyLabel: '原始下游请求头',
|
||||||
})}
|
})}
|
||||||
{renderStoredDebugDetails('原始下游请求体', traceDetail.requestBodyJson, {
|
{renderStoredDebugDetails('原始下游请求体', traceDetail.requestBodyJson, {
|
||||||
@@ -1204,14 +1227,15 @@ export default function ProxyLogs() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'grid', gap: 8 }}>
|
<DetailDisclosureCard title={`Attempt 记录 (${selectedDebugTraceDetail.data.attempts.length})`}>
|
||||||
<div style={detailSectionTitleStyle}>Attempt 记录</div>
|
<div style={{ padding: 12, display: 'grid', gap: 8 }}>
|
||||||
{selectedDebugTraceDetail.data.attempts.length === 0 ? (
|
{selectedDebugTraceDetail.data.attempts.length === 0 ? (
|
||||||
<div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>暂无 attempt 记录</div>
|
<div style={{ color: 'var(--color-text-muted)', fontSize: 13 }}>暂无 attempt 记录</div>
|
||||||
) : (
|
) : (
|
||||||
selectedDebugTraceDetail.data.attempts.map(renderAttemptDetail)
|
selectedDebugTraceDetail.data.attempts.map(renderAttemptDetail)
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</DetailDisclosureCard>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1567,7 +1591,7 @@ export default function ProxyLogs() {
|
|||||||
<div>
|
<div>
|
||||||
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>最近调试追踪</div>
|
<div style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>最近调试追踪</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 4 }}>
|
||||||
按最新 20 条展示,点击查看详情会打开完整请求与 attempt 链路。
|
最多抓最近 20 条,列表分页每页 5 条;打开详情后各段内容可按需展开和收起。
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||||
@@ -1595,7 +1619,7 @@ export default function ProxyLogs() {
|
|||||||
</div>
|
</div>
|
||||||
) : isMobile ? (
|
) : isMobile ? (
|
||||||
<div className="mobile-card-list">
|
<div className="mobile-card-list">
|
||||||
{debugTraces.map((trace) => (
|
{visibleDebugTraces.map((trace) => (
|
||||||
<MobileCard
|
<MobileCard
|
||||||
key={trace.id}
|
key={trace.id}
|
||||||
title={trace.sessionId || `trace-${trace.id}`}
|
title={trace.sessionId || `trace-${trace.id}`}
|
||||||
@@ -1634,7 +1658,7 @@ export default function ProxyLogs() {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{debugTraces.map((trace) => (
|
{visibleDebugTraces.map((trace) => (
|
||||||
<tr key={trace.id}>
|
<tr key={trace.id}>
|
||||||
<td style={{ fontSize: 12, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
<td style={{ fontSize: 12, whiteSpace: 'nowrap', fontVariantNumeric: 'tabular-nums', color: 'var(--color-text-secondary)' }}>
|
||||||
{formatDateTimeLocal(trace.createdAt)}
|
{formatDateTimeLocal(trace.createdAt)}
|
||||||
@@ -1659,6 +1683,38 @@ export default function ProxyLogs() {
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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>
|
</div>
|
||||||
|
|
||||||
{isMobile ? (
|
{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(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
globalThis.document = {
|
globalThis.document = {
|
||||||
|
body: {
|
||||||
|
nodeType: 1,
|
||||||
|
style: {
|
||||||
|
overflow: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
addEventListener: vi.fn(),
|
addEventListener: vi.fn(),
|
||||||
removeEventListener: vi.fn(),
|
removeEventListener: vi.fn(),
|
||||||
} as unknown as Document;
|
} as unknown as Document;
|
||||||
@@ -70,9 +76,19 @@ describe('UpdateCenterSection', () => {
|
|||||||
helper: {
|
helper: {
|
||||||
ok: true,
|
ok: true,
|
||||||
healthy: true,
|
healthy: true,
|
||||||
|
revision: '17',
|
||||||
imageTag: 'latest',
|
imageTag: 'latest',
|
||||||
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||||
history: [
|
history: [
|
||||||
|
{
|
||||||
|
revision: '17',
|
||||||
|
updatedAt: '2026-03-29T12:00:00Z',
|
||||||
|
status: 'deployed',
|
||||||
|
description: 'Upgrade complete',
|
||||||
|
imageRepository: '1467078763/metapi',
|
||||||
|
imageTag: 'latest',
|
||||||
|
imageDigest: 'sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
revision: '16',
|
revision: '16',
|
||||||
updatedAt: '2026-03-28T12:00:00Z',
|
updatedAt: '2026-03-28T12:00:00Z',
|
||||||
@@ -82,6 +98,15 @@ describe('UpdateCenterSection', () => {
|
|||||||
imageTag: 'main',
|
imageTag: 'main',
|
||||||
imageDigest: 'sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
|
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,
|
runningTask: null,
|
||||||
@@ -228,6 +253,7 @@ describe('UpdateCenterSection', () => {
|
|||||||
|
|
||||||
const text = collectText(root.root);
|
const text = collectText(root.root);
|
||||||
expect(text).toContain('latest @ sha256:efb2ee655386');
|
expect(text).toContain('latest @ sha256:efb2ee655386');
|
||||||
|
expect(text).toContain('发现新版本');
|
||||||
expect(text).toContain('Running helm upgrade');
|
expect(text).toContain('Running helm upgrade');
|
||||||
expect(text).toContain('Waiting for rollout');
|
expect(text).toContain('Waiting for rollout');
|
||||||
expect(text).toContain('任务状态 · 已完成');
|
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 () => {
|
it('renders rollback history and triggers rollback tasks for previous revisions', async () => {
|
||||||
let root!: ReactTestRenderer;
|
let root!: ReactTestRenderer;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -4,6 +4,13 @@ import { api } from '../../api.js';
|
|||||||
import ModernSelect from '../../components/ModernSelect.js';
|
import ModernSelect from '../../components/ModernSelect.js';
|
||||||
import { useToast } from '../../components/Toast.js';
|
import { useToast } from '../../components/Toast.js';
|
||||||
import { useIsMobile } from '../../components/useIsMobile.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 = {
|
type UpdateCenterStatus = {
|
||||||
currentVersion?: string;
|
currentVersion?: string;
|
||||||
@@ -206,6 +213,7 @@ export default function UpdateCenterSection() {
|
|||||||
const [config, setConfig] = useState(DEFAULT_CONFIG);
|
const [config, setConfig] = useState(DEFAULT_CONFIG);
|
||||||
const [logs, setLogs] = useState<string[]>([]);
|
const [logs, setLogs] = useState<string[]>([]);
|
||||||
const [taskStatus, setTaskStatus] = useState('');
|
const [taskStatus, setTaskStatus] = useState('');
|
||||||
|
const [historyModalOpen, setHistoryModalOpen] = useState(false);
|
||||||
const streamAbortRef = useRef<AbortController | null>(null);
|
const streamAbortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
const loadStatus = async () => {
|
const loadStatus = async () => {
|
||||||
@@ -367,29 +375,33 @@ export default function UpdateCenterSection() {
|
|||||||
const runningTaskBadge = getTaskBadge(status?.runningTask?.status || taskStatus || undefined);
|
const runningTaskBadge = getTaskBadge(status?.runningTask?.status || taskStatus || undefined);
|
||||||
const lastFinishedTaskBadge = getTaskBadge(status?.lastFinishedTask?.status || undefined);
|
const lastFinishedTaskBadge = getTaskBadge(status?.lastFinishedTask?.status || undefined);
|
||||||
const visibleTaskStatus = taskStatus || status?.runningTask?.status || status?.lastFinishedTask?.status || 'idle';
|
const visibleTaskStatus = taskStatus || status?.runningTask?.status || status?.lastFinishedTask?.status || 'idle';
|
||||||
|
const githubDeployState = describeGitHubDeployState({
|
||||||
const canDeployGithub = Boolean(
|
enabled: config.enabled && config.githubReleasesEnabled,
|
||||||
!deploying
|
helperHealthy,
|
||||||
&& config.enabled
|
helperError: status?.helper?.error,
|
||||||
&& helperHealthy
|
currentVersion: status?.currentVersion,
|
||||||
&& config.githubReleasesEnabled
|
helperImageTag: status?.helper?.imageTag,
|
||||||
&& status?.githubRelease?.normalizedVersion,
|
candidate: status?.githubRelease,
|
||||||
);
|
});
|
||||||
const canDeployDocker = Boolean(
|
const dockerDeployState = describeDockerDeployState({
|
||||||
!deploying
|
enabled: config.enabled && config.dockerHubTagsEnabled,
|
||||||
&& config.enabled
|
helperHealthy,
|
||||||
&& helperHealthy
|
helperError: status?.helper?.error,
|
||||||
&& config.dockerHubTagsEnabled
|
currentVersion: status?.currentVersion,
|
||||||
&& status?.dockerHubTag?.normalizedVersion,
|
helper: status?.helper,
|
||||||
);
|
candidate: status?.dockerHubTag,
|
||||||
|
});
|
||||||
const renderDeployReason = (enabled: boolean, version?: string, helperError?: string | null) => {
|
const updateReminder = buildUpdateReminder({
|
||||||
if (!config.enabled) return '更新中心已关闭,保存启用后才能部署。';
|
currentVersion: status?.currentVersion,
|
||||||
if (!enabled) return '当前来源已停用,开启后才会参与检查和部署。';
|
helper: status?.helper,
|
||||||
if (!helperHealthy) return helperError || 'Deploy Helper 未健康,先修复 helper 再部署。';
|
githubRelease: status?.githubRelease,
|
||||||
if (!version) return '当前来源还没有可部署版本。';
|
dockerHubTag: status?.dockerHubTag,
|
||||||
return '版本可用,点击按钮即可通过 helper 发起滚动更新。';
|
});
|
||||||
};
|
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) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
@@ -405,10 +417,18 @@ export default function UpdateCenterSection() {
|
|||||||
return (
|
return (
|
||||||
<div className="card" style={{ padding: 20 }}>
|
<div className="card" style={{ padding: 20 }}>
|
||||||
<div style={{ marginBottom: 14 }}>
|
<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 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.55 }}>
|
||||||
在设置页里统一查看 GitHub Releases、Docker Hub 版本和 K3s helper 状态,避免部署信息散落在多个入口。
|
在设置页里统一查看 GitHub Releases、Docker Hub 版本和 K3s helper 状态,避免部署信息散落在多个入口。
|
||||||
</div>
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: 'var(--color-text-secondary)', lineHeight: 1.5, marginTop: 6 }}>
|
||||||
|
{updateReminder.detail}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -612,17 +632,20 @@ export default function UpdateCenterSection() {
|
|||||||
<span className={getSourceBadge(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion).className}>
|
<span className={getSourceBadge(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion).className}>
|
||||||
{getSourceBadge(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion).label}
|
{getSourceBadge(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion).label}
|
||||||
</span>
|
</span>
|
||||||
|
<span className={`${githubDeployState.badgeClassName} ${githubDeployState.highlight ? 'stat-value-glow' : ''}`.trim()}>
|
||||||
|
{githubDeployState.badgeLabel}
|
||||||
|
</span>
|
||||||
{config.defaultDeploySource === 'github-release' ? (
|
{config.defaultDeploySource === 'github-release' ? (
|
||||||
<span className="badge badge-info">默认来源</span>
|
<span className="badge badge-info">默认来源</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>优先使用仓库稳定版 release,适合保留语义化版本节奏。</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 || '未发现'}
|
{status?.githubRelease?.displayVersion || status?.githubRelease?.normalizedVersion || '未发现'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
||||||
{renderDeployReason(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion, status?.helper?.error)}
|
{githubDeployState.reason}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -648,17 +671,20 @@ export default function UpdateCenterSection() {
|
|||||||
<span className={getSourceBadge(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion).className}>
|
<span className={getSourceBadge(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion).className}>
|
||||||
{getSourceBadge(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion).label}
|
{getSourceBadge(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion).label}
|
||||||
</span>
|
</span>
|
||||||
|
<span className={`${dockerDeployState.badgeClassName} ${dockerDeployState.highlight ? 'stat-value-glow' : ''}`.trim()}>
|
||||||
|
{dockerDeployState.badgeLabel}
|
||||||
|
</span>
|
||||||
{config.defaultDeploySource === 'docker-hub-tag' ? (
|
{config.defaultDeploySource === 'docker-hub-tag' ? (
|
||||||
<span className="badge badge-info">默认来源</span>
|
<span className="badge badge-info">默认来源</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...fieldHintStyle, marginBottom: 10 }}>适合镜像标签领先于 release 的场景,直接跟随容器分发节奏。</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 || '未发现'}
|
{status?.dockerHubTag?.displayVersion || status?.dockerHubTag?.normalizedVersion || '未发现'}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
||||||
{renderDeployReason(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion, status?.helper?.error)}
|
{dockerDeployState.reason}
|
||||||
</div>
|
</div>
|
||||||
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
||||||
最近推送:{formatTaskTime(status?.dockerHubTag?.publishedAt)}
|
最近推送:{formatTaskTime(status?.dockerHubTag?.publishedAt)}
|
||||||
@@ -730,63 +756,41 @@ export default function UpdateCenterSection() {
|
|||||||
<div style={{ ...sectionPanelStyle, marginBottom: 12 }}>
|
<div style={{ ...sectionPanelStyle, marginBottom: 12 }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||||
<div style={{ fontWeight: 600, fontSize: 13 }}>回退历史</div>
|
<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>
|
||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||||
这里展示 helper 读到的最近 Helm revision。支持直接回退到某个旧 revision,适合快速滚回上一次稳定镜像。
|
页面里默认只保留最近 revision 预览,完整历史放进弹窗,避免设置页被长回退列表拖得过长。
|
||||||
</div>
|
</div>
|
||||||
{status?.helper?.history && status.helper.history.length > 0 ? (
|
{helperHistory.length > 0 ? (
|
||||||
<div style={{ display: 'grid', gap: 10 }}>
|
<div style={{ display: 'grid', gap: 10 }}>
|
||||||
{status.helper.history.map((entry) => {
|
{historyPreview.map((entry) => {
|
||||||
const revision = String(entry?.revision || '').trim();
|
const revision = String(entry?.revision || '').trim();
|
||||||
const isCurrentRevision = revision && revision === String(status?.helper?.revision || '').trim();
|
|
||||||
return (
|
return (
|
||||||
<div
|
<UpdateCenterHistoryEntryCard
|
||||||
key={revision || 'unknown-revision'}
|
key={revision || 'unknown-revision'}
|
||||||
style={{
|
entry={entry}
|
||||||
border: '1px solid var(--color-border-light)',
|
currentRevision={currentRevision}
|
||||||
borderRadius: 'var(--radius-sm)',
|
helperHealthy={helperHealthy}
|
||||||
padding: 12,
|
deploying={deploying}
|
||||||
display: 'grid',
|
compact
|
||||||
gap: 6,
|
formatTaskTime={formatTaskTime}
|
||||||
background: 'var(--color-bg-card)',
|
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>
|
</div>
|
||||||
@@ -845,6 +849,20 @@ export default function UpdateCenterSection() {
|
|||||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 10 }}>
|
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 10 }}>
|
||||||
最近状态:{visibleTaskStatus}
|
最近状态:{visibleTaskStatus}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user