Files
pikasTech-HWLAB/web/hwlab-cloud-web/src/views/caserun/CaseRunView.vue
T
root 2bc5fc653a
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
feat: 完善 CaseRun 原生证据视图
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-18 06:01:58 +02:00

390 lines
30 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!-- 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,
CheckCircle2,
Clock3,
Clipboard,
Cpu,
ExternalLink,
FileCheck2,
FileCode2,
GitBranch,
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 { CaseRunArtifact, CaseRunCaseSummary, CaseRunEvent } from "@/types";
const route = useRoute();
const router = useRouter();
const caserun = useCaseRunStore();
const selectedCaseId = ref("");
type EvidenceTab = "aggregate" | "artifacts" | "workflow" | "hwpod";
const evidenceTabs: Array<{ id: EvidenceTab; label: string }> = [
{ id: "aggregate", label: "结果" },
{ id: "artifacts", label: "产物" },
{ id: "workflow", 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 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(resultSummary.value?.mode ?? resultRun.value?.mode ?? aggregate.value?.mode ?? currentRun.value?.mode ?? selectedCase.value?.mode ?? "live"));
const fixtureModeLabel = computed(() => modeLabel(fixtureMode.value));
const statusItems = computed<ConsoleStatusItem[]>(() => [
{ 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: artifactCount.value, tone: "neutral" },
{ id: "evidence", label: "证据", value: statusLabel(evidenceStatus.value), tone: evidenceStatus.value === "recorded" ? "ok" : "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);
if (caserun.currentRun?.caseId) selectedCaseId.value = caserun.currentRun.caseId;
}
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 === "workflow" || 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 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: "已完成", 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: "进入队列", 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 {
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 });
}
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>
<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>{{ 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">{{ 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>
<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.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>
<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="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><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>