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:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user