[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:
Cita
2026-03-29 17:43:49 +08:00
committed by GitHub
parent dcfd45ea65
commit 11583c51b9
30 changed files with 3819 additions and 7 deletions
+49 -1
View File
@@ -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();
}
});
});
+3
View File
@@ -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>
);
}