2bc5fc653a
Pipelines as Code CI / hwlab-nc01-v03-ci-poll- Success
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
390 lines
30 KiB
Vue
390 lines
30 KiB
Vue
<!-- 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>
|