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> = { 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(); 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(undefined); const phaseRef = useRef('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, 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 (

在线更新

检查、下载、安装并重启控制台,全过程分步可见,失败自动回滚代码。

{loading ? (

正在读取更新状态…

) : null} {failed ? (

无法加载更新状态。

) : null} {!loading && !failed && status ? ( <>
{PHASE_LABELS[phase]} {status.message || '等待检查更新。'}
{busyPhase ? (
{status.progressPercent}%
) : null}
部署方式
{status.deploymentMode === 'git' ? 'Git 仓库' : '手动部署'}
当前版本
{release ? `${release.currentVersion || '--'} (${shortHash(release.currentCommit)})` : '--'}
最新版本
{release && release.status !== 'unavailable' ? `${release.latestVersion || '--'} (${shortHash(release.latestCommit)})` : '--'}
更新来源
{release ? `${release.remote} / ${release.branch}` : '--'}
落后提交
{release ? `${release.ahead} 个` : '--'}
变更规模
{release && release.changedFiles > 0 ? `${release.changedFiles} 文件 / ${release.changedLines} 行` : '--'}
最近检查
{formatTime(status.checkedAt)}
可安装
{status.installSupported ? '支持' : '不支持'}
{release?.summary ?

{release.summary}

: null} {status.error ? (

{status.error}

) : null}
{!status.installSupported ? (

当前为手动部署,无法在线安装更新,请在服务器上执行 git pull 后重启服务。

) : null} {error ? (

{error}

) : null} ) : null}
); }