feat: 增加独立 CaseRun 原生执行台

This commit is contained in:
root
2026-07-18 05:34:08 +02:00
parent 5735d6ef78
commit 115f91d786
15 changed files with 524 additions and 43 deletions
+12
View File
@@ -12,6 +12,18 @@ if (argv[0] === "tasktree") {
console.log(JSON.stringify({ ok: false, operation: "tasktree", error: { code: (error as any)?.code ?? "tasktree_cli_error", message: (error as any)?.message ?? String(error) } }, null, 2));
process.exitCode = 1;
}
} else if (argv[0] === "caserun") {
const { caserunNativeServiceCommand } = await import("../../src/caserun-native-service.ts");
try {
const service = argv[1] === "service" ? argv[2] : "";
const action = argv[1] === "service" ? argv[3] ?? "status" : "status";
const result = await caserunNativeServiceCommand({ service, action, cwd: process.cwd(), env: process.env });
console.log(JSON.stringify(result, null, 2));
process.exitCode = result?.ok === false ? 1 : 0;
} catch (error: any) {
console.log(JSON.stringify({ ok: false, operation: "caserun", error: { code: error?.code ?? "caserun_cli_error", message: error?.message ?? String(error) } }, null, 2));
process.exitCode = 1;
}
} else if (argv[0] === "workbench") {
const { runWorkbenchCli } = await import("../../src/workbench-cli.ts");
try {
+94
View File
@@ -0,0 +1,94 @@
import { spawn } from "node:child_process";
import { closeSync, openSync } from "node:fs";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import path from "node:path";
type ServiceName = "api" | "web";
export async function caserunNativeServiceCommand(input: { service: string; action: string; cwd: string; env: Record<string, string | undefined> }) {
const service = validService(input.service);
const stateDir = path.resolve(input.cwd, input.env.CASERUN_NATIVE_SERVICE_STATE_DIR ?? ".state/caserun-native/services");
const stateFile = path.join(stateDir, `${service}.json`);
const logFile = path.join(stateDir, `${service}.log`);
if (input.action === "status") return status(service, stateFile, logFile);
if (input.action === "logs") return logs(service, stateFile, logFile);
if (input.action === "stop") return stop(service, stateFile, logFile);
if (input.action === "restart") {
await stop(service, stateFile, logFile);
return start(service, stateDir, stateFile, logFile, input.cwd, input.env);
}
if (input.action === "start") {
const current = await readState(stateFile);
if (current?.pid && alive(current.pid)) await stop(service, stateFile, logFile);
return start(service, stateDir, stateFile, logFile, input.cwd, input.env);
}
throw codedError("unsupported_service_action", `unsupported CaseRun service action: ${input.action}`);
}
async function start(service: ServiceName, stateDir: string, stateFile: string, logFile: string, cwd: string, env: Record<string, string | undefined>) {
await mkdir(stateDir, { recursive: true });
const endpoints = serviceEndpoints(service, env);
const command = serviceCommand(service);
const logFd = openSync(logFile, "w");
const serviceCwd = path.join(cwd, "web/hwlab-cloud-web");
const child = spawn(command[0], command.slice(1), { cwd: serviceCwd, env: serviceEnv(service, env), detached: true, stdio: ["ignore", logFd, logFd] });
closeSync(logFd);
child.unref();
const state = { service, pid: child.pid, status: "starting", command, cwd, logFile, endpoints, startedAt: new Date().toISOString() };
await writeFile(stateFile, `${JSON.stringify(state, null, 2)}\n`, "utf8");
return { ok: true, operation: `caserun.service.${service}.start`, ...state, nextCommand: `hwlab-cli caserun service ${service} status` };
}
async function stop(service: ServiceName, stateFile: string, logFile: string) {
const state = await readState(stateFile);
if (!state?.pid || !alive(state.pid)) { await rm(stateFile, { force: true }); return { ok: true, operation: `caserun.service.${service}.stop`, service, status: "stopped", alreadyStopped: true, logFile }; }
try { process.kill(-state.pid, "SIGTERM"); } catch (error: any) { if (error?.code !== "ESRCH") throw error; }
if (!await waitForExit(state.pid, 3_000)) {
try { process.kill(-state.pid, "SIGKILL"); } catch (error: any) { if (error?.code !== "ESRCH") throw error; }
await waitForExit(state.pid, 1_000);
}
await rm(stateFile, { force: true });
return { ok: true, operation: `caserun.service.${service}.stop`, service, status: "stopped", pid: state.pid, logFile };
}
async function status(service: ServiceName, stateFile: string, logFile: string) {
const state = await readState(stateFile);
const running = Boolean(state?.pid && alive(state.pid));
return { ok: running, operation: `caserun.service.${service}.status`, service, status: running ? "running" : "stopped", pid: running ? state.pid : null, endpoints: state?.endpoints ?? null, stateFile, logFile, nextCommand: `hwlab-cli caserun service ${service} logs` };
}
async function logs(service: ServiceName, stateFile: string, logFile: string) {
const state = await status(service, stateFile, logFile);
const lines = (await readFile(logFile, "utf8").catch(() => "")).split("\n").filter(Boolean).slice(-80);
return { ...state, operation: `caserun.service.${service}.logs`, lines, truncated: lines.length === 80 };
}
function serviceCommand(service: ServiceName) {
return service === "api"
? ["bun", "scripts/caserun-native-supervisor.ts"]
: ["bun", "run", "dev:native-caserun"];
}
function serviceEnv(service: ServiceName, env: Record<string, string | undefined>) {
return { ...process.env, ...env, HWLAB_CASERUN_NATIVE_TEST: "1", ...(service === "web"
? {
WORKBENCH_WEB_HOST: requiredEnv(env, "CASERUN_WEB_BIND_HOST"),
WORKBENCH_WEB_PORT: requiredEnv(env, "CASERUN_WEB_PORT"),
HWLAB_CASERUN_NATIVE_URL: `http://${requiredEnv(env, "CASERUN_PROBE_HOST")}:${requiredEnv(env, "CASERUN_API_PORT")}`
}
: { HWLAB_CASERUN_NATIVE_HOST: requiredEnv(env, "CASERUN_API_BIND_HOST"), HWLAB_CASERUN_NATIVE_PORT: requiredEnv(env, "CASERUN_API_PORT") }) };
}
function serviceEndpoints(service: ServiceName, env: Record<string, string | undefined>) {
const port = service === "api" ? requiredEnv(env, "CASERUN_API_PORT") : requiredEnv(env, "CASERUN_WEB_PORT");
const host = requiredEnv(env, "CASERUN_PUBLIC_HOST");
return { host, port: Number(port), url: `http://${host}:${Number(port)}${service === "api" ? "/health" : "/caserun"}` };
}
function validService(value: string): ServiceName { if (value === "api" || value === "web") return value; throw codedError("invalid_service", "CaseRun service must be api or web"); }
async function readState(file: string) { return JSON.parse(await readFile(file, "utf8").catch(() => "null")); }
function alive(pid: number) { try { process.kill(pid, 0); return true; } catch { return false; } }
async function waitForExit(pid: number, timeoutMs: number) {
const deadline = Date.now() + timeoutMs;
while (alive(pid) && Date.now() < deadline) await new Promise((resolve) => setTimeout(resolve, 50));
return !alive(pid);
}
function requiredEnv(env: Record<string, string | undefined>, key: string) { const value = String(env[key] ?? "").trim(); if (!value) throw codedError("native_exposure_config_required", `${key} is required from YAML-first native CaseRun config`); return value; }
function codedError(code: string, message: string) { return Object.assign(new Error(message), { code }); }
@@ -21,12 +21,12 @@ const CONTRACT_VERSION = "caserun.v1";
const BASE_TIME = Date.parse("2026-07-17T00:00:00.000Z");
const scenarios: Record<string, { title: string; steps: NativeStep[] }> = {
"native-queued": { title: "Native queued", steps: [{ status: "queued", stage: "queued", terminal: false }] },
"native-running": { title: "Native running", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }] },
"native-completed": { title: "Native completed", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "completed", stage: "completed", terminal: true }] },
"native-failed": { title: "Native failed", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "building", terminal: false }, { status: "failed", stage: "failed", terminal: true, blocker: { code: "native-build-failed", summary: "Deterministic native build failure", layer: "build" } }] },
"native-blocked": { title: "Native blocked", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "blocked", stage: "preparing", terminal: true, blocker: { code: "native-capability-blocked", summary: "Deterministic native capability blocker", layer: "hwpod" } }] },
"native-canceled": { title: "Native canceled", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "canceled", stage: "canceled", terminal: true }] }
"native-queued": { title: "原生排队中", steps: [{ status: "queued", stage: "queued", terminal: false }] },
"native-running": { title: "原生运行中", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }] },
"native-completed": { title: "原生已完成", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "completed", stage: "completed", terminal: true }] },
"native-failed": { title: "原生失败", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "building", terminal: false }, { status: "failed", stage: "failed", terminal: true, blocker: { code: "native-build-failed", summary: "原生构建按确定性场景失败", layer: "build" } }] },
"native-blocked": { title: "原生受阻", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "blocked", stage: "preparing", terminal: true, blocker: { code: "native-capability-blocked", summary: "原生能力按确定性场景受阻", layer: "hwpod" } }] },
"native-canceled": { title: "原生已取消", steps: [{ status: "queued", stage: "queued", terminal: false }, { status: "running", stage: "agent-running", terminal: false }, { status: "canceled", stage: "canceled", terminal: true }] }
};
let runSequence = 0;
@@ -75,7 +75,7 @@ function childPids(supervisorPid: number): number[] {
rmSync(displacedPath, { force: true });
const supervisor = Bun.spawn([process.execPath, "scripts/caserun-native-supervisor.ts"], {
cwd: root,
env: { ...process.env, HWLAB_CASERUN_NATIVE_PORT: String(port) },
env: { ...process.env, HWLAB_CASERUN_NATIVE_HOST: "127.0.0.1", HWLAB_CASERUN_NATIVE_PORT: String(port) },
stdin: "ignore",
stdout: "pipe",
stderr: "pipe"
@@ -1,7 +1,7 @@
import { listNativeCases, readNativeAggregate, readNativeEvents, readNativeRun, startNativeRun } from "./caserun-native-fixtures";
const port = Number(process.env.HWLAB_CASERUN_NATIVE_PORT || 4316);
const hostname = process.env.HWLAB_CASERUN_NATIVE_HOST || "127.0.0.1";
const port = Number(requiredEnv("HWLAB_CASERUN_NATIVE_PORT"));
const hostname = requiredEnv("HWLAB_CASERUN_NATIVE_HOST");
function json(data: unknown, status = 200): Response {
return Response.json(data, { status, headers: { "cache-control": "no-store" } });
@@ -65,3 +65,9 @@ const server = Bun.serve({
});
console.log(JSON.stringify({ event: "caserun-native-listening", mode: "native-test", hostname: server.hostname, port: server.port }));
function requiredEnv(name: string): string {
const value = String(process.env[name] ?? "").trim();
if (!value) throw new Error(`${name} is required from the CLI-managed YAML profile`);
return value;
}
@@ -7,7 +7,7 @@ const watchedFiles = [
resolve(root, "scripts/caserun-native-fixtures.ts")
];
let child: ReturnType<typeof Bun.spawn> | null = null;
let child: ReturnType<typeof spawnNativeServer> | null = null;
let signature = sourceSignature();
let stopping = false;
let restarting = false;
@@ -25,7 +25,7 @@ function sourceSignature(): string | null {
}
function startChild(reason: string): void {
const spawned = Bun.spawn([process.execPath, "scripts/caserun-native-server.ts"], { cwd: root, stdin: "ignore", stdout: "inherit", stderr: "inherit" });
const spawned = spawnNativeServer();
child = spawned;
console.log(JSON.stringify({ event: "caserun-native-reload", mode: "native-test", reason, pid: spawned.pid }));
void spawned.exited.then((exitCode) => {
@@ -33,6 +33,10 @@ function startChild(reason: string): void {
});
}
function spawnNativeServer() {
return Bun.spawn([process.execPath, "scripts/caserun-native-server.ts"], { cwd: root, stdin: "ignore", stdout: "inherit", stderr: "inherit" });
}
async function stopChild(): Promise<void> {
const runningChild = child;
if (!runningChild) return;
@@ -1,22 +1,26 @@
export default async function caserunNativeSmoke({ page, goto, wait, screenshot, recordStep }) {
await goto("/workbench");
await goto("/caserun");
const panel = page.locator("#caserun-panel");
await panel.waitFor({ state: "visible", timeout: 20000 });
await page.locator("#caserun-case-select").selectOption("native-completed");
const nativeCase = page.locator(".caserun-case-row").filter({ hasText: "native-completed" }).first();
await nativeCase.waitFor({ state: "visible", timeout: 20000 });
await nativeCase.click();
await page.locator("#caserun-start").click();
await page.locator("#caserun-refresh").click();
await wait(300);
const state = await page.locator("#caserun-status").evaluate((element) => element.textContent || "");
const mode = (await page.locator("#caserun-mode").textContent())?.trim() || "";
const terminal = (await page.locator("#caserun-terminal").textContent())?.trim() || "";
const runId = (await page.locator("#caserun-run-id").textContent())?.trim() || "";
const events = await page.locator("#caserun-event-text").textContent();
const aggregateStatus = (await page.locator("#caserun-aggregate-status").textContent())?.trim() || "";
const aggregateRunId = (await page.locator("#caserun-aggregate-run-id").textContent())?.trim() || "";
const aggregateSha = (await page.locator("#caserun-aggregate-sha").textContent())?.trim() || "";
const conditions = {
mode: state.includes("native-test"),
terminal: state.includes("true"),
aggregateStatus: aggregateStatus === "completed",
mode: mode.includes("native-test"),
terminal: terminal === "是",
aggregateStatus: aggregateStatus === "已完成",
aggregateRunId: aggregateRunId === runId && runId.startsWith("native-completed-"),
aggregateSha: /^[a-f0-9]{64}$/.test(aggregateSha),
queuedEvent: events?.includes("queued") === true,
@@ -82,6 +82,13 @@ const navSections = [
icon: Bot,
navId: "workbench.code",
},
{
name: "CaseRun",
label: "CaseRun",
path: "/caserun",
icon: CircuitBoard,
navId: "workbench.code",
},
{
name: "WorkbenchDebug",
label: "调试",
@@ -64,7 +64,7 @@ watch(showWorkbenchDiagnostics, (visible) => {
<button class="btn btn-secondary" type="button" @click="auth.logout"><LogOut :size="16" aria-hidden="true" />退出</button>
</div>
</header>
<div class="platform-content">
<div class="platform-content" :class="{ 'is-bounded-layout': route.meta.boundedLayout === true }">
<slot />
</div>
<BaseDialog :open="showWorkbenchDiagnostics && diagnosticsOpen" title="Workbench 诊断" description="Code Agent session、trace、HWPOD 诊断仍走 Cloud Web 同源 API。" wide @close="diagnosticsOpen = false">
+3
View File
@@ -12,6 +12,7 @@ const ProjectsView = () => import("@/views/projects/ProjectsView.vue");
const ProjectMdtodoView = () => import("@/views/projects/MdtodoView.vue");
const TaskTreeView = () => import("@/views/projects/TaskTreeView.vue");
const AgentRunsView = () => import("@/views/agents/AgentRunsView.vue");
const CaseRunView = () => import("@/views/caserun/CaseRunView.vue");
const routes: RouteRecordRaw[] = [
{ path: "/", redirect: "/workbench" },
@@ -21,6 +22,8 @@ const routes: RouteRecordRaw[] = [
{ path: "/workbench/debug", name: "WorkbenchDebug", component: WorkbenchDebugView, meta: { requiresAuth: true, navId: "workbench.debug", title: "Workbench 调试", section: "workbench-debug" } },
{ path: "/workbench/sessions/:sessionId", alias: ["/workspace/sessions/:sessionId"], name: "CodeWorkbenchSession", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } },
{ path: "/workbench", alias: ["/workspace"], name: "CodeWorkbench", component: CodeWorkbenchView, meta: { requiresAuth: true, navId: "workbench.code", title: "Code 工作台", section: "workbench" } },
{ path: "/caserun", name: "CaseRun", component: CaseRunView, meta: { requiresAuth: true, navId: "workbench.code", title: "CaseRun 执行台", section: "caserun", boundedLayout: true } },
{ path: "/caserun/runs/:runId", name: "CaseRunDetail", component: CaseRunView, meta: { requiresAuth: true, navId: "workbench.code", navParent: "CaseRun", title: "CaseRun 详情", section: "caserun", boundedLayout: true } },
{ path: "/agents/runs", name: "AgentRuns", component: AgentRunsView, meta: { requiresAuth: true, navId: "workbench.code", title: "AgentRun 运行观察", section: "agents" } },
{ path: "/agents/runs/:runId", name: "AgentRunDetail", component: AgentRunsView, meta: { requiresAuth: true, navId: "workbench.code", navParent: "AgentRuns", title: "AgentRun 详情", section: "agents" } },
{ path: "/opencode", name: "OpenCode", component: OpenCodeFrameView, meta: { requiresAuth: true, navId: "opencode.root", title: "OpenCode", section: "opencode" } },
+2 -1
View File
@@ -12,6 +12,7 @@ declare module "vue-router" {
navId?: string;
navParent?: string;
title?: string;
section?: "workbench" | "workbench-debug" | "opencode" | "project" | "agents" | "admin" | "user" | "system";
boundedLayout?: boolean;
section?: "workbench" | "workbench-debug" | "caserun" | "opencode" | "project" | "agents" | "admin" | "user" | "system";
}
}
@@ -67,6 +67,15 @@
padding: 12px;
}
.platform-content.is-bounded-layout {
overflow: hidden;
}
.platform-content.is-bounded-layout > * {
height: 100%;
overflow: hidden;
}
.platform-content > * {
min-height: 0;
}
@@ -0,0 +1,317 @@
<!-- SPEC: PJ2026-0103 HarnessRL; implementation reference: draft-2026-07-18-caserun-independent-page. -->
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import {
Activity,
Archive,
Check,
Clipboard,
Cpu,
ExternalLink,
FileCode2,
RefreshCw,
ShieldAlert,
TerminalSquare,
XCircle,
} from "lucide-vue-next";
import PageCommandBar from "@/components/layout/PageCommandBar.vue";
import StatusStrip, { type ConsoleStatusItem } from "@/components/layout/StatusStrip.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import { useCaseRunStore } from "@/stores/caserun";
import type { CaseRunCaseSummary, CaseRunEvent } from "@/types";
const route = useRoute();
const router = useRouter();
const caserun = useCaseRunStore();
const selectedCaseId = ref("");
type EvidenceTab = "aggregate" | "artifacts" | "trace" | "hwpod";
const evidenceTabs: Array<{ id: EvidenceTab; label: string }> = [
{ id: "aggregate", label: "汇总" },
{ id: "artifacts", label: "产物" },
{ id: "trace", label: "链路" },
{ id: "hwpod", label: "HWPOD" },
];
const selectedTab = ref<EvidenceTab>("aggregate");
let pollTimer: ReturnType<typeof setInterval> | null = null;
const runId = computed(() => typeof route.params.runId === "string" ? route.params.runId : "");
const currentRun = computed(() => caserun.currentRun);
const currentStatusCode = computed(() => currentRun.value?.status ?? "idle");
const currentStageCode = computed(() => currentRun.value?.stage ?? "pending-selection");
const currentStatus = computed(() => statusLabel(currentStatusCode.value));
const currentStage = computed(() => stageLabel(currentStageCode.value));
const selectedCase = computed<CaseRunCaseSummary | null>(() => caserun.cases.find((item) => item.caseId === selectedCaseId.value) ?? null);
const currentEvents = computed(() => caserun.events ?? []);
const currentBlocker = computed(() => currentRun.value?.blocker ?? null);
const aggregate = computed(() => caserun.aggregate);
const terminal = computed(() => currentRun.value?.terminal === true);
const statusTone = computed(() => {
if (currentStatusCode.value === "completed") return "ok";
if (["failed", "blocked"].includes(currentStatusCode.value)) return "error";
if (["queued", "running", "cancel_requested"].includes(currentStatusCode.value)) return "running";
return "pending";
});
const fixtureMode = computed(() => String(aggregate.value?.mode ?? currentRun.value?.mode ?? selectedCase.value?.mode ?? "live"));
const fixtureModeLabel = computed(() => modeLabel(fixtureMode.value));
const stages = ["queued", "preparing", "agent-running", "building", "downloading", "uart-reading", "aggregating", "completed"];
const stageIndex = computed(() => {
const index = stages.indexOf(String(currentStageCode.value));
return index >= 0 ? index : currentStatusCode.value === "failed" || currentStatusCode.value === "blocked" ? 2 : -1;
});
const statusItems = computed<ConsoleStatusItem[]>(() => [
{ id: "cases", label: "可用用例", value: caserun.cases.length, tone: "info" },
{ id: "status", label: "当前状态", value: currentStatus.value, tone: statusTone.value === "ok" ? "ok" : statusTone.value === "error" ? "error" : "info" },
{ id: "stage", label: "当前阶段", value: currentStage.value, tone: "neutral" },
{ id: "events", label: "事件", value: currentEvents.value.length, tone: "neutral" },
{ id: "artifacts", label: "产物", value: currentRun.value?.facts?.artifactCount ?? 0, tone: "neutral" },
]);
onMounted(async () => {
selectedCaseId.value = typeof route.query.case === "string" ? route.query.case : "";
selectedTab.value = parseTab(route.query.tab);
await caserun.refreshCases();
if (!selectedCaseId.value) selectedCaseId.value = caserun.cases[0]?.caseId ?? "";
if (runId.value) await caserun.refreshRun(runId.value);
pollTimer = setInterval(() => {
if (caserun.shouldPoll) void caserun.refreshRun();
}, 5000);
});
onBeforeUnmount(() => {
if (pollTimer !== null) clearInterval(pollTimer);
});
watch(() => route.params.runId, async (value) => {
if (typeof value === "string" && value) await caserun.refreshRun(value);
});
watch(() => route.query.tab, (value) => { selectedTab.value = parseTab(value); });
function parseTab(value: unknown): EvidenceTab {
return value === "artifacts" || value === "trace" || value === "hwpod" ? value : "aggregate";
}
async function startRun(): Promise<void> {
if (!selectedCaseId.value) return;
const started = await caserun.startRun(selectedCaseId.value);
if (started?.runId) await router.push({ name: "CaseRunDetail", params: { runId: started.runId }, query: { case: selectedCaseId.value } });
}
async function refresh(): Promise<void> {
await caserun.refreshCases();
if (runId.value) await caserun.refreshRun(runId.value);
}
async function selectCase(caseId: string): Promise<void> {
selectedCaseId.value = caseId;
await router.replace({ name: runId.value ? "CaseRunDetail" : "CaseRun", params: runId.value ? { runId: runId.value } : {}, query: { case: caseId, tab: selectedTab.value } });
}
async function selectTab(tab: EvidenceTab): Promise<void> {
selectedTab.value = tab;
await router.replace({ query: { ...route.query, tab } });
}
async function copy(value: string): Promise<void> {
if (value) await navigator.clipboard?.writeText(value);
}
function eventLabel(event: CaseRunEvent): string {
return `${stageLabel(event.stage)} · ${statusLabel(event.status)}`;
}
function statusLabel(value: unknown): string {
const code = String(value ?? "unknown");
return ({ idle: "空闲", pending: "等待中", queued: "已排队", running: "运行中", completed: "已完成", failed: "失败", blocked: "受阻", canceled: "已取消", cancelled: "已取消", cancel_requested: "正在取消", unknown: "未知" } as Record<string, string>)[code] ?? code;
}
function stageLabel(value: unknown): string {
const code = String(value ?? "unknown");
return ({ "pending-selection": "等待选择用例", queued: "排队", preparing: "准备", prepared: "准备完成", "agent-running": "代理执行", building: "构建", downloading: "下载", "uart-reading": "串口读取", aggregating: "汇总", completed: "完成", failed: "失败", blocked: "受阻", canceled: "取消", cancelled: "取消", unknown: "未知阶段" } as Record<string, string>)[code] ?? code;
}
function modeLabel(value: string): string {
if (value === "native-test") return "原生测试 · native-test";
if (value === "software-smoke") return "软件冒烟 · software-smoke";
if (value === "live") return "真实运行 · live";
return value;
}
function eventTime(value?: string): string {
if (!value) return "--:--:--";
const date = new Date(value);
return Number.isNaN(date.getTime()) ? "--:--:--" : date.toLocaleTimeString("zh-CN", { hour12: false });
}
</script>
<template>
<section class="caserun-page" id="caserun-page">
<PageCommandBar eyebrow="强化学习 / 用例运行" title="CaseRun 执行台" description="从 HWPOD 用例提交到硬件证据归档的统一运行视图。">
<template #actions>
<span id="caserun-mode" class="caserun-origin"><Activity :size="14" aria-hidden="true" />{{ fixtureModeLabel }}</span>
<button id="caserun-refresh" class="btn btn-secondary" type="button" title="刷新 CaseRun" aria-label="刷新 CaseRun" @click="refresh"><RefreshCw :size="15" aria-hidden="true" />刷新</button>
<button v-if="runId" class="btn btn-secondary" type="button" title="复制 runId" aria-label="复制 runId" @click="copy(runId)"><Clipboard :size="15" aria-hidden="true" />复制 runId</button>
</template>
</PageCommandBar>
<StatusStrip :items="statusItems" label="CaseRun 状态摘要" live="polite" />
<div class="caserun-workspace">
<aside class="caserun-sidebar" aria-label="CaseRun 用例选择">
<div class="caserun-sidebar-heading">
<div><span class="caserun-kicker">用例目录</span><h2>可执行用例</h2></div>
<span class="caserun-count">{{ caserun.cases.length }}</span>
</div>
<div class="caserun-case-list">
<button v-for="item in caserun.cases" :key="item.caseId" class="caserun-case-row" :class="{ selected: item.caseId === selectedCaseId }" type="button" @click="selectCase(item.caseId)">
<span class="case-row-icon"><Cpu :size="16" aria-hidden="true" /></span>
<span class="case-row-copy"><strong>{{ item.title || item.caseId }}</strong><small>{{ item.caseId }}</small><small>{{ item.hwpodSpec || "未声明 HWPOD 规格" }}</small></span>
<Check v-if="item.available !== false" class="case-row-check" :size="15" aria-hidden="true" />
</button>
<div v-if="!caserun.cases.length && !caserun.loading" class="caserun-empty">当前入口没有可执行用例</div>
</div>
<div v-if="selectedCase" class="caserun-context">
<span class="caserun-kicker">已选上下文</span>
<strong>{{ selectedCase.title || selectedCase.caseId }}</strong>
<dl>
<div><dt>用例 ID</dt><dd>{{ selectedCase.caseId }}</dd></div>
<div><dt>运行模式</dt><dd>{{ modeLabel(selectedCase.mode || "live") }}</dd></div>
<div><dt>HWPOD</dt><dd>{{ selectedCase.hwpodSpec || "-" }}</dd></div>
</dl>
</div>
<button id="caserun-start" class="caserun-start" type="button" :disabled="!selectedCaseId || caserun.starting" @click="startRun"><TerminalSquare :size="17" aria-hidden="true" />{{ caserun.starting ? "提交中" : "启动 CaseRun" }}</button>
</aside>
<main class="caserun-main">
<section class="caserun-run-header" id="caserun-panel">
<div class="run-heading">
<span class="caserun-kicker">当前运行</span>
<div class="run-title-line"><h2>{{ currentRun?.caseId || selectedCase?.caseId || "未选择运行" }}</h2><StatusBadge :status="statusTone" :label="currentStatus" /></div>
<p id="caserun-run-id">{{ currentRun?.runId || "启动 CaseRun 后,这里会显示稳定的运行标识。" }}</p>
</div>
<dl class="run-facts">
<div><dt>阶段</dt><dd id="caserun-status">{{ currentStage }}</dd></div>
<div><dt>终态</dt><dd id="caserun-terminal">{{ currentRun ? (currentRun.terminal ? "是" : "否") : "-" }}</dd></div>
<div><dt>作业</dt><dd>{{ currentRun?.facts?.jobId || "-" }}</dd></div>
<div><dt>产物</dt><dd>{{ currentRun?.facts?.artifactCount ?? 0 }}</dd></div>
</dl>
</section>
<section class="caserun-timeline" aria-label="CaseRun 执行阶段">
<div class="section-heading"><div><span class="caserun-kicker">执行路径</span><h3>执行阶段</h3></div><span class="timeline-note">API 读取模型 · {{ currentRun?.updatedAt || "等待读取" }}</span></div>
<div class="stage-track">
<div v-for="(stage, index) in stages" :key="stage" class="stage-node" :class="{ reached: index <= stageIndex, active: index === stageIndex }"><span class="stage-dot"><Check v-if="index < stageIndex || terminal" :size="12" aria-hidden="true" /></span><span>{{ stageLabel(stage) }}</span></div>
</div>
<div v-if="currentBlocker" class="caserun-blocker" role="status"><ShieldAlert :size="18" aria-hidden="true" /><div><strong>{{ currentBlocker.code }}</strong><p>{{ currentBlocker.summary }}</p><small>{{ currentBlocker.layer || "runtime" }}</small></div></div>
<div v-else-if="caserun.error" class="caserun-error" role="alert"><XCircle :size="18" aria-hidden="true" /><span>{{ caserun.error }}</span></div>
</section>
<section class="caserun-events" aria-label="CaseRun 事件流">
<div class="section-heading"><div><span class="caserun-kicker">事件流</span><h3>运行事件</h3></div><span class="timeline-note">{{ currentEvents.length }} 条事件</span></div>
<div v-if="!currentEvents.length" class="event-empty">尚未读取事件启动 CaseRun 或从深链载入 run 服务端事件会显示在这里</div>
<ol v-else class="event-list" id="caserun-event-text">
<li v-for="(event, index) in currentEvents" :key="`${event.at}-${index}`"><time>{{ eventTime(event.at) }}</time><span class="event-dot" /><div><strong>{{ eventLabel(event) }}</strong><small>{{ event.payload ? JSON.stringify(event.payload) : "服务端状态事件" }}</small></div></li>
</ol>
</section>
</main>
<aside class="caserun-inspector" aria-label="CaseRun 证据检查器">
<div class="inspector-heading"><div><span class="caserun-kicker">证据</span><h2>运行证据</h2></div><Archive :size="18" aria-hidden="true" /></div>
<div class="evidence-tabs" role="tablist" aria-label="运行证据标签页">
<button v-for="tab in evidenceTabs" :key="tab.id" type="button" role="tab" :aria-selected="selectedTab === tab.id" @click="selectTab(tab.id)">{{ tab.label }}</button>
</div>
<div class="evidence-body" id="caserun-aggregate">
<template v-if="selectedTab === 'aggregate'">
<div class="evidence-state"><span class="state-label">汇总状态</span><strong id="caserun-aggregate-status">{{ statusLabel(aggregate?.status || "pending") }}</strong><small id="caserun-aggregate-run-id">{{ aggregate?.runId || "等待 CaseRun" }}</small></div>
<div class="hash-row"><span>SHA256</span><button type="button" title="复制汇总 SHA" aria-label="复制汇总 SHA" @click="copy(aggregate?.sha256 || '')"><code id="caserun-aggregate-sha">{{ aggregate?.sha256 || "-" }}</code><Clipboard :size="14" aria-hidden="true" /></button></div>
<dl class="evidence-facts"><div><dt>摘要</dt><dd>{{ aggregate?.summary ? "已读取" : "-" }}</dd></div><div><dt>证据</dt><dd>{{ aggregate?.evidence ? "已读取" : "-" }}</dd></div><div><dt>事实来源</dt><dd>{{ aggregate?.sourceAuthority?.mode || currentRun?.sourceAuthority?.mode || fixtureMode }}</dd></div></dl>
</template>
<template v-else-if="selectedTab === 'artifacts'"><div class="evidence-state"><span class="state-label">产物清单</span><strong>{{ currentRun?.references?.manifest?.count ?? 0 }} 项</strong><small>{{ currentRun?.references?.manifest?.href || "暂无清单引用" }}</small></div></template>
<template v-else-if="selectedTab === 'trace'"><div class="evidence-state"><span class="state-label">链路标识</span><strong>{{ currentRun?.references?.trace?.traceId || "暂无链路" }}</strong><small>只读链路引用执行证据仍以 CaseRun API 为准</small></div></template>
<template v-else><div class="evidence-state"><span class="state-label">HWPOD 操作</span><strong>{{ currentRun?.references?.hwpod?.hwpodId || "暂无 HWPOD" }}</strong><small>节点 {{ currentRun?.references?.hwpod?.nodeId || "-" }}</small></div></template>
</div>
<div class="inspector-footer"><FileCode2 :size="15" aria-hidden="true" /><span>证据来自当前 CaseRun API不在客户端合成终态</span><ExternalLink :size="14" aria-hidden="true" /></div>
</aside>
</div>
</section>
</template>
<style scoped>
.caserun-page { display: grid; height: 100%; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr); gap: 12px; overflow: hidden; color: var(--console-graphite-950); }
.caserun-origin { display: inline-flex; align-items: center; gap: 6px; border: 1px solid var(--console-border); border-radius: var(--console-radius-sm); background: var(--console-surface-muted); padding: 7px 9px; color: var(--console-cyan-700); font-family: var(--console-font-mono); font-size: 11px; }
.caserun-workspace { display: grid; height: 100%; min-height: 0; grid-template-columns: minmax(220px, 0.78fr) minmax(420px, 1.7fr) minmax(250px, 0.92fr); overflow: hidden; overscroll-behavior: contain; border: 1px solid var(--console-border); border-radius: var(--console-radius-md); background: var(--console-surface-raised); box-shadow: var(--console-shadow-sm); }
.caserun-sidebar, .caserun-inspector { min-width: 0; background: var(--console-surface-muted); }
.caserun-sidebar { display: flex; min-height: 0; flex-direction: column; border-right: 1px solid var(--console-border); }
.caserun-inspector { display: grid; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr) auto; overflow: hidden; border-left: 1px solid var(--console-border); }
.caserun-sidebar-heading, .inspector-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--console-border); padding: 15px; }
.caserun-sidebar-heading h2, .inspector-heading h2, .run-heading h2, .section-heading h3 { margin: 3px 0 0; font-size: 15px; font-weight: 780; }
.caserun-kicker { color: var(--console-cyan-700); font-family: var(--console-font-mono); font-size: 10px; font-weight: 780; letter-spacing: .08em; }
.caserun-count { min-width: 24px; padding: 3px 7px; border: 1px solid var(--console-border); border-radius: 99px; color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 11px; text-align: center; }
.caserun-case-list { min-height: 0; flex: 1 1 auto; overflow: auto; overscroll-behavior: contain; padding: 8px; }
.caserun-case-row { display: grid; width: 100%; grid-template-columns: 28px minmax(0, 1fr) 16px; gap: 8px; align-items: start; border: 1px solid transparent; border-radius: var(--console-radius-sm); background: transparent; padding: 10px 8px; color: inherit; text-align: left; cursor: pointer; }
.caserun-case-row:hover, .caserun-case-row.selected { border-color: var(--console-cyan-200); background: var(--console-cyan-100); }
.case-row-icon { display: grid; width: 28px; height: 28px; place-items: center; border-radius: 6px; background: var(--console-surface-raised); color: var(--console-cyan-700); }
.case-row-copy { display: grid; min-width: 0; gap: 2px; }
.case-row-copy strong, .case-row-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.case-row-copy strong { font-size: 12px; }
.case-row-copy small { color: var(--console-graphite-600); font-family: var(--console-font-mono); font-size: 10px; }
.case-row-check { margin-top: 5px; color: var(--console-green-700); }
.caserun-context { display: grid; gap: 8px; margin: 8px; border-top: 1px solid var(--console-border); padding: 13px 4px 4px; }
.caserun-context strong { font-size: 13px; }
.caserun-context dl, .run-facts, .evidence-facts { display: grid; gap: 6px; margin: 0; }
.caserun-context dl div, .run-facts div, .evidence-facts div { display: flex; justify-content: space-between; gap: 8px; }
.caserun-context dt, .run-facts dt, .evidence-facts dt { color: var(--console-graphite-500); font-family: var(--console-font-mono); font-size: 10px; text-transform: uppercase; }
.caserun-context dd, .run-facts dd, .evidence-facts dd { margin: 0; overflow: hidden; color: var(--console-graphite-800); font-family: var(--console-font-mono); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.caserun-start { display: inline-flex; min-height: 40px; align-items: center; justify-content: center; gap: 8px; margin: auto 12px 13px; border: 1px solid var(--console-cyan-700); border-radius: var(--console-radius-sm); background: var(--console-cyan-700); color: white; font-weight: 760; cursor: pointer; }
.caserun-start:disabled { cursor: not-allowed; opacity: .5; }
.caserun-main { display: grid; min-width: 0; min-height: 0; grid-template-rows: auto auto minmax(0, 1fr); overflow: hidden; }
.caserun-run-header { display: flex; min-width: 0; align-items: flex-start; justify-content: space-between; gap: 18px; border-bottom: 1px solid var(--console-border); padding: 18px 20px; }
.run-heading { min-width: 0; }
.run-title-line { display: flex; flex-wrap: wrap; align-items: center; gap: 9px; }
.run-heading p { max-width: 45ch; margin: 6px 0 0; overflow: hidden; color: var(--console-graphite-500); font-family: var(--console-font-mono); font-size: 11px; text-overflow: ellipsis; white-space: nowrap; }
.run-facts { min-width: 190px; }
.caserun-timeline, .caserun-events { min-width: 0; padding: 16px 20px; }
.caserun-timeline { overflow-x: auto; border-bottom: 1px solid var(--console-border); }
.section-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.timeline-note { color: var(--console-graphite-500); font-family: var(--console-font-mono); font-size: 10px; }
.stage-track { display: grid; min-width: 680px; grid-template-columns: repeat(8, 1fr); margin: 25px 0 19px; }
.stage-node { position: relative; display: grid; justify-items: center; gap: 8px; color: var(--console-graphite-500); font-family: var(--console-font-mono); font-size: 10px; text-align: center; }
.stage-node::before { position: absolute; top: 7px; right: 50%; width: 100%; height: 2px; background: var(--console-border); content: ""; }
.stage-node:first-child::before { display: none; }
.stage-node.reached { color: var(--console-cyan-700); }
.stage-node.reached::before { background: var(--console-cyan-500); }
.stage-node.active { color: var(--console-graphite-950); font-weight: 780; }
.stage-dot { position: relative; z-index: 1; display: grid; width: 16px; height: 16px; place-items: center; border: 2px solid var(--console-border-strong); border-radius: 50%; background: var(--console-surface-raised); }
.stage-node.reached .stage-dot { border-color: var(--console-cyan-600); background: var(--console-cyan-600); color: white; }
.stage-node.active .stage-dot { box-shadow: 0 0 0 4px var(--console-cyan-100); }
.caserun-blocker, .caserun-error { display: flex; align-items: flex-start; gap: 10px; border: 1px solid var(--console-red-700); border-radius: var(--console-radius-sm); background: var(--console-red-100); padding: 10px 12px; color: var(--console-red-700); }
.caserun-blocker strong { font-family: var(--console-font-mono); font-size: 11px; }
.caserun-blocker p { margin: 3px 0; font-size: 12px; }
.caserun-blocker small { font-family: var(--console-font-mono); font-size: 10px; }
.caserun-events { min-height: 0; overflow: auto; overscroll-behavior: contain; }
.event-empty, .caserun-empty { color: var(--console-graphite-500); font-size: 12px; line-height: 1.6; }
.event-list { display: grid; gap: 0; margin: 13px 0 0; padding: 0; list-style: none; }
.event-list li { display: grid; grid-template-columns: 64px 12px minmax(0, 1fr); gap: 10px; align-items: start; padding: 9px 0; border-bottom: 1px solid var(--console-graphite-100); }
.event-list time, .event-list small { color: var(--console-graphite-500); font-family: var(--console-font-mono); font-size: 10px; }
.event-list li > div { display: grid; min-width: 0; gap: 3px; }
.event-list strong { font-size: 12px; }
.event-list small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.event-dot { width: 8px; height: 8px; margin-top: 4px; border: 2px solid var(--console-cyan-600); border-radius: 50%; background: var(--console-surface-raised); }
.evidence-tabs { display: grid; grid-template-columns: repeat(4, 1fr); border-bottom: 1px solid var(--console-border); }
.evidence-tabs button { border: 0; border-bottom: 2px solid transparent; background: transparent; padding: 10px 4px; color: var(--console-graphite-500); font-family: var(--console-font-mono); font-size: 10px; cursor: pointer; }
.evidence-tabs button[aria-selected="true"] { border-bottom-color: var(--console-cyan-600); color: var(--console-cyan-700); font-weight: 780; }
.evidence-body { min-height: 0; overflow: auto; overscroll-behavior: contain; padding: 16px; }
.evidence-state { display: grid; gap: 8px; }
.state-label, .hash-row > span { color: var(--console-graphite-500); font-family: var(--console-font-mono); font-size: 10px; text-transform: uppercase; }
.evidence-state strong { overflow-wrap: anywhere; font-size: 14px; }
.evidence-state small { color: var(--console-graphite-600); line-height: 1.5; overflow-wrap: anywhere; }
.hash-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin: 20px 0; border-top: 1px solid var(--console-border); border-bottom: 1px solid var(--console-border); padding: 10px 0; }
.hash-row button { display: inline-flex; min-width: 0; align-items: center; gap: 6px; border: 0; background: transparent; color: var(--console-cyan-700); cursor: pointer; }
.hash-row code { max-width: 150px; overflow: hidden; font-family: var(--console-font-mono); font-size: 10px; text-overflow: ellipsis; }
.evidence-facts { margin-top: 18px; }
.inspector-footer { display: flex; align-items: center; gap: 7px; border-top: 1px solid var(--console-border); padding: 12px 16px; color: var(--console-graphite-500); font-size: 10px; line-height: 1.45; }
.inspector-footer svg:last-child { flex: 0 0 auto; color: var(--console-cyan-700); }
@media (max-width: 1180px) { .caserun-workspace { grid-template-columns: minmax(205px, .7fr) minmax(420px, 1.4fr); grid-template-rows: minmax(0, 1fr) auto; overflow-y: auto; } .caserun-inspector { grid-column: 1 / -1; border-top: 1px solid var(--console-border); border-left: 0; } .evidence-body { max-height: 180px; } }
@media (max-width: 760px) { .caserun-workspace { display: flex; min-height: 0; flex-direction: column; overflow-y: auto; } .caserun-sidebar, .caserun-inspector { flex: 0 0 auto; border: 0; } .caserun-sidebar { border-bottom: 1px solid var(--console-border); } .caserun-case-list { max-height: 210px; } .caserun-main { min-height: 520px; flex: 0 0 auto; } .caserun-run-header { flex-direction: column; } .run-facts { width: 100%; } .stage-track { min-width: 620px; } .caserun-inspector { border-top: 1px solid var(--console-border); } .page-command-actions { justify-content: flex-start; } }
</style>
@@ -8,7 +8,6 @@ import CommandComposer from "@/components/workbench/CommandComposer.vue";
import ConversationPanel from "@/components/workbench/ConversationPanel.vue";
import SessionRail from "@/components/workbench/SessionRail.vue";
import WorkbenchKafkaDebugPanel from "@/components/workbench/WorkbenchKafkaDebugPanel.vue";
import CaseRunPanel from "@/components/caserun/CaseRunPanel.vue";
import HwpodNodeOpsPanel from "@/components/hwpod/HwpodNodeOpsPanel.vue";
import { useAutoRefresh } from "@/composables/useAutoRefresh";
import { shouldReflectWorkbenchSessionUrl } from "@/router/workbench-navigation";
@@ -152,7 +151,6 @@ async function reflectActiveSessionInUrl(value: string | null): Promise<void> {
<CommandComposer />
</main>
<div class="workbench-tools-column">
<CaseRunPanel />
<HwpodNodeOpsPanel />
</div>
</div>
+49 -23
View File
@@ -2,23 +2,40 @@ import { Agent } from "node:http";
import { fileURLToPath, URL } from "node:url";
import vue from "@vitejs/plugin-vue";
import { defineConfig, loadEnv, type Plugin } from "vite";
import { defineConfig, loadEnv, type Plugin, type ProxyOptions } from "vite";
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "");
const nativeApiUrl = String(process.env.WORKBENCH_NATIVE_API_URL ?? env.WORKBENCH_NATIVE_API_URL ?? "").trim();
const nativeRuntimeConfig = String(process.env.WORKBENCH_WEB_RUNTIME_CONFIG ?? env.WORKBENCH_WEB_RUNTIME_CONFIG ?? "").trim();
const nativeCaseRunEnabled = (process.env.HWLAB_CASERUN_NATIVE_TEST ?? env.HWLAB_CASERUN_NATIVE_TEST) === "1";
const nativeCaseRunTarget = process.env.HWLAB_CASERUN_NATIVE_URL ?? env.HWLAB_CASERUN_NATIVE_URL ?? "http://127.0.0.1:4316";
const nativeCaseRunTarget = String(process.env.HWLAB_CASERUN_NATIVE_URL ?? env.HWLAB_CASERUN_NATIVE_URL ?? "").trim();
const nativeCaseRunAgent = new Agent({ keepAlive: true });
if (nativeApiUrl && nativeCaseRunEnabled) {
throw new Error("Workbench and CaseRun native modes cannot share one Vite server");
}
if (nativeCaseRunEnabled && !nativeCaseRunTarget) throw new Error("HWLAB_CASERUN_NATIVE_URL is required from the CLI-managed YAML profile in CaseRun native mode");
if (nativeApiUrl && !nativeRuntimeConfig) throw new Error("WORKBENCH_WEB_RUNTIME_CONFIG is required in Workbench native mode");
const proxy: Record<string, string | ProxyOptions> | undefined = nativeCaseRunEnabled
? {
"/auth": { target: nativeCaseRunTarget, changeOrigin: false, agent: nativeCaseRunAgent },
"/v1/caserun": { target: nativeCaseRunTarget, changeOrigin: false, agent: nativeCaseRunAgent }
}
: nativeApiUrl
? {
"/v1": { target: nativeApiUrl, changeOrigin: false },
"/auth": { target: nativeApiUrl, changeOrigin: false },
"/health": { target: nativeApiUrl, changeOrigin: false }
}
: undefined;
return {
plugins: [...(nativeApiUrl ? [nativeRuntimeConfigPlugin(nativeRuntimeConfig)] : []), vue()],
plugins: [
...(nativeApiUrl ? [nativeRuntimeConfigPlugin(nativeRuntimeConfig)] : []),
...(nativeCaseRunEnabled ? [nativeCaseRunRuntimeConfigPlugin()] : []),
vue()
],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url))
@@ -31,26 +48,7 @@ export default defineConfig(({ mode }) => {
watch: nativeCaseRunEnabled || env.WORKBENCH_VITE_USE_POLLING === "1"
? { usePolling: true, interval: nativeCaseRunEnabled ? 300 : 500 }
: undefined,
proxy: nativeCaseRunEnabled
? {
"/auth": {
target: nativeCaseRunTarget,
changeOrigin: false,
agent: nativeCaseRunAgent
},
"/v1/caserun": {
target: nativeCaseRunTarget,
changeOrigin: false,
agent: nativeCaseRunAgent
}
}
: nativeApiUrl
? {
"/v1": { target: nativeApiUrl, changeOrigin: false },
"/auth": { target: nativeApiUrl, changeOrigin: false },
"/health": { target: nativeApiUrl, changeOrigin: false }
}
: undefined
proxy
},
build: {
outDir: "dist",
@@ -78,6 +76,34 @@ function nativeRuntimeConfigPlugin(serialized: string): Plugin {
};
}
function nativeCaseRunRuntimeConfigPlugin(): Plugin {
return {
name: "hwlab-caserun-native-runtime-config",
configureServer(server) {
server.middlewares.use((request, response, next) => {
if (request.url !== "/v1/web-performance" || request.method !== "POST") return next();
response.writeHead(204, { "cache-control": "no-store" });
response.end();
});
},
transformIndexHtml() {
return [{
tag: "script",
injectTo: "head-prepend",
children: `window.HWLAB_CLOUD_WEB_CONFIG = {
displayTime: { timeZone: "Asia/Shanghai", locale: "zh-CN", label: "北京时间" },
workbench: {
realtimeFeatures: { liveKafkaSse: false, kafkaRefreshReplay: false, projectionRealtime: true },
debugCapabilities: { isolatedKafka: false, rawHwlabEventWindow: { enabled: false, maxEntries: 1, maxRetainedBytes: 1 } },
traceTimeline: { autoExpandRunning: false, autoCollapseTerminal: false },
runtimePolicy: {}
}
};`
}];
}
};
}
export function parseWorkbenchNativeRuntimeConfig(serialized: string): Record<string, unknown> {
let value: unknown;
try { value = JSON.parse(serialized); }