feat: 完善 CaseRun 原生证据视图
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
root
2026-07-18 06:01:58 +02:00
parent e64eb95cd4
commit 2bc5fc653a
7 changed files with 255 additions and 47 deletions
+101
View File
@@ -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 和 BlockerTrace、原始事件和 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 或原始数据库访问暴露给浏览器。
@@ -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");
});
@@ -23,7 +23,14 @@ const BASE_TIME = Date.parse("2026-07-17T00:00:00.000Z");
const scenarios: Record<string, { title: string; steps: NativeStep[] }> = {
"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 }
@@ -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 });
+3 -3
View File
@@ -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;
+5 -3
View File
@@ -517,12 +517,14 @@ export interface HwlabNodeUpdateMetadata {
}
export interface CaseRunCaseSummary { caseId: string; title?: string; mode?: string; available?: boolean; hwpodSpec?: string; subject?: Record<string, unknown> | null; expected?: Record<string, unknown> | null; runtime?: Record<string, unknown> | null; [key: string]: unknown }
export interface CaseRunCasesResponse { ok?: boolean; mode?: string; contractVersion?: string; count?: number; cases?: CaseRunCaseSummary[]; caseAuthority?: Record<string, unknown>; [key: string]: unknown }
export interface CaseRunEvent { at?: string; status?: string; stage?: string; payload?: Record<string, unknown>; [key: string]: unknown }
export interface CaseRunEvent { id?: number; at?: string; createdAt?: string; status?: string; stage?: string; payload?: Record<string, unknown>; [key: string]: unknown }
export interface CaseRunBlocker { code: string; summary: string; layer?: string | null; details?: Record<string, unknown> | null }
export interface CaseRunReference { href?: string; ref?: string | null; sha256?: string | null; count?: number; relationship?: Record<string, unknown> | 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<string, unknown> | null; hwpod?: Record<string, unknown> | null }; sourceAuthority?: Record<string, unknown>; 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<string, unknown> | null; hwpod?: Record<string, unknown> | null }; sourceAuthority?: Record<string, unknown>; 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<string, unknown>; 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<string, unknown>; sha256?: string; summary?: Record<string, unknown> | null; evidence?: Record<string, unknown> | null; run?: Record<string, unknown> | 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<string, unknown>; 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<Record<string, unknown>>; [key: string]: unknown }
export interface ProviderProfileCatalogItem {
profile: string;
@@ -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<EvidenceTab>("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<CaseRunArtifact[]>(() => 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<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: "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<void> {
@@ -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<string, string>)[code] ?? code;
return ({ idle: "空闲", pending: "等待中", queued: "已排队", running: "运行中", completed: "已完成", recorded: "已记录", 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;
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<string, string>)[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`;
}
</script>
<template>
@@ -191,18 +230,23 @@ function eventTime(value?: string): string {
<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>
<div><dt>模式</dt><dd>{{ modeLabel(fixtureMode) }}</dd></div>
<div><dt>耗时</dt><dd><Clock3 :size="12" aria-hidden="true" />{{ duration }}</dd></div>
<div><dt>证据</dt><dd>{{ statusLabel(evidenceStatus) }}</dd></div>
<div><dt>产物</dt><dd>{{ artifactCount }} </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 class="section-heading"><div><span class="caserun-kicker">实际路径</span><h3>服务端阶段</h3></div><span class="timeline-note">{{ currentEvents.length }} 个事实节点 · {{ duration }}</span></div>
<div v-if="currentEvents.length" class="stage-track" :style="{ gridTemplateColumns: `repeat(${currentEvents.length}, minmax(96px, 1fr))` }">
<div v-for="(event, index) in currentEvents" :key="event.id ?? `${event.createdAt || event.at}-${index}`" class="stage-node reached" :class="{ active: index === currentEvents.length - 1 }">
<span class="stage-dot"><Check :size="12" aria-hidden="true" /></span>
<span>{{ stageLabel(event.stage) }}</span>
<time>{{ eventTime(event.createdAt || event.at) }}</time>
</div>
</div>
<div v-else class="timeline-empty">运行事件到达后将按 API 返回顺序显示真实执行路径</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>
@@ -211,7 +255,7 @@ function eventTime(value?: string): string {
<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>
<li v-for="(event, index) in currentEvents" :key="event.id ?? `${event.createdAt || event.at}-${index}`"><time>{{ eventTime(event.createdAt || event.at) }}</time><span class="event-dot" /><div><strong>{{ eventLabel(event) }}</strong><small>{{ eventDetail(event) }}</small></div></li>
</ol>
</section>
</main>
@@ -223,12 +267,40 @@ function eventTime(value?: string): string {
</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>
<div class="result-banner" :class="{ complete: terminal }">
<CheckCircle2 :size="22" aria-hidden="true" />
<div><span class="state-label">运行结果</span><strong id="caserun-aggregate-status">{{ statusLabel(aggregate?.status || currentRun?.status || "pending") }}</strong><small id="caserun-aggregate-run-id">{{ aggregate?.runId || currentRun?.runId || "等待 CaseRun" }}</small></div>
</div>
<dl class="evidence-facts result-facts">
<div><dt>运行模式</dt><dd>{{ modeLabel(fixtureMode) }}</dd></div>
<div><dt>执行耗时</dt><dd>{{ duration }}</dd></div>
<div><dt>证据状态</dt><dd id="caserun-evidence-status">{{ statusLabel(evidenceStatus) }}</dd></div>
<div><dt>产物数量</dt><dd>{{ artifactCount }} </dd></div>
<div><dt>需要硬件</dt><dd>{{ resultRun?.hardwareRequired === false ? "否" : resultRun?.hardwareRequired === true ? "是" : "-" }}</dd></div>
</dl>
<div v-if="aggregate?.sha256" 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>
</template>
<template v-else-if="selectedTab === 'artifacts'">
<div class="evidence-section-heading"><span class="state-label">产物清单</span><strong>{{ artifactCount }} </strong></div>
<div v-if="artifacts.length" class="artifact-list">
<article v-for="artifact in artifacts" :key="artifactName(artifact)" class="artifact-row caserun-artifact-row">
<FileCheck2 :size="17" aria-hidden="true" />
<div><strong>{{ artifactName(artifact) }}</strong><small>{{ artifactSize(artifact.bytes) }}</small><code>{{ artifact.sha256 || "未提供 SHA256" }}</code></div>
<button v-if="artifact.sha256" type="button" title="复制产物 SHA256" aria-label="复制产物 SHA256" @click="copy(artifact.sha256)"><Clipboard :size="14" aria-hidden="true" /></button>
</article>
</div>
<div v-else class="evidence-empty">当前运行尚未登记产物</div>
</template>
<template v-else-if="selectedTab === 'workflow'">
<div class="workflow-identity"><GitBranch :size="20" aria-hidden="true" /><div><span class="state-label">Temporal Workflow</span><strong id="caserun-workflow-id">{{ currentRun?.workflowId || "暂无 workflowId" }}</strong></div></div>
<dl class="identity-list">
<div><dt>workflowRunId</dt><dd>{{ currentRun?.workflowRunId || "-" }}</dd></div>
<div><dt>runId</dt><dd>{{ currentRun?.runId || "-" }}</dd></div>
<div><dt>创建时间</dt><dd>{{ currentRun?.createdAt || "-" }}</dd></div>
<div><dt>更新时间</dt><dd>{{ currentRun?.updatedAt || "-" }}</dd></div>
<div v-if="currentRun?.references?.trace?.traceId"><dt>traceId</dt><dd>{{ currentRun.references.trace.traceId }}</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>