feat: 自动更新

This commit is contained in:
npc0-hue
2026-07-15 19:43:06 +08:00
parent f64eb0831f
commit f3b14b7945
54 changed files with 3207 additions and 589 deletions
@@ -0,0 +1,495 @@
import {
AlertTriangle,
CheckCircle2,
Circle,
Clock3,
Download,
GitBranch,
Hammer,
HardDriveDownload,
Loader2,
PackageCheck,
ShieldCheck,
Terminal,
Wrench,
X
} from "lucide-react";
import { useCallback, useState } from "react";
import { cx } from "../utils/classes";
export type RuntimeTaskStatus = "running" | "succeeded" | "failed";
export type RuntimeTaskStageStatus = "pending" | "running" | "completed" | "failed";
export interface RuntimeTaskStage {
key: string;
label: string;
description: string;
}
export interface RuntimeTaskDialogState {
open: boolean;
title: string;
description: string;
status: RuntimeTaskStatus;
percent: number;
currentStageKey: string;
stages: RuntimeTaskStage[];
stageStatus: Record<string, RuntimeTaskStageStatus>;
logs: string[];
summary?: string;
error?: string;
}
export interface RuntimeTaskDialogAction {
label: string;
onClick: () => void;
disabled?: boolean;
kind?: "primary" | "default" | "danger";
}
export const runtimeBuildStages: RuntimeTaskStage[] = [
{ key: "git_sync", label: "拉取代码", description: "同步平台批准的 run 或客户端源码版本。" },
{ key: "env_check", label: "安装环境", description: "检查 Go、系统依赖和隔离构建目录。" },
{ key: "deps_download", label: "下载依赖", description: "解析模块依赖并准备构建缓存。" },
{ key: "build_compile", label: "编译构建", description: "编译目标平台二进制。" },
{ key: "package_finalize", label: "打包成功", description: "注入配置、校验 checksum、生成 artifact。" }
];
export const runtimeDownloadStages: RuntimeTaskStage[] = [
{ key: "scope_check", label: "权限校验", description: "确认当前服务器范围和 artifact 授权。" },
{ key: "artifact_lookup", label: "定位产物", description: "读取最新可下载 run 包引用。" },
{ key: "download_ref", label: "生成下载", description: "创建限时下载引用和分块参数。" }
];
export const runtimeUpdateStages: RuntimeTaskStage[] = [
{ key: "artifact_lookup", label: "定位产物", description: "读取最近生成或下载的 run artifact。" },
{ key: "checksum_verify", label: "校验签名", description: "确认 checksum 可用于 run 自更新。" },
{ key: "dispatch_job", label: "推送更新", description: "向在线 run 节点派发自更新任务。" },
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
];
export const runtimeDependencyStages: RuntimeTaskStage[] = [
{ key: "profile_read", label: "读取声明", description: "读取插件声明的 probe 和 install plan。" },
{ key: "env_probe", label: "环境检查", description: "让 run 节点评估当前运行环境。" },
{ key: "install_prepare", label: "安装环境", description: "准备安全、可审计的依赖安装任务。" },
{ key: "job_track", label: "等待确认", description: "记录 job id 并刷新后台任务状态。" }
];
export const runtimeLogStages: RuntimeTaskStage[] = [
{ key: "source_read", label: "读取日志源", description: "读取插件声明的日志源和 checkpoint。" },
{ key: "cursor_prepare", label: "准备游标", description: "创建平台日志查询或历史回填游标。" },
{ key: "job_track", label: "等待确认", description: "打开实时日志或记录回填任务。" }
];
export function runtimeInitialStageStatus(stages: RuntimeTaskStage[]): Record<string, RuntimeTaskStageStatus> {
return Object.fromEntries(stages.map((stage) => [stage.key, "pending" as RuntimeTaskStageStatus]));
}
interface RuntimeTaskRunOptions<T> {
title: string;
description: string;
stages: RuntimeTaskStage[];
execute: () => Promise<T>;
executeStageIndex?: number;
}
export interface RuntimeTrackedJob {
id: string;
state: "queued" | "accepted" | "running" | "succeeded" | "failed" | "cancelled";
progress: { percent: number; message?: string };
}
interface RuntimeTrackedTaskOptions<T> {
title: string;
description: string;
stages: RuntimeTaskStage[];
start: () => Promise<{ value: T; jobId: string }>;
poll: (jobId: string) => Promise<RuntimeTrackedJob>;
pollIntervalMs?: number;
}
export function useRuntimeTaskController() {
const [task, setTask] = useState<RuntimeTaskDialogState | null>(null);
const closeTask = useCallback(() => {
setTask((current) => (current ? { ...current, open: false } : current));
}, []);
const succeedTask = useCallback((summary: string) => {
setTask((current) =>
current
? {
...current,
status: "succeeded",
percent: 100,
summary,
error: undefined,
stageStatus: Object.fromEntries(current.stages.map((stage) => [stage.key, "completed" as RuntimeTaskStageStatus])),
logs: appendRuntimeLog(current.logs, summary)
}
: current
);
}, []);
const failTask = useCallback((message: string) => {
setTask((current) =>
current
? {
...current,
status: "failed",
error: message,
stageStatus: { ...current.stageStatus, [current.currentStageKey]: "failed" },
logs: appendRuntimeLog(current.logs, message)
}
: current
);
}, []);
const runTask = useCallback(async <T,>({ title, description, stages, execute, executeStageIndex }: RuntimeTaskRunOptions<T>): Promise<T> => {
const currentStageKey = stages[0]?.key ?? "start";
const executeIndex = Math.max(0, Math.min(stages.length - 1, executeStageIndex ?? Math.floor(stages.length / 2)));
setTask({
open: true,
title,
description,
status: "running",
percent: 2,
currentStageKey,
stages,
stageStatus: runtimeInitialStageStatus(stages),
logs: [`${title} 已启动`]
});
let value: T | undefined;
let executed = false;
for (let index = 0; index < stages.length; index += 1) {
const stage = stages[index];
const startPercent = stageProgress(index, stages.length, false);
setTask((current) =>
current
? {
...current,
currentStageKey: stage.key,
percent: Math.max(current.percent, startPercent),
stageStatus: { ...current.stageStatus, [stage.key]: "running" },
logs: appendRuntimeLog(current.logs, `${stage.label}`)
}
: current
);
try {
if (index === executeIndex) {
executed = true;
value = await execute();
} else {
await wait(index === 0 ? 240 : 360);
}
} catch (error) {
const message = error instanceof Error ? error.message : `${title}失败`;
setTask((current) =>
current
? {
...current,
status: "failed",
currentStageKey: stage.key,
stageStatus: { ...current.stageStatus, [stage.key]: "failed" },
error: message,
logs: appendRuntimeLog(current.logs, message)
}
: current
);
throw error;
}
const endPercent = stageProgress(index, stages.length, true);
setTask((current) =>
current
? {
...current,
percent: Math.max(current.percent, endPercent),
stageStatus: { ...current.stageStatus, [stage.key]: "completed" },
logs: appendRuntimeLog(current.logs, `${stage.label}完成`)
}
: current
);
}
if (!executed) {
value = await execute();
}
return value as T;
}, []);
const runTrackedTask = useCallback(
async <T,>({ title, description, stages, start, poll, pollIntervalMs = 800 }: RuntimeTrackedTaskOptions<T>): Promise<T> => {
const firstStage = stages[0]?.key ?? "start";
setTask({
open: true,
title,
description,
status: "running",
percent: 1,
currentStageKey: firstStage,
stages,
stageStatus: { ...runtimeInitialStageStatus(stages), [firstStage]: "running" },
logs: [`${title} 正在创建后台构建任务`]
});
const started = await start();
setTask((current) => (current ? { ...current, logs: appendRuntimeLog(current.logs, `后台 job ${started.jobId} 已排队`) } : current));
while (true) {
const job = await poll(started.jobId);
const message = job.progress.message?.trim() || job.state;
const stageIndex = trackedStageIndex(stages, message, job.progress.percent);
const stage = stages[stageIndex] ?? stages[0];
const stageStatus = Object.fromEntries(
stages.map((item, index) => [item.key, index < stageIndex || job.state === "succeeded" ? "completed" : index === stageIndex ? "running" : "pending"])
) as Record<string, RuntimeTaskStageStatus>;
setTask((current) =>
current
? {
...current,
percent: Math.max(current.percent, Math.min(99, job.progress.percent)),
currentStageKey: stage?.key ?? current.currentStageKey,
stageStatus,
logs: current.logs[current.logs.length - 1] === message ? current.logs : appendRuntimeLog(current.logs, message)
}
: current
);
if (job.state === "succeeded") {
setTask((current) =>
current
? {
...current,
status: "succeeded",
percent: 100,
stageStatus: Object.fromEntries(stages.map((item) => [item.key, "completed" as RuntimeTaskStageStatus])),
logs: appendRuntimeLog(current.logs, "构建产物已由 run worker 上传")
}
: current
);
return started.value;
}
if (job.state === "failed" || job.state === "cancelled") {
const error = message || (job.state === "cancelled" ? "构建已取消" : "构建失败");
setTask((current) =>
current
? {
...current,
status: "failed",
error,
stageStatus: { ...stageStatus, [stage?.key ?? firstStage]: "failed" },
logs: appendRuntimeLog(current.logs, error)
}
: current
);
throw new Error(error);
}
await wait(pollIntervalMs);
}
},
[]
);
return { task, runTask, runTrackedTask, succeedTask, failTask, closeTask };
}
interface RuntimeTaskProgressDialogProps {
task: RuntimeTaskDialogState | null;
onClose: () => void;
actions?: RuntimeTaskDialogAction[];
}
export function RuntimeTaskProgressDialog({ task, onClose, actions = [] }: RuntimeTaskProgressDialogProps) {
if (!task?.open) {
return null;
}
const activeStage = task.stages.find((stage) => stage.key === task.currentStageKey) ?? task.stages[0];
const statusLabel = runtimeTaskStatusLabel(task.status);
const closeLabel = task.status === "running" ? "后台运行" : "关闭";
return (
<div className="confirm-backdrop runtime-task-backdrop" role="presentation" onClick={task.status === "running" ? undefined : onClose}>
<div className="drawer-panel management-dialog-panel runtime-task-panel" role="dialog" aria-modal="true" aria-label={task.title} onClick={(event) => event.stopPropagation()}>
<div className="panel-header runtime-task-header">
<span>
<strong>{task.title}</strong>
<small>{task.description}</small>
</span>
<span className={cx("status-pill", task.status === "succeeded" && "status-active", task.status === "failed" && "status-error", task.status === "running" && "status-disabled")}>
{task.status === "running" && <Loader2 size={13} className="runtime-task-spin" />}
{task.status === "succeeded" && <CheckCircle2 size={13} />}
{task.status === "failed" && <AlertTriangle size={13} />}
{statusLabel}
</span>
</div>
<div className="runtime-task-meter" aria-label={`${task.title} 进度 ${Math.round(task.percent)}%`}>
<div className="runtime-task-meter-row">
<span>{activeStage?.label ?? statusLabel}</span>
<strong>{Math.round(task.percent)}%</strong>
</div>
<span className="runtime-task-meter-track">
<span className={cx("runtime-task-meter-fill", task.status === "failed" && "runtime-task-meter-failed")} style={{ width: `${Math.max(0, Math.min(100, task.percent))}%` }} />
</span>
</div>
{activeStage && task.status === "running" && (
<div className="runtime-task-current">
<span className="runtime-task-current-icon">{stageIcon(activeStage.key, "running")}</span>
<span>
<strong>{activeStage.label}</strong>
<small>{activeStage.description}</small>
</span>
</div>
)}
<ol className="runtime-task-stages" aria-label="运行任务阶段">
{task.stages.map((stage) => {
const status = task.stageStatus[stage.key] ?? "pending";
return (
<li key={stage.key} className={cx("runtime-task-stage", `runtime-task-stage-${status}`)}>
<span className="runtime-task-stage-icon">{stageIcon(stage.key, status)}</span>
<span className="runtime-task-stage-copy">
<strong>{stage.label}</strong>
<small>{stage.description}</small>
</span>
<span className="runtime-task-stage-status">{runtimeStageStatusLabel(status)}</span>
</li>
);
})}
</ol>
<div className="runtime-task-log" aria-label="运行任务日志">
{task.logs.map((line, index) => (
<span key={`${index}-${line}`}>
<Terminal size={12} />
{line}
</span>
))}
</div>
{(task.summary || task.error) && (
<div className={cx("inline-result-strip", task.error && "runtime-task-error")}>
{task.error ? <AlertTriangle size={14} /> : <CheckCircle2 size={14} />}
<span>{task.error ?? task.summary}</span>
</div>
)}
<div className="confirm-actions runtime-task-actions">
<button type="button" onClick={onClose}>
{task.status === "running" ? <Clock3 size={14} /> : <X size={14} />}
{closeLabel}
</button>
{actions.map((action) => (
<button
key={action.label}
type="button"
className={cx(action.kind === "primary" && "confirm-primary", action.kind === "danger" && "confirm-danger")}
disabled={action.disabled || task.status === "running"}
onClick={action.onClick}
>
{action.label.includes("下载") ? <Download size={14} /> : <PackageCheck size={14} />}
{action.label}
</button>
))}
</div>
</div>
</div>
);
}
function stageIcon(stageKey: string, status: RuntimeTaskStageStatus) {
if (status === "completed") {
return <CheckCircle2 size={15} />;
}
if (status === "failed") {
return <AlertTriangle size={15} />;
}
if (status === "running") {
return <Loader2 size={15} className="runtime-task-spin" />;
}
if (stageKey.includes("git")) {
return <GitBranch size={15} />;
}
if (stageKey.includes("env") || stageKey.includes("install")) {
return <Wrench size={15} />;
}
if (stageKey.includes("deps") || stageKey.includes("download")) {
return <HardDriveDownload size={15} />;
}
if (stageKey.includes("compile") || stageKey.includes("build")) {
return <Hammer size={15} />;
}
if (stageKey.includes("scope") || stageKey.includes("checksum")) {
return <ShieldCheck size={15} />;
}
if (stageKey.includes("package") || stageKey.includes("artifact")) {
return <PackageCheck size={15} />;
}
return <Circle size={15} />;
}
function runtimeTaskStatusLabel(status: RuntimeTaskStatus): string {
switch (status) {
case "running":
return "构建中";
case "succeeded":
return "构建成功";
case "failed":
return "失败";
}
}
function runtimeStageStatusLabel(status: RuntimeTaskStageStatus): string {
switch (status) {
case "completed":
return "已完成";
case "running":
return "进行中";
case "failed":
return "失败";
case "pending":
return "等待中";
}
}
function stageProgress(index: number, total: number, completed: boolean): number {
if (total <= 0) {
return completed ? 100 : 0;
}
const base = (index / total) * 90 + 4;
const next = ((index + 1) / total) * 90 + 4;
return completed ? next : base;
}
function trackedStageIndex(stages: RuntimeTaskStage[], message: string, percent: number): number {
const stageKey = message.split(":", 1)[0];
const explicit = stages.findIndex((stage) => stage.key === stageKey);
if (explicit >= 0) {
return explicit;
}
const thresholds = [0, 25, 40, 60, 80];
let index = 0;
for (let candidate = 0; candidate < Math.min(stages.length, thresholds.length); candidate += 1) {
if (percent >= thresholds[candidate]) {
index = candidate;
}
}
return Math.min(index, Math.max(0, stages.length - 1));
}
function appendRuntimeLog(logs: string[], line: string): string[] {
return [...logs, line].slice(-8);
}
function wait(ms: number): Promise<void> {
return new Promise((resolve) => {
window.setTimeout(resolve, ms);
});
}