Files
browser/platform_web/components/RuntimeTaskProgress.tsx
T
npc0-hue 292b380f3c feat: ship single-file run distribution and list key reset
Compile-time run auth replaces zip sidecars, lengthens run keys, revokes
active sessions on reset, and exposes run-key reset in the server list.
2026-07-24 14:14:26 +08:00

550 lines
21 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;
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, "构建产物已由 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 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);
});
}