- 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.
338 lines
11 KiB
TypeScript
338 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|