From 2bc5fc653a012d2fb1fd0d17248008fbfbc5d9cb Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Jul 2026 06:01:58 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=8C=E5=96=84=20CaseRun=20?= =?UTF-8?q?=E5=8E=9F=E7=94=9F=E8=AF=81=E6=8D=AE=E8=A7=86=E5=9B=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.7 --- docs/reference/spec-v03-caserun-web.md | 101 ++++++++++++++ .../scripts/caserun-native-fixtures.test.ts | 7 +- .../scripts/caserun-native-fixtures.ts | 32 ++++- .../scripts/caserun-native-web-probe.mjs | 18 ++- web/hwlab-cloud-web/src/stores/caserun.ts | 6 +- web/hwlab-cloud-web/src/types/index.ts | 8 +- .../src/views/caserun/CaseRunView.vue | 130 ++++++++++++++---- 7 files changed, 255 insertions(+), 47 deletions(-) create mode 100644 docs/reference/spec-v03-caserun-web.md diff --git a/docs/reference/spec-v03-caserun-web.md b/docs/reference/spec-v03-caserun-web.md new file mode 100644 index 00000000..2172ca58 --- /dev/null +++ b/docs/reference/spec-v03-caserun-web.md @@ -0,0 +1,101 @@ +# HWLAB v0.3 CaseRun 独立页面设计规格 + +## 1. 设计结论 + +CaseRun 是硬件执行运行台,不是 Workbench 的附属诊断卡片。页面必须使用独立路由 `/caserun`,让用户从选择 case 到阅读执行证据都在同一条稳定深链内完成。 + +页面采用“命令栏 + 运行摘要 + 三栏运行工作区”的结构: + +- 左栏负责 case、HWPOD 资源和执行上下文选择。 +- 中栏负责当前 run 的阶段时间线、事件流和终态变化。 +- 右栏负责 aggregate、artifact、Trace、HWPOD operation result 和 blocker 阅读。 + +工作台可以保留跳转入口,但不再嵌入 CaseRun 控制面板,避免同一 run 在两个页面各自维护选择、轮询和状态投影。 + +## 2. 调研依据 + +- AgentRun 运行观察页:采用窄命令栏、状态摘要条、可筛选主列表和右侧 Inspector,适合高密度运行状态扫描。 +- TaskTree 页面:采用稳定 URL、主内容区占优、时间线和可折叠详情,适合执行阶段与长列表阅读。 +- Temporal/CI 运行详情惯例:把 workflow/run identity、阶段、失败断点和产物引用分层展示,不能只用一个总体状态替代过程证据。 +- HWLAB 当前 Workbench:已有 `CaseRunPanel`、CaseRun store/API 和 native fixture,但面板尺寸、工作台上下文和 HWPOD 操作共用空间,无法承载完整证据阅读。 +- HarnessRL 合同:CaseRun 结果包含 `runId`、`caseId`、`status`、`stage`、`terminal`、`blocker`、events、aggregate、manifest、replay、Trace 和 HWPOD 引用,页面不得丢弃这些事实。 + +## 3. 视觉方向 + +采用“实验室控制台”视觉方向: + +- 复用 HWLAB 现有浅色 console token、IBM Plex Sans/Mono、窄命令栏和状态条。 +- 主色使用石墨、青绿色和少量琥珀/红色状态色,避免紫色渐变、营销式 hero 和装饰性大卡片。 +- 页面首屏直接展示可操作运行对象,标题区保持紧凑,不添加说明型大段文案。 +- 证据区使用浅色代码/日志表面,长文本内部滚动,不让原始 JSON 撑破页面。 +- 所有按钮遵循已有图标与文字组合;刷新、复制、打开 Trace 等熟悉动作使用图标并保留无障碍名称。 +- 卡片只用于重复的 case、阶段和证据条目,页面分区使用无装饰的 bounded layout,不嵌套卡片。 + +## 4. 页面信息架构 + +### 4.1 命令栏 + +- eyebrow:`HARNESSRL / CASERUN` +- 标题:`CaseRun 执行台` +- 描述:显示当前 selected origin、API mode 和最后一次读取时间。 +- 操作:刷新 cases、重新读取当前 run、复制 runId;运行中提供取消入口时使用后端已有 cancel contract。 +- 状态条:可用 cases、当前 status、stage、terminal、events 数量、artifact 数量。 + +### 4.2 左栏:执行选择 + +- case 列表显示 `caseId`、标题、mode、目标 HWPOD 和可用状态。 +- 选择 case 后显示目标硬件、执行范围、workspace/能力摘要和已知 blocker。 +- 启动按钮只提交 `caseId`,不在客户端拼接 workspace、node、runtime URL 或 fallback。 +- 当前 run 进入深链后,左栏保留 case 选择但不覆盖当前 run。 + +### 4.3 中栏:运行过程 + +- 顶部显示 runId、caseId、当前 status、stage、terminal 和更新时间。 +- 阶段时间线至少支持 `queued`、`preparing`、`agent-running`、`building`、`downloading`、`uart-reading`、`aggregating`、`completed`、`failed`、`canceled`、`blocked`。 +- 事件流显示服务端返回的时间、stage、status 和脱敏 payload 摘要。 +- 运行中按现有 API read model 轮询;terminal 只能由 API 返回的 `terminal` 决定。 +- 空态、加载态、错误态和 blocker 态必须保留主区域结构,不能以空白或通用请求失败替代。 + +### 4.4 右栏:证据 Inspector + +使用 Tab 或折叠区组织以下证据: + +- `Aggregate`:status、summary、evidence、aggregate SHA 和 run identity。 +- `Artifacts`:manifest 引用、artifact 数量、hash/ref 和可用状态。 +- `Trace`:traceId、AgentRun/command/session 引用及只读下钻入口。 +- `HWPOD`:hwpodId、nodeId、operation result、workspace、能力和连接状态。 +- `Blocker`:code、summary、layer、details 和下一步只读诊断。 + +默认展开 Aggregate 和 Blocker;Trace、原始事件和 manifest 在需要时展开,避免长日志抢占首屏。 + +## 5. 深链与状态权威 + +- 基础路由为 `/caserun`。 +- 当前 run 使用 `/caserun/runs/:runId`,刷新后恢复同一个 run。 +- query 只承载 `case`、`tab` 等集合视图状态,不承载终态、stage、artifact 或 blocker。 +- Web 只调用 selected origin 的同源 CaseRun API。 +- API 的 `status`、`stage`、`terminal`、`events`、`aggregate` 和引用字段是唯一事实;客户端不能依据 elapsed time、事件数量、HTTP 200 或 fixture 名称合成终态。 + +## 6. L1 Native 合同 + +前后端必须独立启动、独立日志、独立停止: + +- API/fixture:使用现有 `caserun:native:start|stop|status|logs`,默认 native API port 为 `4316`。 +- Web:使用 `dev:native-caserun`,Vite HMR 与 `/v1/caserun/*` native proxy 独立运行。 +- Native fixture:继续覆盖 queued、running、completed、failed、blocked、canceled、events 和 aggregate。 +- Web smoke:使用 repo-owned `caserun-native-web-probe.mjs`,验证 `/caserun` 首屏、case 选择、启动、刷新、事件和 aggregate。 +- Native 页面必须显示 `mode=native-test`,但生产 selected origin 不得读取 native fixture 或 native fallback。 + +## 7. 响应式与验收 + +- 桌面首屏使用三栏 bounded workspace,主 run 区域优先保证可读宽度。 +- 中等宽度将右侧 Inspector 收为抽屉或下方区域,不能压缩事件流至不可读。 +- 移动端按“命令栏 → 当前 run → case 选择 → 事件 → 证据”纵向排列,启动和刷新保持可达。 +- L1 验收使用 `web-probe` custom/local 入口,覆盖桌面 `1920x1080` 和紧凑桌面视口。 +- 必须确认首屏 DOM、启动/刷新交互、无 pageerror、无 console.error、无关键失败请求和无 document 横向溢出。 + +## 8. 非目标 + +- 不在本次页面改造中重写 HarnessRL API、Temporal workflow 或 CaseRun evidence contract。 +- 不恢复 v0.2 direct URL、旧 runner、G14 fallback 或客户端第二状态源。 +- 不把 CI/CD、Kubernetes、HWPOD node 或原始数据库访问暴露给浏览器。 diff --git a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts index 62d6d4bb..39c79f27 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.test.ts @@ -15,9 +15,13 @@ test("native CaseRun fixtures expose six deterministic API-owned states", () => const running = readNativeRun(started!.runId); assert.equal(running?.status, "running"); assert.equal(running?.terminal, false); + for (let index = 0; index < 3; index += 1) readNativeRun(started!.runId); const completed = readNativeRun(started!.runId); assert.equal(completed?.status, "completed"); assert.equal(completed?.terminal, true); + assert.equal(completed?.workflowId, `harnessrl-caserun-${started!.runId}`); + assert.equal(completed?.result.summary.artifactCount, 1); + assert.equal(completed?.result.summary.artifacts[0].path, "native-result.json"); assert.match(completed?.references.aggregate.sha256 ?? "", /^[a-f0-9]{64}$/); const aggregate = readNativeAggregate(started!.runId); @@ -27,7 +31,8 @@ test("native CaseRun fixtures expose six deterministic API-owned states", () => assert.equal(aggregate?.sha256, completed?.references.aggregate.sha256); const events = readNativeEvents(started!.runId); - assert.deepEqual(events?.events.map((event) => event.status), ["queued", "running", "completed"]); + assert.deepEqual(events?.events.map((event) => event.stage), ["queued", "prepare", "prepared", "build-completed", "collect-completed", "completed"]); + assert.equal(events?.events[0]?.createdAt, events?.events[0]?.at); assert.equal(events?.mode, "native-test"); }); diff --git a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts index 79ef62a1..605c53ed 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts +++ b/web/hwlab-cloud-web/scripts/caserun-native-fixtures.ts @@ -23,7 +23,14 @@ const BASE_TIME = Date.parse("2026-07-17T00:00:00.000Z"); const scenarios: Record = { "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-completed": { title: "原生已完成", steps: [ + { status: "queued", stage: "queued", terminal: false }, + { status: "running", stage: "prepare", terminal: false }, + { status: "running", stage: "prepared", terminal: false }, + { status: "running", stage: "build-completed", terminal: false }, + { status: "running", stage: "collect-completed", 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 }] } @@ -38,15 +45,23 @@ function timestamp(index: number): string { function aggregateResponse(run: NativeRunState) { const step = run.steps[run.cursor] ?? run.steps.at(-1)!; + const artifact = { + path: "native-result.json", + bytes: 335, + sha256: createHash("sha256").update(`${run.runId}:native-result.json`).digest("hex") + }; const summary = { + mode: MODE, status: step.status, jobId: `job-${run.runId}`, - artifactCount: step.status === "completed" ? 2 : 0 + artifacts: step.status === "completed" ? [artifact] : [], + artifactCount: step.status === "completed" ? 1 : 0 }; const evidence = { + mode: MODE, status: step.status, blocker: step.blocker ?? null, - artifacts: step.status === "completed" ? [{ name: "firmware.bin" }, { name: "caserun.log" }] : [] + artifacts: step.status === "completed" ? [artifact] : [] }; const archivedRun = { runId: run.runId, @@ -54,7 +69,8 @@ function aggregateResponse(run: NativeRunState) { status: step.status, stage: step.stage, terminal: step.terminal, - mode: MODE + mode: MODE, + hardwareRequired: false }; const source = { runId: run.runId, caseId: run.caseId, status: step.status, summary, evidence, run: archivedRun }; return { @@ -68,6 +84,7 @@ function aggregateResponse(run: NativeRunState) { terminal: step.terminal, sourceAuthority: { mode: MODE, sequence: "request-driven" }, sha256: createHash("sha256").update(JSON.stringify(source)).digest("hex"), + result: { run: archivedRun, summary, evidence }, summary, evidence, run: archivedRun @@ -82,6 +99,8 @@ function runResponse(run: NativeRunState) { contractVersion: CONTRACT_VERSION, runId: run.runId, caseId: run.caseId, + workflowId: `harnessrl-caserun-${run.runId}`, + workflowRunId: `native-workflow-${run.runId}`, status: step.status, stage: step.stage, terminal: step.terminal, @@ -90,9 +109,10 @@ function runResponse(run: NativeRunState) { updatedAt: timestamp(run.cursor), facts: { jobId: `job-${run.runId}`, - artifactCount: step.status === "completed" ? 2 : 0, + artifactCount: step.status === "completed" ? 1 : 0, evidenceStatus: step.status }, + result: aggregateResponse(run).result, references: { status: { href: `/v1/caserun/runs/${run.runId}` }, events: { href: `/v1/caserun/runs/${run.runId}/events`, count: run.cursor + 1 }, @@ -142,7 +162,9 @@ export function readNativeEvents(runId: string) { if (!run) return null; const current = runResponse(run); const events = run.steps.slice(0, run.cursor + 1).map((step, index) => ({ + id: index + 1, at: timestamp(index), + createdAt: timestamp(index), status: step.status, stage: step.stage, payload: { mode: MODE, terminal: step.terminal, blocker: step.blocker ?? null } diff --git a/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs b/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs index 4c7444d5..3c0c9ebf 100644 --- a/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs +++ b/web/hwlab-cloud-web/scripts/caserun-native-web-probe.mjs @@ -9,23 +9,29 @@ export default async function caserunNativeSmoke({ page, goto, wait, screenshot, 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 evidenceStatus = (await page.locator("#caserun-evidence-status").textContent())?.trim() || ""; + await page.getByRole("tab", { name: "产物" }).click(); + const artifactText = (await page.locator(".caserun-artifact-row").textContent()) || ""; + await page.getByRole("tab", { name: "编排" }).click(); + const workflowId = (await page.locator("#caserun-workflow-id").textContent())?.trim() || ""; + await page.getByRole("tab", { name: "结果" }).click(); const conditions = { 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, - runningEvent: events?.includes("running") === true, - completedEvent: events?.includes("completed") === true + evidenceStatus: evidenceStatus === "已完成", + artifact: artifactText.includes("native-result.json") && artifactText.includes("335 B"), + workflow: workflowId === `harnessrl-caserun-${runId}`, + queuedEvent: events?.includes("进入队列") === true, + buildEvent: events?.includes("构建完成") === true, + completedEvent: events?.includes("运行完成") === true }; const image = await screenshot("caserun-native-completed.png"); recordStep("caserun-native-completed", { ok: Object.values(conditions).every(Boolean), conditions }); diff --git a/web/hwlab-cloud-web/src/stores/caserun.ts b/web/hwlab-cloud-web/src/stores/caserun.ts index ab609a98..71f25f16 100644 --- a/web/hwlab-cloud-web/src/stores/caserun.ts +++ b/web/hwlab-cloud-web/src/stores/caserun.ts @@ -19,7 +19,7 @@ export const useCaseRunStore = defineStore("caserun", () => { error.value = null; const result = await caserunAPI.cases(); cases.value = result.data?.cases ?? []; - error.value = result.ok ? null : result.error ?? "CaseRun cases unavailable"; + error.value = result.ok ? null : result.error ?? "无法读取 CaseRun 用例"; loading.value = false; } @@ -31,7 +31,7 @@ export const useCaseRunStore = defineStore("caserun", () => { const result = await caserunAPI.startRun(caseId); starting.value = false; if (!result.ok || !result.data) { - error.value = result.error ?? "CaseRun start failed"; + error.value = result.error ?? "CaseRun 启动失败"; return null; } currentRun.value = result.data; @@ -43,7 +43,7 @@ export const useCaseRunStore = defineStore("caserun", () => { if (!runId) return; const result = await caserunAPI.run(runId); if (!result.ok || !result.data) { - error.value = result.error ?? "CaseRun run unavailable"; + error.value = result.error ?? "无法读取 CaseRun 运行"; return; } currentRun.value = result.data; diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index a7ddd0ab..4bfd5a1d 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -517,12 +517,14 @@ export interface HwlabNodeUpdateMetadata { } export interface CaseRunCaseSummary { caseId: string; title?: string; mode?: string; available?: boolean; hwpodSpec?: string; subject?: Record | null; expected?: Record | null; runtime?: Record | null; [key: string]: unknown } export interface CaseRunCasesResponse { ok?: boolean; mode?: string; contractVersion?: string; count?: number; cases?: CaseRunCaseSummary[]; caseAuthority?: Record; [key: string]: unknown } -export interface CaseRunEvent { at?: string; status?: string; stage?: string; payload?: Record; [key: string]: unknown } +export interface CaseRunEvent { id?: number; at?: string; createdAt?: string; status?: string; stage?: string; payload?: Record; [key: string]: unknown } export interface CaseRunBlocker { code: string; summary: string; layer?: string | null; details?: Record | null } export interface CaseRunReference { href?: string; ref?: string | null; sha256?: string | null; count?: number; relationship?: Record | null; [key: string]: unknown } -export interface CaseRunRunResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; blocker?: CaseRunBlocker | null; createdAt?: string; updatedAt?: string; facts?: { jobId?: string | null; artifactCount?: number; evidenceStatus?: string | null }; references?: { status?: CaseRunReference; events?: CaseRunReference; aggregate?: CaseRunReference; manifest?: CaseRunReference | null; replay?: CaseRunReference | null; trace?: Record | null; hwpod?: Record | null }; sourceAuthority?: Record; error?: { code?: string; message?: string } | null; statusUrl?: string; [key: string]: unknown } +export interface CaseRunArtifact { path?: string; name?: string; bytes?: number; sha256?: string; [key: string]: unknown } +export interface CaseRunResult { run?: { mode?: string; hardwareRequired?: boolean; [key: string]: unknown } | null; summary?: { mode?: string; status?: string; artifactCount?: number; artifacts?: CaseRunArtifact[]; [key: string]: unknown } | null; evidence?: { mode?: string; status?: string; artifacts?: CaseRunArtifact[]; [key: string]: unknown } | null; [key: string]: unknown } +export interface CaseRunRunResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; workflowId?: string; workflowRunId?: string; status?: string; stage?: string; terminal?: boolean; blocker?: CaseRunBlocker | null; createdAt?: string; updatedAt?: string; caseRepo?: string; runDir?: string; result?: CaseRunResult | null; facts?: { jobId?: string | null; artifactCount?: number; evidenceStatus?: string | null }; references?: { status?: CaseRunReference; events?: CaseRunReference; aggregate?: CaseRunReference; manifest?: CaseRunReference | null; replay?: CaseRunReference | null; trace?: Record | null; hwpod?: Record | null }; sourceAuthority?: Record; error?: { code?: string; message?: string } | null; statusUrl?: string; [key: string]: unknown } export interface CaseRunEventsResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record; events?: CaseRunEvent[]; [key: string]: unknown } -export interface CaseRunAggregateResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record; sha256?: string; summary?: Record | null; evidence?: Record | null; run?: Record | null; [key: string]: unknown } +export interface CaseRunAggregateResponse { ok?: boolean; mode?: string; contractVersion?: string; runId?: string; caseId?: string; status?: string; stage?: string; terminal?: boolean; sourceAuthority?: Record; sha256?: string | null; result?: CaseRunResult | null; summary?: CaseRunResult["summary"]; evidence?: CaseRunResult["evidence"]; run?: CaseRunResult["run"]; [key: string]: unknown } export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array>; [key: string]: unknown } export interface ProviderProfileCatalogItem { profile: string; diff --git a/web/hwlab-cloud-web/src/views/caserun/CaseRunView.vue b/web/hwlab-cloud-web/src/views/caserun/CaseRunView.vue index 74d25d52..e164d1c4 100644 --- a/web/hwlab-cloud-web/src/views/caserun/CaseRunView.vue +++ b/web/hwlab-cloud-web/src/views/caserun/CaseRunView.vue @@ -6,10 +6,14 @@ import { Activity, Archive, Check, + CheckCircle2, + Clock3, Clipboard, Cpu, ExternalLink, + FileCheck2, FileCode2, + GitBranch, RefreshCw, ShieldAlert, TerminalSquare, @@ -19,17 +23,17 @@ 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"; +import type { CaseRunArtifact, CaseRunCaseSummary, CaseRunEvent } from "@/types"; const route = useRoute(); const router = useRouter(); const caserun = useCaseRunStore(); const selectedCaseId = ref(""); -type EvidenceTab = "aggregate" | "artifacts" | "trace" | "hwpod"; +type EvidenceTab = "aggregate" | "artifacts" | "workflow" | "hwpod"; const evidenceTabs: Array<{ id: EvidenceTab; label: string }> = [ - { id: "aggregate", label: "汇总" }, + { id: "aggregate", label: "结果" }, { id: "artifacts", label: "产物" }, - { id: "trace", label: "链路" }, + { id: "workflow", label: "编排" }, { id: "hwpod", label: "HWPOD" }, ]; const selectedTab = ref("aggregate"); @@ -46,25 +50,28 @@ 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 resultSummary = computed(() => aggregate.value?.result?.summary ?? aggregate.value?.summary ?? currentRun.value?.result?.summary ?? null); +const resultEvidence = computed(() => aggregate.value?.result?.evidence ?? aggregate.value?.evidence ?? currentRun.value?.result?.evidence ?? null); +const resultRun = computed(() => aggregate.value?.result?.run ?? aggregate.value?.run ?? currentRun.value?.result?.run ?? null); +const artifacts = computed(() => resultSummary.value?.artifacts ?? resultEvidence.value?.artifacts ?? []); +const artifactCount = computed(() => resultSummary.value?.artifactCount ?? currentRun.value?.facts?.artifactCount ?? artifacts.value.length); +const evidenceStatus = computed(() => String(resultEvidence.value?.status ?? currentRun.value?.facts?.evidenceStatus ?? "pending")); +const duration = computed(() => formatDuration(currentRun.value?.createdAt, currentRun.value?.updatedAt)); 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 fixtureMode = computed(() => String(resultSummary.value?.mode ?? resultRun.value?.mode ?? 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(() => [ - { 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: "duration", label: "运行耗时", value: duration.value, tone: "neutral" }, { id: "events", label: "事件", value: currentEvents.value.length, tone: "neutral" }, - { id: "artifacts", label: "产物", value: currentRun.value?.facts?.artifactCount ?? 0, tone: "neutral" }, + { id: "artifacts", label: "产物", value: artifactCount.value, tone: "neutral" }, + { id: "evidence", label: "证据", value: statusLabel(evidenceStatus.value), tone: evidenceStatus.value === "recorded" ? "ok" : "neutral" }, ]); onMounted(async () => { @@ -72,7 +79,10 @@ onMounted(async () => { 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); + if (runId.value) { + await caserun.refreshRun(runId.value); + if (caserun.currentRun?.caseId) selectedCaseId.value = caserun.currentRun.caseId; + } pollTimer = setInterval(() => { if (caserun.shouldPoll) void caserun.refreshRun(); }, 5000); @@ -89,7 +99,7 @@ watch(() => route.params.runId, async (value) => { watch(() => route.query.tab, (value) => { selectedTab.value = parseTab(value); }); function parseTab(value: unknown): EvidenceTab { - return value === "artifacts" || value === "trace" || value === "hwpod" ? value : "aggregate"; + return value === "artifacts" || value === "workflow" || value === "hwpod" ? value : "aggregate"; } async function startRun(): Promise { @@ -121,14 +131,24 @@ function eventLabel(event: CaseRunEvent): string { return `${stageLabel(event.stage)} · ${statusLabel(event.status)}`; } +function eventDetail(event: CaseRunEvent): string { + const payload = event.payload ?? {}; + if (typeof payload.artifactSha256 === "string") return `产物 SHA256 · ${payload.artifactSha256}`; + if (typeof payload.worker === "string") return `执行者 · ${payload.worker}`; + if (typeof payload.mode === "string") return `运行模式 · ${modeLabel(payload.mode)}`; + if (typeof payload.caseId === "string") return `用例 · ${payload.caseId}`; + const entries = Object.entries(payload).filter(([, value]) => value !== null && value !== undefined && typeof value !== "object"); + return entries.length > 0 ? entries.map(([key, value]) => `${key}=${String(value)}`).join(" · ") : "阶段事实已记录"; +} + 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)[code] ?? code; + return ({ idle: "空闲", pending: "等待中", queued: "已排队", running: "运行中", completed: "已完成", recorded: "已记录", failed: "失败", blocked: "受阻", canceled: "已取消", cancelled: "已取消", cancel_requested: "正在取消", unknown: "未知" } as Record)[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)[code] ?? code; + return ({ "pending-selection": "等待选择用例", queued: "进入队列", prepare: "准备运行", preparing: "准备运行", prepared: "准备完成", "agent-running": "代理执行", building: "构建中", "build-completed": "构建完成", downloading: "下载", "uart-reading": "串口读取", "collect-completed": "证据归集", aggregating: "结果汇总", completed: "运行完成", failed: "运行失败", blocked: "运行受阻", canceled: "已取消", cancelled: "已取消", unknown: "未知阶段" } as Record)[code] ?? code; } function modeLabel(value: string): string { @@ -143,6 +163,25 @@ function eventTime(value?: string): string { const date = new Date(value); return Number.isNaN(date.getTime()) ? "--:--:--" : date.toLocaleTimeString("zh-CN", { hour12: false }); } + +function formatDuration(start?: string, end?: string): string { + if (!start || !end) return "-"; + const elapsed = new Date(end).getTime() - new Date(start).getTime(); + if (!Number.isFinite(elapsed) || elapsed < 0) return "-"; + if (elapsed < 10_000) return `${(elapsed / 1000).toFixed(1)} 秒`; + if (elapsed < 60_000) return `${Math.round(elapsed / 1000)} 秒`; + return `${Math.floor(elapsed / 60_000)} 分 ${Math.round((elapsed % 60_000) / 1000)} 秒`; +} + +function artifactName(artifact: CaseRunArtifact): string { + return artifact.path || artifact.name || "未命名产物"; +} + +function artifactSize(bytes?: number): string { + if (!Number.isFinite(bytes)) return "大小未知"; + if (Number(bytes) < 1024) return `${bytes} B`; + return `${(Number(bytes) / 1024).toFixed(1)} KiB`; +}