496 lines
18 KiB
TypeScript
496 lines
18 KiB
TypeScript
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);
|
|
});
|
|
}
|