feat: 自动更新
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user