550 lines
21 KiB
TypeScript
550 lines
21 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;
|
||
title?: string;
|
||
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 runtimeRunBuildStages: RuntimeTaskStage[] = [
|
||
{ key: "git_sync", label: "拉取 run 更新", description: "同步平台批准的 run 源码或发布版本。" },
|
||
{ key: "env_check", label: "检测构建环境", description: "检查 Go 工具链、目标平台和隔离构建目录。" },
|
||
{ key: "build_compile", label: "构建中", description: "编译所选平台和架构的 run 二进制。" },
|
||
{ 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 runtimeKeyResetStages: RuntimeTaskStage[] = [
|
||
{ key: "scope_check", label: "权限校验", description: "确认当前账号可以重置该服务器的 run 密钥。" },
|
||
{ key: "confirm_reset", label: "确认重置", description: "记录本次密钥轮换并撤销旧 run 会话。" },
|
||
{ key: "key_rotate", label: "轮换密钥", description: "生成新密钥代际并撤销旧发行物。" },
|
||
{ key: "regenerate_hint", label: "等待重发", description: "提示重新生成并部署新的 run 可执行文件。" }
|
||
];
|
||
|
||
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, "构建产物已由 platform builder 登记")
|
||
}
|
||
: 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 activeStageIndex = Math.max(0, task.stages.findIndex((stage) => stage.key === activeStage?.key));
|
||
const activeStepLabel = task.stages.length > 0 ? `第 ${activeStageIndex + 1}/${task.stages.length} 步` : "无阶段";
|
||
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-progress-summary" aria-label="进度展示">
|
||
<span>进度展示</span>
|
||
<strong>{activeStage ? `${activeStepLabel} · ${activeStage.label}` : statusLabel}</strong>
|
||
<small>{task.status === "running" ? "平台正在执行当前阶段,完成后会自动推进下一步。" : task.status === "succeeded" ? "所有阶段已完成,可下载产物或继续后续操作。" : "任务已停止,请查看失败原因和运行日志。"}</small>
|
||
</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"}
|
||
title={action.title ?? action.label}
|
||
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);
|
||
});
|
||
}
|