feat(web): add console update panel, system maintenance path and connection settings UI

- Add online update workflow with plan, backup and install states.

- Expose maintenance paths, component backup jobs and connection authorization modes.

- Align auth and automation screens with the fused control plane.
This commit is contained in:
chick
2026-09-07 00:45:44 +08:00
parent 6d251dab7b
commit f6319374cd
20 changed files with 1985 additions and 67 deletions
@@ -0,0 +1,337 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Button, Card, Progress, Tag } from 'animal-island-ui';
import { Icon } from '../ui/icon.js';
import {
BUSY_UPDATE_PHASES,
createConsoleUpdateApiDataSource,
type ConsoleUpdateDataSource,
type UpdatePhase,
type UpdateStatus,
} from './console-update-api-data-source.js';
export type {
ConsoleUpdateDataSource,
UpdatePhase,
UpdateStatus,
} from './console-update-api-data-source.js';
const FAST_POLL_MS = 1_000;
const SLOW_POLL_MS = 30_000;
const PHASE_LABELS: Readonly<Record<UpdatePhase, string>> = {
idle: '待检查',
checking: '正在检查',
up_to_date: '已是最新',
update_available: '发现新版本',
downloading: '正在下载',
ready: '待安装',
installing: '正在安装',
install_queued: '安装已排队',
restarting: '正在重启',
failed: '更新失败',
rolled_back: '已回滚',
};
function formatTime(value: string): string {
if (!value) return '尚未检查';
return new Intl.DateTimeFormat('zh-CN', { dateStyle: 'medium', timeStyle: 'short' }).format(
new Date(value),
);
}
function shortHash(commit: string): string {
return commit ? commit.slice(0, 8) : '--';
}
/**
* Online update for the console itself, in the same four steps the Hub uses:
* check, download, install, restart. The panel keeps polling so a restart that
* swaps the process underneath it lands on the confirmed version on its own.
*/
export function ConsoleUpdatePanel({
dataSource,
onCompleted,
}: {
readonly dataSource?: ConsoleUpdateDataSource | undefined;
readonly onCompleted?: ((message: string) => void) | undefined;
}) {
const [source] = useState(() => dataSource ?? createConsoleUpdateApiDataSource());
const [status, setStatus] = useState<UpdateStatus>();
const [loading, setLoading] = useState(true);
const [failed, setFailed] = useState(false);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
const [attempt, setAttempt] = useState(0);
const timer = useRef<number | undefined>(undefined);
const phaseRef = useRef<UpdatePhase>('idle');
const apply = useCallback((next: UpdateStatus) => {
phaseRef.current = next.phase;
setStatus(next);
setLoading(false);
setFailed(false);
}, []);
// Silent refresh used by the poll loop: a transient failure keeps the last known state.
const refresh = useCallback(async () => {
try {
apply(await source.status());
} catch {
/* the explicit reload path reports the problem */
}
}, [apply, source]);
useEffect(() => {
const controller = new AbortController();
let active = true;
setLoading(true);
setFailed(false);
source.status(controller.signal).then(
(next) => {
if (!active) return;
apply(next);
},
() => {
if (!active || controller.signal.aborted) return;
setFailed(true);
setLoading(false);
},
);
return () => {
active = false;
controller.abort();
};
}, [apply, attempt, source]);
// Poll fast while a phase is in flight, otherwise stay quiet but self-healing. The phase
// lives in a ref so a status change never restarts the timer mid-countdown.
const pollingEnabled = !loading && !failed;
useEffect(() => {
if (!pollingEnabled) return;
let cancelled = false;
const schedule = () => {
const delay = BUSY_UPDATE_PHASES.has(phaseRef.current) ? FAST_POLL_MS : SLOW_POLL_MS;
timer.current = window.setTimeout(() => {
void refresh().finally(() => {
if (!cancelled) schedule();
});
}, delay);
};
schedule();
return () => {
cancelled = true;
window.clearTimeout(timer.current);
};
}, [pollingEnabled, refresh]);
const run = async (action: () => Promise<UpdateStatus>, confirmText?: string) => {
if (busy) return;
if (confirmText && !window.confirm(confirmText)) return;
setBusy(true);
setError('');
try {
const next = await action();
apply(next);
if (next.message) onCompleted?.(next.message);
} catch (caught) {
setError(caught instanceof Error ? caught.message : '更新操作未完成,请稍后重试。');
try {
apply(await source.status());
} catch {
/* keep the reported error on screen */
}
} finally {
setBusy(false);
}
};
const phase = status?.phase ?? 'idle';
const release = status?.release ?? null;
const busyPhase = BUSY_UPDATE_PHASES.has(phase);
const blocked = busy || busyPhase;
const canDownload = phase === 'update_available' || phase === 'failed' || phase === 'rolled_back';
const canInstall = phase === 'ready';
const canRestart = Boolean(status?.restartSupported) && phase !== 'restarting';
return (
<Card pattern="default" className="settings-card maintenance-card update-card">
<section aria-label="在线更新">
<h2>
<Icon name="version" />
线
</h2>
<p className="maintenance-hint">
</p>
{loading ? (
<p role="status" aria-label="在线更新加载状态">
</p>
) : null}
{failed ? (
<div role="alert">
<p></p>
<Button
htmlType="button"
onClick={() => {
setAttempt((value) => value + 1);
}}
>
</Button>
</div>
) : null}
{!loading && !failed && status ? (
<>
<div className="update-status-row">
<Tag
size="small"
variant="soft"
color={
busyPhase
? 'app-orange'
: phase === 'failed' || phase === 'rolled_back'
? 'app-red'
: 'app-green'
}
>
{PHASE_LABELS[phase]}
</Tag>
<span className="update-phase-message">{status.message || '等待检查更新。'}</span>
</div>
{busyPhase ? (
<div className="update-progress">
<Progress
percent={status.progressPercent}
showInfo={false}
duration={0}
aria-label="更新进度"
/>
<strong>{status.progressPercent}%</strong>
</div>
) : null}
<dl className="maintenance-summary update-summary">
<div>
<dt></dt>
<dd>{status.deploymentMode === 'git' ? 'Git 仓库' : '手动部署'}</dd>
</div>
<div>
<dt></dt>
<dd>
{release
? `${release.currentVersion || '--'} (${shortHash(release.currentCommit)})`
: '--'}
</dd>
</div>
<div>
<dt></dt>
<dd>
{release && release.status !== 'unavailable'
? `${release.latestVersion || '--'} (${shortHash(release.latestCommit)})`
: '--'}
</dd>
</div>
<div>
<dt></dt>
<dd>{release ? `${release.remote} / ${release.branch}` : '--'}</dd>
</div>
<div>
<dt></dt>
<dd>{release ? `${release.ahead}` : '--'}</dd>
</div>
<div>
<dt></dt>
<dd>
{release && release.changedFiles > 0
? `${release.changedFiles} 文件 / ${release.changedLines}`
: '--'}
</dd>
</div>
<div>
<dt></dt>
<dd>{formatTime(status.checkedAt)}</dd>
</div>
<div>
<dt></dt>
<dd>{status.installSupported ? '支持' : '不支持'}</dd>
</div>
</dl>
{release?.summary ? <p className="update-release-summary">{release.summary}</p> : null}
{status.error ? (
<p role="alert" className="maintenance-error">
{status.error}
</p>
) : null}
<div className="update-actions">
<Button
htmlType="button"
loading={busy && phase === 'checking'}
disabled={blocked}
onClick={() => void run(() => source.check())}
>
</Button>
<Button
htmlType="button"
type="primary"
loading={busy && phase === 'downloading'}
disabled={blocked || !canDownload}
onClick={() => void run(() => source.download())}
>
</Button>
<Button
htmlType="button"
danger
loading={busy && (phase === 'installing' || phase === 'install_queued')}
disabled={blocked || !canInstall || !status.installSupported}
onClick={() =>
void run(
() => source.install(),
'确认安装已验证的更新?控制台将更新依赖、重建前端并重启服务。',
)
}
>
</Button>
<Button
htmlType="button"
loading={busy && phase === 'restarting'}
disabled={blocked || !canRestart}
onClick={() =>
void run(
() => source.restart(),
'确认立即重启控制台服务?正在执行的设备任务会被中断。',
)
}
>
<Icon name="restart" />
</Button>
</div>
{!status.installSupported ? (
<p className="maintenance-hint">
线 git pull
</p>
) : null}
{error ? (
<p role="alert" className="maintenance-error">
{error}
</p>
) : null}
</>
) : null}
</section>
</Card>
);
}