forked from boos/BoosAPI
[codex] add K3s update center (#314)
* add k3s update center * fix update center status timeouts * Polish update center settings UI * Document k3s update center * Refocus update center docs for users * Clarify k3s migration path in docs
This commit is contained in:
+49
-1
@@ -1,3 +1,7 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { api } from '../api.js';
|
||||
import { tr } from '../i18n.js';
|
||||
import { SITE_DOCS_URL } from '../docsLink.js';
|
||||
|
||||
@@ -30,6 +34,37 @@ const LINKS = [
|
||||
];
|
||||
|
||||
export default function About() {
|
||||
const [currentVersion, setCurrentVersion] = useState(`v${VERSION}`);
|
||||
const [latestGitHubVersion, setLatestGitHubVersion] = useState('');
|
||||
const [latestDockerHubVersion, setLatestDockerHubVersion] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const loadStatus = async () => {
|
||||
try {
|
||||
const status = await api.getUpdateCenterStatus() as {
|
||||
currentVersion?: string;
|
||||
githubRelease?: { normalizedVersion?: string } | null;
|
||||
dockerHubTag?: { normalizedVersion?: string } | null;
|
||||
};
|
||||
if (cancelled) return;
|
||||
if (status.currentVersion) {
|
||||
setCurrentVersion(`v${status.currentVersion}`);
|
||||
}
|
||||
setLatestGitHubVersion(String(status.githubRelease?.normalizedVersion || ''));
|
||||
setLatestDockerHubVersion(String(status.dockerHubTag?.normalizedVersion || ''));
|
||||
} catch {
|
||||
// ignore update-center lookup failures on about page
|
||||
}
|
||||
};
|
||||
|
||||
void loadStatus();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="animate-fade-in" style={{ maxWidth: 860 }}>
|
||||
{/* Header */}
|
||||
@@ -47,7 +82,7 @@ export default function About() {
|
||||
/>
|
||||
<div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>Metapi</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>v{VERSION}</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-tertiary)', marginTop: 2 }}>{currentVersion}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ fontSize: 14, color: 'var(--color-text-secondary)', lineHeight: 1.8 }}>
|
||||
@@ -55,6 +90,19 @@ export default function About() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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: 'grid', gap: 8, fontSize: 13 }}>
|
||||
<div>GitHub 稳定版:{latestGitHubVersion || '暂无数据'}</div>
|
||||
<div>Docker Hub:{latestDockerHubVersion || '暂无数据'}</div>
|
||||
<div>
|
||||
<Link to="/settings" style={{ color: 'var(--color-primary)', textDecoration: 'none', fontWeight: 600 }}>
|
||||
前往更新中心
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Features */}
|
||||
<div className="card animate-slide-up stagger-2" style={{ padding: 22, marginBottom: 14 }}>
|
||||
<h3 style={{ fontSize: 15, fontWeight: 600, marginBottom: 14 }}>{tr('核心特色')}</h3>
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
|
||||
import About from './About.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getUpdateCenterStatus: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('About update center', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
apiMock.getUpdateCenterStatus.mockResolvedValue({
|
||||
currentVersion: '1.2.3',
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
url: 'https://github.com/cita-777/metapi/releases/tag/v1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.3.1',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shows current version and newer release summaries', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<About />
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('v1.2.3');
|
||||
expect(text).toContain('GitHub 稳定版');
|
||||
expect(text).toContain('1.3.0');
|
||||
expect(text).toContain('Docker Hub');
|
||||
expect(text).toContain('1.3.1');
|
||||
expect(text).toContain('前往更新中心');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import ModernSelect from '../components/ModernSelect.js';
|
||||
import DownstreamApiKeyModal from './settings/DownstreamApiKeyModal.js';
|
||||
import FactoryResetModal from './settings/FactoryResetModal.js';
|
||||
import RouteSelectorModal from './settings/RouteSelectorModal.js';
|
||||
import UpdateCenterSection from './settings/UpdateCenterSection.js';
|
||||
import {
|
||||
applyRoutingProfilePreset,
|
||||
resolveRoutingProfilePreset,
|
||||
@@ -1759,6 +1760,8 @@ export default function Settings() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<UpdateCenterSection />
|
||||
|
||||
<div className="card animate-slide-up stagger-6" style={{ padding: 20 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 12 }}>维护工具</div>
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
|
||||
@@ -168,9 +168,14 @@ describe('Settings log cleanup schedule', () => {
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).trim() === '测试一次签到'
|
||||
));
|
||||
const scheduleCard = root.root.find((node) => (
|
||||
node.type === 'div'
|
||||
&& String(node.props.className || '').includes('card')
|
||||
&& collectText(node).includes('定时任务')
|
||||
));
|
||||
|
||||
expect(root.root.findAllByType('select')).toHaveLength(0);
|
||||
expect(root.root.findAllByType(ModernSelect).length).toBeGreaterThanOrEqual(3);
|
||||
expect(scheduleCard.findAllByType('select')).toHaveLength(0);
|
||||
expect(scheduleCard.findAllByType(ModernSelect).length).toBeGreaterThanOrEqual(2);
|
||||
expect(String(triggerButton.props.className || '')).toContain('btn-ghost');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { ToastProvider } from '../../components/Toast.js';
|
||||
|
||||
import UpdateCenterSection from './UpdateCenterSection.js';
|
||||
|
||||
const { apiMock } = vi.hoisted(() => ({
|
||||
apiMock: {
|
||||
getUpdateCenterStatus: vi.fn(),
|
||||
saveUpdateCenterConfig: vi.fn(),
|
||||
checkUpdateCenter: vi.fn(),
|
||||
deployUpdateCenter: vi.fn(),
|
||||
streamUpdateCenterTaskLogs: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../api.js', () => ({
|
||||
api: apiMock,
|
||||
}));
|
||||
|
||||
function collectText(node: ReactTestInstance): string {
|
||||
return (node.children || []).map((child) => {
|
||||
if (typeof child === 'string') return child;
|
||||
return collectText(child);
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function flushMicrotasks() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
describe('UpdateCenterSection', () => {
|
||||
const originalDocument = globalThis.document;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.document = {
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
} as unknown as Document;
|
||||
apiMock.getUpdateCenterStatus.mockResolvedValue({
|
||||
currentVersion: '1.2.3',
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.3.1',
|
||||
},
|
||||
helper: {
|
||||
ok: true,
|
||||
healthy: true,
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
});
|
||||
apiMock.saveUpdateCenterConfig.mockResolvedValue({
|
||||
success: true,
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://updated-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
});
|
||||
apiMock.checkUpdateCenter.mockResolvedValue({
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.3.1',
|
||||
},
|
||||
});
|
||||
apiMock.deployUpdateCenter.mockResolvedValue({
|
||||
success: true,
|
||||
reused: false,
|
||||
task: {
|
||||
id: 'task-1',
|
||||
},
|
||||
});
|
||||
apiMock.streamUpdateCenterTaskLogs.mockImplementation(async (_taskId: string, handlers: { onLog?: (entry: { message: string }) => void; onDone?: (payload: { status: string }) => void }) => {
|
||||
handlers.onLog?.({ message: 'Running helm upgrade' });
|
||||
handlers.onLog?.({ message: 'Waiting for rollout' });
|
||||
handlers.onDone?.({ status: 'succeeded' });
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
globalThis.document = originalDocument;
|
||||
});
|
||||
|
||||
it('loads status, saves config updates, and renders streamed deploy logs', async () => {
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const helperInput = root.root.find((node) => (
|
||||
node.type === 'input'
|
||||
&& node.props.value === 'http://metapi-deploy-helper.ai.svc.cluster.local:9850'
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
helperInput.props.onChange({ target: { value: 'http://updated-helper.ai.svc.cluster.local:9850' } });
|
||||
});
|
||||
const checkboxInputs = root.root.findAll((node) => node.type === 'input' && node.props.type === 'checkbox');
|
||||
|
||||
await act(async () => {
|
||||
checkboxInputs[1].props.onChange({ target: { checked: false } });
|
||||
checkboxInputs[2].props.onChange({ target: { checked: false } });
|
||||
});
|
||||
|
||||
const defaultSourceTrigger = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-trigger')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
defaultSourceTrigger.props.onClick();
|
||||
});
|
||||
|
||||
const dockerHubOption = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('modern-select-option')
|
||||
&& collectText(node).includes('Docker Hub Tags')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
dockerHubOption.props.onClick();
|
||||
});
|
||||
|
||||
const saveButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('保存更新中心配置')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await saveButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.saveUpdateCenterConfig).toHaveBeenCalledWith(expect.objectContaining({
|
||||
helperBaseUrl: 'http://updated-helper.ai.svc.cluster.local:9850',
|
||||
githubReleasesEnabled: false,
|
||||
dockerHubTagsEnabled: false,
|
||||
defaultDeploySource: 'docker-hub-tag',
|
||||
}));
|
||||
|
||||
const deployButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.onClick === 'function'
|
||||
&& collectText(node).includes('部署 GitHub 稳定版')
|
||||
));
|
||||
|
||||
await act(async () => {
|
||||
await deployButton.props.onClick();
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
expect(apiMock.deployUpdateCenter).toHaveBeenCalledWith({
|
||||
source: 'github-release',
|
||||
targetVersion: '1.3.0',
|
||||
});
|
||||
expect(apiMock.streamUpdateCenterTaskLogs).toHaveBeenCalledWith('task-1', expect.any(Object));
|
||||
|
||||
const text = collectText(root.root);
|
||||
expect(text).toContain('Running helm upgrade');
|
||||
expect(text).toContain('Waiting for rollout');
|
||||
expect(text).toContain('任务状态 · 已完成');
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('disables deploy actions when the helper is unhealthy', async () => {
|
||||
apiMock.getUpdateCenterStatus.mockResolvedValueOnce({
|
||||
currentVersion: '1.2.3',
|
||||
config: {
|
||||
enabled: true,
|
||||
helperBaseUrl: 'http://metapi-deploy-helper.ai.svc.cluster.local:9850',
|
||||
namespace: 'ai',
|
||||
releaseName: 'metapi',
|
||||
chartRef: 'oci://ghcr.io/cita-777/charts/metapi',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
},
|
||||
githubRelease: {
|
||||
normalizedVersion: '1.3.0',
|
||||
},
|
||||
dockerHubTag: {
|
||||
normalizedVersion: '1.3.1',
|
||||
},
|
||||
helper: {
|
||||
ok: false,
|
||||
healthy: false,
|
||||
error: 'helper unavailable',
|
||||
},
|
||||
runningTask: null,
|
||||
lastFinishedTask: null,
|
||||
});
|
||||
|
||||
let root!: ReactTestRenderer;
|
||||
try {
|
||||
await act(async () => {
|
||||
root = create(
|
||||
<MemoryRouter>
|
||||
<ToastProvider>
|
||||
<UpdateCenterSection />
|
||||
</ToastProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
});
|
||||
await flushMicrotasks();
|
||||
|
||||
const githubDeployButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn')
|
||||
&& collectText(node).includes('部署 GitHub 稳定版')
|
||||
));
|
||||
const dockerDeployButton = root.root.find((node) => (
|
||||
node.type === 'button'
|
||||
&& typeof node.props.className === 'string'
|
||||
&& node.props.className.includes('btn')
|
||||
&& collectText(node).includes('部署 Docker Hub 标签')
|
||||
));
|
||||
|
||||
expect(githubDeployButton.props.disabled).toBe(true);
|
||||
expect(dockerDeployButton.props.disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
githubDeployButton.props.onClick?.();
|
||||
dockerDeployButton.props.onClick?.();
|
||||
});
|
||||
|
||||
expect(apiMock.deployUpdateCenter).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
root?.unmount();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,679 @@
|
||||
import React, { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
|
||||
import { api } from '../../api.js';
|
||||
import ModernSelect from '../../components/ModernSelect.js';
|
||||
import { useToast } from '../../components/Toast.js';
|
||||
import { useIsMobile } from '../../components/useIsMobile.js';
|
||||
|
||||
type UpdateCenterStatus = {
|
||||
currentVersion?: string;
|
||||
config?: {
|
||||
enabled: boolean;
|
||||
helperBaseUrl: string;
|
||||
namespace: string;
|
||||
releaseName: string;
|
||||
chartRef: string;
|
||||
imageRepository: string;
|
||||
githubReleasesEnabled: boolean;
|
||||
dockerHubTagsEnabled: boolean;
|
||||
defaultDeploySource: 'github-release' | 'docker-hub-tag';
|
||||
};
|
||||
githubRelease?: {
|
||||
normalizedVersion?: string;
|
||||
} | null;
|
||||
dockerHubTag?: {
|
||||
normalizedVersion?: string;
|
||||
} | null;
|
||||
helper?: {
|
||||
ok?: boolean;
|
||||
healthy?: boolean;
|
||||
error?: string | null;
|
||||
} | null;
|
||||
runningTask?: {
|
||||
id?: string;
|
||||
status?: string;
|
||||
} | null;
|
||||
lastFinishedTask?: {
|
||||
id?: string;
|
||||
status?: string;
|
||||
finishedAt?: string | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
const DEFAULT_CONFIG: NonNullable<UpdateCenterStatus['config']> = {
|
||||
enabled: false,
|
||||
helperBaseUrl: '',
|
||||
namespace: 'default',
|
||||
releaseName: '',
|
||||
chartRef: '',
|
||||
imageRepository: '1467078763/metapi',
|
||||
githubReleasesEnabled: true,
|
||||
dockerHubTagsEnabled: true,
|
||||
defaultDeploySource: 'github-release',
|
||||
};
|
||||
|
||||
const DEPLOY_SOURCE_OPTIONS = [
|
||||
{
|
||||
value: 'github-release',
|
||||
label: 'GitHub Releases',
|
||||
description: '优先跟踪仓库稳定版 release。',
|
||||
},
|
||||
{
|
||||
value: 'docker-hub-tag',
|
||||
label: 'Docker Hub Tags',
|
||||
description: '适合直接跟随镜像标签推进部署。',
|
||||
},
|
||||
] as const;
|
||||
|
||||
const inputStyle: CSSProperties = {
|
||||
width: '100%',
|
||||
padding: '10px 14px',
|
||||
border: '1px solid var(--color-border)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
fontSize: 13,
|
||||
outline: 'none',
|
||||
background: 'var(--color-bg)',
|
||||
color: 'var(--color-text-primary)',
|
||||
};
|
||||
|
||||
const sectionPanelStyle: CSSProperties = {
|
||||
border: '1px solid var(--color-border-light)',
|
||||
borderRadius: 'var(--radius-md)',
|
||||
padding: 14,
|
||||
background: 'var(--color-bg)',
|
||||
};
|
||||
|
||||
const summaryLabelStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
const summaryValueStyle: CSSProperties = {
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
color: 'var(--color-text-primary)',
|
||||
lineHeight: 1.4,
|
||||
};
|
||||
|
||||
const fieldLabelStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-muted)',
|
||||
marginBottom: 6,
|
||||
};
|
||||
|
||||
const fieldHintStyle: CSSProperties = {
|
||||
fontSize: 12,
|
||||
color: 'var(--color-text-muted)',
|
||||
lineHeight: 1.5,
|
||||
};
|
||||
|
||||
function formatTaskTime(value?: string | null) {
|
||||
if (!value) return '暂无完成记录';
|
||||
const timestamp = Date.parse(value);
|
||||
if (!Number.isFinite(timestamp)) return value;
|
||||
return new Date(timestamp).toLocaleString('zh-CN', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function getTaskBadge(status?: string | null) {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return { className: 'badge badge-info', label: '进行中' };
|
||||
case 'pending':
|
||||
return { className: 'badge badge-warning', label: '排队中' };
|
||||
case 'succeeded':
|
||||
return { className: 'badge badge-success', label: '已完成' };
|
||||
case 'failed':
|
||||
return { className: 'badge badge-error', label: '失败' };
|
||||
default:
|
||||
return { className: 'badge badge-muted', label: '空闲' };
|
||||
}
|
||||
}
|
||||
|
||||
function getHelperBadge(helper?: UpdateCenterStatus['helper'] | null, helperBaseUrl?: string) {
|
||||
if (!helperBaseUrl) {
|
||||
return { className: 'badge badge-muted', label: '未配置' };
|
||||
}
|
||||
if (helper?.healthy) {
|
||||
return { className: 'badge badge-success', label: 'Healthy' };
|
||||
}
|
||||
if (helper?.ok) {
|
||||
return { className: 'badge badge-warning', label: '可达但未就绪' };
|
||||
}
|
||||
return { className: 'badge badge-error', label: '不可用' };
|
||||
}
|
||||
|
||||
function getSourceBadge(enabled: boolean, version?: string) {
|
||||
if (!enabled) {
|
||||
return { className: 'badge badge-muted', label: '已停用' };
|
||||
}
|
||||
if (version) {
|
||||
return { className: 'badge badge-success', label: '可部署' };
|
||||
}
|
||||
return { className: 'badge badge-warning', label: '未发现版本' };
|
||||
}
|
||||
|
||||
export default function UpdateCenterSection() {
|
||||
const toast = useToast();
|
||||
const isMobile = useIsMobile();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [deploying, setDeploying] = useState(false);
|
||||
const [status, setStatus] = useState<UpdateCenterStatus | null>(null);
|
||||
const [config, setConfig] = useState(DEFAULT_CONFIG);
|
||||
const [logs, setLogs] = useState<string[]>([]);
|
||||
const [taskStatus, setTaskStatus] = useState('');
|
||||
const streamAbortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const loadStatus = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const next = await api.getUpdateCenterStatus() as UpdateCenterStatus;
|
||||
setStatus(next);
|
||||
setConfig(next.config || DEFAULT_CONFIG);
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '加载更新中心失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void loadStatus();
|
||||
return () => {
|
||||
streamAbortRef.current?.abort();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const saveConfig = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await api.saveUpdateCenterConfig(config) as { config?: UpdateCenterStatus['config'] };
|
||||
const nextConfig = result.config || config;
|
||||
setConfig(nextConfig);
|
||||
setStatus((prev) => ({
|
||||
...(prev || {}),
|
||||
config: nextConfig,
|
||||
}));
|
||||
toast.success('更新中心配置已保存');
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '保存更新中心配置失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const checkNow = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
const next = await api.checkUpdateCenter() as UpdateCenterStatus;
|
||||
setStatus(next);
|
||||
setConfig(next.config || DEFAULT_CONFIG);
|
||||
toast.success('已刷新更新信息');
|
||||
} catch (error: any) {
|
||||
toast.error(error?.message || '检查更新失败');
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const runDeploy = async (source: 'github-release' | 'docker-hub-tag', targetVersion: string) => {
|
||||
if (!targetVersion) return;
|
||||
setDeploying(true);
|
||||
setLogs([]);
|
||||
setTaskStatus('running');
|
||||
streamAbortRef.current?.abort();
|
||||
streamAbortRef.current = new AbortController();
|
||||
let taskId = '';
|
||||
|
||||
try {
|
||||
const response = await api.deployUpdateCenter({ source, targetVersion }) as { task?: { id: string } };
|
||||
taskId = response.task?.id || '';
|
||||
if (!taskId) {
|
||||
throw new Error('部署任务未返回 taskId');
|
||||
}
|
||||
|
||||
await api.streamUpdateCenterTaskLogs(taskId, {
|
||||
signal: streamAbortRef.current.signal,
|
||||
onLog: (entry) => {
|
||||
const message = String(entry?.message || '').trim();
|
||||
if (!message) return;
|
||||
setLogs((prev) => [...prev, message].slice(-200));
|
||||
},
|
||||
onDone: (payload) => {
|
||||
setTaskStatus(String(payload?.status || 'unknown'));
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
if (taskId) {
|
||||
try {
|
||||
const taskResponse = await api.getTask(taskId) as { task?: { status?: string; logs?: Array<{ message?: string }> } };
|
||||
const task = taskResponse.task;
|
||||
if (task) {
|
||||
setTaskStatus(String(task.status || 'unknown'));
|
||||
setLogs(Array.isArray(task.logs) ? task.logs.map((entry) => String(entry?.message || '')).filter(Boolean) : []);
|
||||
toast.info('实时日志流已断开,已回退到任务详情快照');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// fall through to the generic error state
|
||||
}
|
||||
}
|
||||
setTaskStatus('failed');
|
||||
toast.error(error?.message || '部署失败');
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
void loadStatus();
|
||||
}
|
||||
};
|
||||
|
||||
const helperHealthy = !!status?.helper?.healthy;
|
||||
const helperBadge = getHelperBadge(status?.helper, config.helperBaseUrl);
|
||||
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 发起滚动更新。';
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 6 }}>更新中心</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)' }}>
|
||||
正在加载部署来源、版本状态和 helper 健康检查...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card" style={{ padding: 20 }}>
|
||||
<div style={{ marginBottom: 14 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 14, marginBottom: 6 }}>更新中心</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', lineHeight: 1.55 }}>
|
||||
在设置页里统一查看 GitHub Releases、Docker Hub 版本和 K3s helper 状态,避免部署信息散落在多个入口。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={summaryLabelStyle}>当前运行版本</div>
|
||||
<div style={{ ...summaryValueStyle, fontFamily: 'var(--font-mono)' }}>
|
||||
{status?.currentVersion || '-'}
|
||||
</div>
|
||||
<div style={fieldHintStyle}>以当前容器内运行版本为准。</div>
|
||||
</div>
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={summaryLabelStyle}>Deploy Helper</div>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<span className={helperBadge.className}>{helperBadge.label}</span>
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, fontFamily: config.helperBaseUrl ? 'var(--font-mono)' : 'inherit' }}>
|
||||
{config.helperBaseUrl || '尚未配置 Helper URL'}
|
||||
</div>
|
||||
</div>
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={summaryLabelStyle}>默认部署来源</div>
|
||||
<div style={summaryValueStyle}>
|
||||
{DEPLOY_SOURCE_OPTIONS.find((item) => item.value === config.defaultDeploySource)?.label || 'GitHub Releases'}
|
||||
</div>
|
||||
<div style={fieldHintStyle}>保存配置后,手动部署默认优先使用这里的来源。</div>
|
||||
</div>
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={summaryLabelStyle}>最近任务</div>
|
||||
<div style={{ marginBottom: 6 }}>
|
||||
<span className={status?.runningTask ? runningTaskBadge.className : lastFinishedTaskBadge.className}>
|
||||
{status?.runningTask ? `运行中 · ${runningTaskBadge.label}` : lastFinishedTaskBadge.label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={fieldHintStyle}>
|
||||
{status?.runningTask?.id
|
||||
? `任务 ID: ${status.runningTask.id}`
|
||||
: formatTaskTime(status?.lastFinishedTask?.finishedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(3, minmax(0, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<label style={{ ...sectionPanelStyle, display: 'flex', gap: 10, alignItems: 'flex-start', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.enabled}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, enabled: e.target.checked }))}
|
||||
style={{ width: 16, height: 16, marginTop: 2, accentColor: 'var(--color-primary)' }}
|
||||
/>
|
||||
<span style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>启用更新中心</span>
|
||||
<span style={fieldHintStyle}>允许系统检查版本,并在配置齐全时触发 K3s 部署。</span>
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ ...sectionPanelStyle, display: 'flex', gap: 10, alignItems: 'flex-start', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.githubReleasesEnabled}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, githubReleasesEnabled: e.target.checked }))}
|
||||
style={{ width: 16, height: 16, marginTop: 2, accentColor: 'var(--color-primary)' }}
|
||||
/>
|
||||
<span style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>GitHub Releases</span>
|
||||
<span style={fieldHintStyle}>从仓库稳定版 release 提取 SemVer 版本号并提供部署入口。</span>
|
||||
</span>
|
||||
</label>
|
||||
<label style={{ ...sectionPanelStyle, display: 'flex', gap: 10, alignItems: 'flex-start', cursor: 'pointer' }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={config.dockerHubTagsEnabled}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, dockerHubTagsEnabled: e.target.checked }))}
|
||||
style={{ width: 16, height: 16, marginTop: 2, accentColor: 'var(--color-primary)' }}
|
||||
/>
|
||||
<span style={{ display: 'grid', gap: 4 }}>
|
||||
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--color-text-primary)' }}>Docker Hub</span>
|
||||
<span style={fieldHintStyle}>从镜像标签中找出最新稳定版本,适合镜像优先的发布链路。</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ ...sectionPanelStyle, marginBottom: 12 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 6 }}>部署配置</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
和现有 Settings 区块保持同一套表单密度。这里保存的是 helper 和目标 release 的持久化配置。
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<div style={fieldLabelStyle}>Deploy Helper URL</div>
|
||||
<input
|
||||
value={config.helperBaseUrl}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, helperBaseUrl: e.target.value }))}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
placeholder="http://metapi-deploy-helper.namespace.svc.cluster.local:9850"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<div style={fieldLabelStyle}>默认部署来源</div>
|
||||
<ModernSelect
|
||||
value={config.defaultDeploySource}
|
||||
onChange={(value) => setConfig((prev) => ({
|
||||
...prev,
|
||||
defaultDeploySource: value === 'docker-hub-tag' ? 'docker-hub-tag' : 'github-release',
|
||||
}))}
|
||||
options={DEPLOY_SOURCE_OPTIONS.map((item) => ({ ...item }))}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<div style={fieldLabelStyle}>Namespace</div>
|
||||
<input
|
||||
value={config.namespace}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, namespace: e.target.value }))}
|
||||
style={inputStyle}
|
||||
placeholder="default"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<div style={fieldLabelStyle}>Release Name</div>
|
||||
<input
|
||||
value={config.releaseName}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, releaseName: e.target.value }))}
|
||||
style={inputStyle}
|
||||
placeholder="metapi"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<div style={fieldLabelStyle}>Chart Ref</div>
|
||||
<input
|
||||
value={config.chartRef}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, chartRef: e.target.value }))}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
placeholder="oci://ghcr.io/cita-777/charts/metapi"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<div style={fieldLabelStyle}>Image Repository</div>
|
||||
<input
|
||||
value={config.imageRepository}
|
||||
onChange={(e) => setConfig((prev) => ({ ...prev, imageRepository: e.target.value }))}
|
||||
style={{ ...inputStyle, fontFamily: 'var(--font-mono)' }}
|
||||
placeholder="1467078763/metapi"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap' }}>
|
||||
<button type="button" onClick={saveConfig} disabled={saving} className="btn btn-primary">
|
||||
{saving ? '保存中...' : '保存更新中心配置'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkNow}
|
||||
disabled={checking}
|
||||
className="btn btn-ghost"
|
||||
style={{ border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
{checking ? '检查中...' : '检查更新'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ ...sectionPanelStyle, marginBottom: 12 }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13, marginBottom: 6 }}>可部署版本</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 12 }}>
|
||||
默认来源会用主按钮强调。Helper 未就绪时,部署入口会自动禁用,避免触发无效任务。
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>GitHub Releases</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className={getSourceBadge(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion).className}>
|
||||
{getSourceBadge(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion).label}
|
||||
</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 }}>
|
||||
{status?.githubRelease?.normalizedVersion || '未发现'}
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
||||
{renderDeployReason(config.githubReleasesEnabled, status?.githubRelease?.normalizedVersion, status?.helper?.error)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!helperHealthy) return;
|
||||
void runDeploy('github-release', status?.githubRelease?.normalizedVersion || '');
|
||||
}}
|
||||
disabled={!canDeployGithub}
|
||||
className={config.defaultDeploySource === 'github-release' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
style={config.defaultDeploySource === 'github-release' ? undefined : { border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
部署 GitHub 稳定版
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontSize: 13, fontWeight: 600 }}>Docker Hub</div>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<span className={getSourceBadge(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion).className}>
|
||||
{getSourceBadge(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion).label}
|
||||
</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 }}>
|
||||
{status?.dockerHubTag?.normalizedVersion || '未发现'}
|
||||
</div>
|
||||
<div style={{ ...fieldHintStyle, marginBottom: 12 }}>
|
||||
{renderDeployReason(config.dockerHubTagsEnabled, status?.dockerHubTag?.normalizedVersion, status?.helper?.error)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!helperHealthy) return;
|
||||
void runDeploy('docker-hub-tag', status?.dockerHubTag?.normalizedVersion || '');
|
||||
}}
|
||||
disabled={!canDeployDocker}
|
||||
className={config.defaultDeploySource === 'docker-hub-tag' ? 'btn btn-primary' : 'btn btn-ghost'}
|
||||
style={config.defaultDeploySource === 'docker-hub-tag' ? undefined : { border: '1px solid var(--color-border)' }}
|
||||
>
|
||||
部署 Docker Hub 标签
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: isMobile ? '1fr' : 'repeat(2, minmax(0, 1fr))',
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={fieldLabelStyle}>Helper 健康摘要</div>
|
||||
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginBottom: 6 }}>
|
||||
<span className={helperBadge.className}>{helperBadge.label}</span>
|
||||
<span className={runningTaskBadge.className}>当前任务 · {runningTaskBadge.label}</span>
|
||||
</div>
|
||||
<div style={fieldHintStyle}>
|
||||
{status?.helper?.error || 'Helper 正常时会先执行 helm upgrade,再等待 kubectl rollout status。'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={fieldLabelStyle}>任务快照</div>
|
||||
<div style={{ display: 'grid', gap: 6, fontSize: 13, color: 'var(--color-text-primary)' }}>
|
||||
<div>
|
||||
运行中任务:
|
||||
<span style={{ marginLeft: 6, color: 'var(--color-text-secondary)' }}>
|
||||
{status?.runningTask?.id ? `${status.runningTask.id} · ${status.runningTask.status || '-'}` : '无'}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
最近完成:
|
||||
<span style={{ marginLeft: 6, color: 'var(--color-text-secondary)' }}>
|
||||
{status?.lastFinishedTask?.id
|
||||
? `${status.lastFinishedTask.id} · ${status.lastFinishedTask.status || '-'}`
|
||||
: '无'}
|
||||
</span>
|
||||
</div>
|
||||
<div style={fieldHintStyle}>
|
||||
完成时间:{formatTaskTime(status?.lastFinishedTask?.finishedAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={sectionPanelStyle}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8, marginBottom: 10, flexWrap: 'wrap' }}>
|
||||
<div style={{ fontWeight: 600, fontSize: 13 }}>部署日志</div>
|
||||
<span className={getTaskBadge(visibleTaskStatus).className}>
|
||||
任务状态 · {getTaskBadge(visibleTaskStatus).label}
|
||||
</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginBottom: 10 }}>
|
||||
日志流断开时会自动回退到任务快照。这里只保留最近 200 条输出,方便直接判断 Helm 与 rollout 卡在哪一步。
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
border: '1px solid var(--color-border-light)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
background: 'var(--color-bg-card)',
|
||||
padding: 12,
|
||||
minHeight: 120,
|
||||
}}
|
||||
>
|
||||
{logs.length > 0 ? (
|
||||
<pre
|
||||
style={{
|
||||
margin: 0,
|
||||
whiteSpace: 'pre-wrap',
|
||||
wordBreak: 'break-word',
|
||||
fontSize: 12,
|
||||
lineHeight: 1.65,
|
||||
color: 'var(--color-text-secondary)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
}}
|
||||
>
|
||||
{logs.join('\n')}
|
||||
</pre>
|
||||
) : (
|
||||
<div style={{ ...fieldHintStyle, minHeight: 94, display: 'flex', alignItems: 'center' }}>
|
||||
部署开始后,这里会实时显示 helm upgrade、kubectl rollout status 和回滚日志。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{status?.helper?.error ? (
|
||||
<div style={{ fontSize: 12, color: 'var(--color-danger)', marginTop: 10 }}>
|
||||
Helper 错误:{status.helper.error}
|
||||
</div>
|
||||
) : null}
|
||||
<div style={{ fontSize: 12, color: 'var(--color-text-muted)', marginTop: 10 }}>
|
||||
最近状态:{visibleTaskStatus}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user