Files
browser/platform_web/components/RuntimeTaskProgress.tsx
T

526 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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" | "retrying" | "succeeded" | "failed" | "cancelled";
progress: { percent: number; message?: string };
attempt?: number;
retryPolicy?: { maxAttempts: number };
nextAttemptAt?: string;
cancelReason?: string;
reconcileOutcome?: string;
}
export interface RuntimeTrackedJobProjection {
status: RuntimeTaskStatus;
percent: number;
currentStageKey: string;
stageStatus: Record<string, RuntimeTaskStageStatus>;
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 projection = projectRuntimeTrackedJob(stages, job);
setTask((current) =>
current
? {
...current,
percent: job.state === "succeeded" ? 100 : Math.max(current.percent, projection.percent),
currentStageKey: projection.currentStageKey || current.currentStageKey,
stageStatus: projection.stageStatus,
logs: current.logs[current.logs.length - 1] === projection.message ? current.logs : appendRuntimeLog(current.logs, projection.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 = projection.message || (job.state === "cancelled" ? "构建已取消" : "构建失败");
setTask((current) =>
current
? {
...current,
status: "failed",
error,
currentStageKey: projection.currentStageKey || firstStage,
stageStatus: projection.stageStatus,
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));
}
export function projectRuntimeTrackedJob(stages: RuntimeTaskStage[], job: RuntimeTrackedJob): RuntimeTrackedJobProjection {
const retryLabel = job.state === "retrying" ? `等待第 ${Math.min((job.attempt ?? 0) + 1, job.retryPolicy?.maxAttempts ?? (job.attempt ?? 0) + 1)} 次尝试${job.nextAttemptAt ? `${new Date(job.nextAttemptAt).toLocaleString()}` : ""}` : "";
const message = job.cancelReason?.trim() || job.progress.message?.trim() || retryLabel || job.reconcileOutcome?.trim() || job.state;
const stageIndex = trackedStageIndex(stages, message, job.progress.percent);
const currentStageKey = stages[stageIndex]?.key ?? stages[0]?.key ?? "start";
const terminalFailure = job.state === "failed" || job.state === "cancelled";
const stageStatus = Object.fromEntries(
stages.map((item, index) => [
item.key,
job.state === "succeeded" || index < stageIndex ? "completed" : index === stageIndex ? (terminalFailure ? "failed" : "running") : "pending"
])
) as Record<string, RuntimeTaskStageStatus>;
return {
status: job.state === "succeeded" ? "succeeded" : terminalFailure ? "failed" : "running",
percent: job.state === "succeeded" ? 100 : Math.min(99, Math.max(0, job.progress.percent)),
currentStageKey,
stageStatus,
message
};
}
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);
});
}