Merge pull request #1167 from pikasTech/fix/v03-r3-live-hwpod-parity-1153

R3: 补齐 v0.3 Live 与 HWPOD parity
This commit is contained in:
Lyon
2026-06-14 02:16:11 +08:00
committed by GitHub
9 changed files with 861 additions and 5 deletions
@@ -0,0 +1,52 @@
import assert from "node:assert/strict";
import test from "node:test";
import type { ApiResult, HwpodNodeOpsResponse, HwpodSpecsResponse, LiveProbePayload, LiveSurface } from "../src/types/index.ts";
import { summarizeBuilds, summarizeHwpodNodeOps, summarizeProbeRows } from "../src/stores/workbench-live.ts";
test("R3 probe summary exposes React WorkbenchProbe rows", () => {
const summary = summarizeProbeRows(liveSurface());
assert.equal(summary.rows.length, 4);
assert.equal(summary.okCount, 4);
assert.equal(summary.tone, "ok");
assert.ok(summary.rows.some((row) => row.path === "/health/live" && row.detail.includes("revision=c0cbdbb38d")));
});
test("R3 build summary maps live-builds services instead of raw JSON", () => {
const summary = summarizeBuilds(liveSurface());
assert.equal(summary.rows.length, 2);
assert.equal(summary.tone, "warn");
assert.match(summary.countsText ?? "", /服务 2 个/);
assert.ok(summary.rows.some((row) => row.label === "hwlab-cloud-web" && row.reason?.includes("构建时间不可用")));
});
test("R3 HWPOD summary combines specs availability, node-ops and blocker stream", () => {
const summary = summarizeHwpodNodeOps({ live: liveSurface() });
assert.equal(summary.contractVersion, "hwpod-node-ops-v1");
assert.equal(summary.hwpodName, "d601-f103-v2");
assert.equal(summary.nodeId, "node-d601-f103-v2");
assert.equal(summary.supportedOpsCount, 3);
assert.equal(summary.connectedCount, 0);
assert.ok(summary.tiles.some((tile) => tile.label === "Contract"));
assert.ok(summary.tiles.some((tile) => tile.label === "Ops"));
assert.ok(summary.errors.some((error) => error.code === "hwpod_node_unavailable"));
assert.ok(summary.streamLines.some((line) => line.includes("op_01_workspace_ls workspace.ls hwpod_node_unavailable")));
});
function liveSurface(): LiveSurface {
const liveProbe = ok<LiveProbePayload>({ serviceId: "hwlab-cloud-api", environment: "v03", status: "ok", ready: true, revision: "c0cbdbb38de77f1ecc9d1222438a13294d81d4dd", observedAt: "2026-06-13T17:59:15.000Z" });
return {
healthLive: liveProbe,
health: ok({ serviceId: "hwlab-edge-proxy", status: "ok", ready: true, revision: "c0cbdbb38de77f1ecc9d1222438a13294d81d4dd" }),
restIndex: ok({ serviceId: "hwlab-cloud-api", status: "ok", ready: true, revision: "c0cbdbb38de77f1ecc9d1222438a13294d81d4dd" }),
adapter: ok({ serviceId: "hwlab-cloud-api", status: "ok", ready: true, revision: "c0cbdbb38de77f1ecc9d1222438a13294d81d4dd" }),
hwpodSpecs: ok<HwpodSpecsResponse>({ ok: true, status: "completed", count: 1, availableCount: 0, nodeOpsContractVersion: "hwpod-node-ops-v1", specs: [{ name: "d601-f103-v2", hwpodId: "d601-f103-v2", nodeId: "node-d601-f103-v2", workspacePath: "F:\\Work\\D601-HWLAB", source: { caseId: "d601-f103-v2-compile" }, availability: { ok: false, status: "blocked", checkedAt: "2026-06-13T17:59:15.785Z", results: [{ opId: "op_01_workspace_ls", op: "workspace.ls", ok: false, status: "blocked", blocker: { code: "hwpod_node_unavailable", layer: "hwpod-node", retryable: true, summary: "no outbound WebSocket hwpod-node is connected" } }] } }] }),
hwpodNodeOps: ok<HwpodNodeOpsResponse>({ ok: true, status: "ready", contractVersion: "hwpod-node-ops-v1", route: "/v1/hwpod-node-ops", supportedOps: ["node.health", "workspace.ls", "debug.build"], websocket: { connectedCount: 0, pendingCount: 0, nodes: [] } }),
liveBuilds: ok({ status: "ok", counts: { total: 2, withBuildTime: 0, unavailable: 2, external: 0 }, services: [{ serviceId: "hwlab-cloud-api", status: "build_time_unavailable", build: { createdAt: null, unavailableReason: "构建时间不可用:live tag 与 deploy metadata 不匹配", metadataSource: "live-health" }, image: { tag: "env-abc", digest: "sha256:abcdef0123456789" }, commit: { id: "c0cbdbb38de77f1ecc9d1222438a13294d81d4dd" }, revision: "c0cbdbb38de77f1ecc9d1222438a13294d81d4dd" }, { serviceId: "hwlab-cloud-web", status: "build_time_unavailable", build: { createdAt: null, unavailableReason: "构建时间不可用:live tag 与 deploy metadata 不匹配" }, image: { tag: "env-web" }, commit: { id: "c0cbdbb38de77f1ecc9d1222438a13294d81d4dd" }, revision: "c0cbdbb38de77f1ecc9d1222438a13294d81d4dd" }] }),
loadedAt: "2026-06-13T18:00:00.000Z"
};
}
function ok<T>(data: T): ApiResult<T> {
return { ok: true, status: 200, data, error: null };
}
@@ -1,28 +1,100 @@
<script setup lang="ts">
import { onMounted } from "vue";
import { computed, nextTick, onMounted, ref, watch } from "vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import { useHwpodStore } from "@/stores/hwpod";
import { summarizeHwpodNodeOps, formatTimestamp, type HwpodDetail } from "@/stores/workbench-live";
import { useWorkbenchStore } from "@/stores/workbench";
const hwpod = useHwpodStore();
const workbench = useWorkbenchStore();
const selectedDetail = ref<HwpodDetail | null>(null);
const follow = ref(true);
const scrollRef = ref<HTMLDivElement | null>(null);
const summary = computed(() => summarizeHwpodNodeOps({ live: workbench.live, specs: hwpod.specs, nodeOps: hwpod.nodeOps, error: hwpod.error }));
onMounted(() => void hwpod.refresh());
watch(() => summary.value.streamLines.join("\n"), () => {
if (!follow.value) return;
void nextTick(() => jumpToBottom());
});
function jumpToBottom(): void {
const node = scrollRef.value;
if (!node) return;
node.scrollTop = node.scrollHeight;
}
function onStreamScroll(): void {
const node = scrollRef.value;
if (!node) return;
if (node.scrollHeight - node.clientHeight - node.scrollTop > 24) follow.value = false;
}
</script>
<template>
<aside class="hwpod-panel" id="hwpod-node-sidebar">
<aside class="hwpod-panel" id="hwpod-node-sidebar" aria-label="HWPOD node-ops 摘要看板">
<div class="panel-header compact">
<div>
<p class="page-eyebrow">HWPOD</p>
<h2>Node Ops</h2>
</div>
<StatusBadge :status="hwpod.nodeOps?.status || 'source'" />
<StatusBadge :status="summary.tone" :label="`node-ops ${summary.status}`" />
</div>
<div class="evidence-grid">
<div class="evidence-grid" aria-label="HWPOD evidence level">
<span>DEV-LIVE</span>
<span>SOURCE</span>
<span>LOCAL</span>
<span>DRY-RUN</span>
<span>BLOCKED</span>
</div>
<pre id="hwpod-event-scroll" class="mini-log">{{ JSON.stringify(hwpod.nodeOps || hwpod.specs || { status: 'empty' }, null, 2) }}</pre>
<section class="hwpod-node-status" aria-label="HWPOD node-ops status">
<div class="hwpod-node-summary" id="hwpod-node-summary">
<button v-for="tile in summary.tiles" :key="tile.key" class="summary-tile" type="button" :data-hwpod-detail="tile.key" :data-hwpod-title="tile.detail.title" @click="selectedDetail = tile.detail">
<span>{{ tile.label }}</span>
<strong>{{ tile.value }}</strong>
</button>
</div>
<dl class="hwpod-node-meta">
<div><dt>hwpod</dt><dd>{{ summary.hwpodName }}</dd></div>
<div><dt>node</dt><dd>{{ summary.nodeId }}</dd></div>
<div><dt>workspace</dt><dd>{{ summary.workspacePath }}</dd></div>
<div><dt>case</dt><dd>{{ summary.caseId }}</dd></div>
<div><dt>route</dt><dd>{{ summary.route }}</dd></div>
<div><dt>updated</dt><dd>{{ formatTimestamp(summary.updatedAt) }}</dd></div>
<div><dt>ws</dt><dd>{{ summary.connectedCount }} connected / {{ summary.pendingCount }} pending</dd></div>
<div><dt>supported ops</dt><dd>{{ summary.supportedOpsCount }}</dd></div>
</dl>
</section>
<section v-if="summary.errors.length" class="hwpod-errors" aria-label="HWPOD blockers">
<strong>阻塞聚合</strong>
<ul>
<li v-for="error in summary.errors" :key="error.key">
<code>{{ error.code }}</code>
<span>{{ error.message }}</span>
</li>
</ul>
</section>
<section class="hwpod-event-panel" aria-label="node-ops ">
<header>
<span>node-ops</span>
<div>
<button id="hwpod-event-follow" type="button" @click="follow = !follow">{{ follow ? '跟随' : '暂停' }}</button>
<button id="hwpod-event-jump" type="button" @click="follow = true; jumpToBottom()">跳到底部</button>
</div>
</header>
<div id="hwpod-event-scroll" ref="scrollRef" class="hwpod-event-scroll" @scroll="onStreamScroll">
<pre id="hwpod-event-text">{{ summary.streamLines.join('\n') || '等待 HWPOD node-ops。' }}</pre>
</div>
</section>
<div v-if="selectedDetail" class="workbench-dialog-backdrop" role="presentation" @click.self="selectedDetail = null">
<section id="hwpod-detail-dialog" class="workbench-dialog" role="dialog" aria-modal="true" aria-labelledby="hwpod-detail-title">
<header>
<strong id="hwpod-detail-title">{{ selectedDetail.title }}</strong>
<button type="button" class="dialog-close" aria-label="关闭" @click="selectedDetail = null">x</button>
</header>
<pre id="hwpod-detail-body">{{ selectedDetail.body }}</pre>
</section>
</div>
</aside>
</template>
@@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import type { LiveSurface } from "@/types";
import { formatTimestamp, summarizeBuilds } from "@/stores/workbench-live";
const props = defineProps<{ live: LiveSurface | null }>();
const open = ref(false);
const summary = computed(() => summarizeBuilds(props.live));
</script>
<template>
<section class="live-build-summary" data-live-build-summary>
<button id="live-build-toggle" class="status-chip-button" type="button" aria-haspopup="dialog" @click="open = true">
<span class="status-chip-label">最新镜像构建时间</span>
<StatusBadge :status="summary.tone" :label="summary.latest.builtAt ? formatTimestamp(summary.latest.builtAt) : '构建时间不可用'" />
<span class="status-chip-meta">{{ summary.latest.label }} · tag {{ summary.latest.tag || 'unknown' }} · commit {{ summary.latest.commitShort || 'unknown' }}</span>
</button>
<div v-if="open" class="workbench-dialog-backdrop" role="presentation" @click.self="open = false">
<section id="live-build-dialog" class="workbench-dialog wide" role="dialog" aria-modal="true" aria-labelledby="live-build-title">
<header>
<strong id="live-build-title">构建版本明细</strong>
<button type="button" class="dialog-close" aria-label="关闭" @click="open = false">x</button>
</header>
<div class="live-build-dialog-summary">
<p>{{ summary.latestText }}</p>
<p v-if="summary.countsText">{{ summary.countsText }}</p>
</div>
<ul class="live-build-list" role="list">
<li v-for="row in summary.rows" :key="row.key" class="live-build-row" :data-live-build-row="row.key">
<div class="live-build-row-head">
<strong>{{ row.label }}</strong>
<StatusBadge :status="row.status" :label="row.builtAt ? formatTimestamp(row.builtAt) : '构建时间不可用'" />
</div>
<div class="live-build-meta">
<span>tag {{ row.tag || 'unknown' }}</span>
<span>env {{ row.envImageTag || 'unknown' }}</span>
<span>commit {{ row.actualCommitShort || row.commitShort || 'unknown' }}</span>
<span>revision {{ row.revisionShort || 'unknown' }}</span>
<span v-if="row.digestShort">digest {{ row.digestShort }}</span>
<span v-if="row.source">来源 {{ row.source }}</span>
<span v-if="row.rollout">rollout {{ row.rollout }}</span>
</div>
<p v-if="row.reason" class="live-build-reason">{{ row.reason }}</p>
</li>
</ul>
</section>
</div>
</section>
</template>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed, ref } from "vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import type { LiveSurface } from "@/types";
import { formatTimestamp, summarizeProbeRows } from "@/stores/workbench-live";
const props = defineProps<{ live: LiveSurface | null }>();
const open = ref(false);
const summary = computed(() => summarizeProbeRows(props.live));
</script>
<template>
<section class="probe-card" data-probe-card>
<button class="status-chip-button" type="button" aria-haspopup="dialog" @click="open = true">
<span class="status-chip-label">工作区状态</span>
<StatusBadge :status="summary.tone" :label="summary.tone === 'ok' ? '健康' : summary.tone === 'blocked' ? '阻塞' : '等待验证'" />
<span class="status-chip-meta">{{ summary.okCount }}/{{ summary.rows.length }} · {{ formatTimestamp(summary.checkedAt) }}</span>
</button>
<div v-if="open" class="workbench-dialog-backdrop" role="presentation" @click.self="open = false">
<section id="probe-status-dialog" class="workbench-dialog" role="dialog" aria-modal="true" aria-labelledby="probe-status-title">
<header>
<strong id="probe-status-title">工作区状态明细</strong>
<button type="button" class="dialog-close" aria-label="关闭" @click="open = false">x</button>
</header>
<ul class="probe-list" role="list">
<li v-for="row in summary.rows" :key="row.key" class="probe-row" :data-probe-row="row.key">
<div class="probe-row-head">
<span class="probe-row-label">{{ row.label }}</span>
<StatusBadge :status="row.status" :label="row.status === 'ok' ? '通过' : row.status === 'blocked' ? '阻塞' : '等待'" />
</div>
<div class="probe-row-meta">
<span>{{ row.method }}</span>
<code>{{ row.path }}</code>
<span>{{ formatTimestamp(row.lastCheckedAt) }}</span>
</div>
<p>{{ row.detail }}</p>
</li>
</ul>
</section>
</div>
</section>
</template>
@@ -0,0 +1,349 @@
import type { ApiResult, HwpodNodeOpsResponse, HwpodSpecsResponse, LiveProbePayload, LiveSurface, Tone } from "@/types";
import { asRecord, firstNonEmptyString, statusTone } from "@/utils";
export interface ProbeRow {
key: string;
label: string;
method: string;
path: string;
status: Tone;
detail: string;
lastCheckedAt: string | null;
}
export interface ProbeSummary {
rows: ProbeRow[];
tone: Tone;
okCount: number;
checkedAt: string | null;
}
export interface BuildRow {
key: string;
label: string;
tag: string | null;
envImageTag: string | null;
commitShort: string | null;
actualCommitShort: string | null;
envCommitShort: string | null;
revisionShort: string | null;
digestShort: string | null;
builtAt: string | null;
status: Tone;
reason: string | null;
source: string | null;
rollout: string | null;
}
export interface BuildSummary {
rows: BuildRow[];
latest: BuildRow;
tone: Tone;
latestText: string;
countsText: string | null;
}
export interface HwpodDetail {
key: string;
title: string;
body: string;
}
export interface HwpodTile {
key: string;
label: string;
value: string;
detail: HwpodDetail;
tone: Tone;
}
export interface HwpodErrorSummary {
key: string;
code: string;
layer: string | null;
message: string;
retryable: boolean | null;
}
export interface HwpodNodeOpsSummary {
status: string;
tone: Tone;
contractVersion: string;
hwpodName: string;
nodeId: string;
workspacePath: string;
caseId: string;
route: string;
updatedAt: string | null;
specsCount: number;
availableSpecsCount: number;
supportedOpsCount: number;
connectedCount: number;
pendingCount: number;
tiles: HwpodTile[];
details: HwpodDetail[];
errors: HwpodErrorSummary[];
streamLines: string[];
}
const PROBE_DEFINITIONS = Object.freeze([
{ key: "cloud-api-live", label: "Cloud API 实况身份", method: "GET", path: "/health/live" },
{ key: "cloud-api-health", label: "Cloud API health", method: "GET", path: "/health" },
{ key: "rest-index", label: "REST index", method: "GET", path: "/v1" },
{ key: "adapter", label: "Codex app-server adapter", method: "POST", path: "/v1/rpc/system.health" }
]);
export function summarizeProbeRows(live: LiveSurface | null): ProbeSummary {
const results = [live?.healthLive, live?.health, live?.restIndex, live?.adapter];
const rows = PROBE_DEFINITIONS.map((definition, index) => rowFromProbe(definition, results[index] ?? null));
const okCount = rows.filter((row) => row.status === "ok").length;
const tone: Tone = okCount === rows.length ? "ok" : rows.some((row) => row.status === "blocked" || row.status === "error") ? "blocked" : "pending";
return { rows, tone, okCount, checkedAt: live?.loadedAt ?? null };
}
export function summarizeBuilds(live: LiveSurface | null): BuildSummary {
const payload = asRecord(live?.liveBuilds.data);
const services = recordArray(payload?.services).concat(recordArray(payload?.builds));
const rows = services.length > 0 ? services.map(rowFromLiveBuildRecord) : [rowFromLiveHealth(live?.healthLive.data ?? null)];
const explicitLatest = asRecord(payload?.latest);
const latest = explicitLatest ? rowFromLiveBuildRecord(explicitLatest) : rows.find((row) => row.builtAt) ?? rows[0] ?? emptyBuildRow();
const tone: Tone = rows.some((row) => row.status === "ok") ? "ok" : rows.some((row) => row.status === "blocked" || row.status === "error") ? "blocked" : rows.some((row) => row.status === "warn") ? "warn" : "pending";
return { rows, latest, tone, latestText: latestBuildText(latest), countsText: countsText(asRecord(payload?.counts)) };
}
export function summarizeHwpodNodeOps(input: { live?: LiveSurface | null; specs?: HwpodSpecsResponse | null; nodeOps?: HwpodNodeOpsResponse | null; error?: string | null }): HwpodNodeOpsSummary {
const specPayload = input.live?.hwpodSpecs.data ?? input.specs ?? null;
const nodePayload = input.live?.hwpodNodeOps.data ?? input.nodeOps ?? null;
const specs = recordArray(specPayload?.specs);
const selectedSpec = specs.find((item) => asRecord(item.availability)?.ok === true) ?? specs[0] ?? null;
const availability = asRecord(selectedSpec?.availability);
const specResults = recordArray(availability?.results);
const nodeResults = recordArray(asRecord(nodePayload)?.results);
const results = specResults.length > 0 ? specResults : nodeResults;
const websocket = asRecord(asRecord(nodePayload)?.websocket);
const source = asRecord(selectedSpec?.source);
const workspace = asRecord(selectedSpec?.workspace);
const nodeBinding = asRecord(selectedSpec?.nodeBinding);
const status = firstNonEmptyString(availability?.status, nodePayload?.status, input.live?.hwpodNodeOps.ok ? "ready" : null, input.error ? "blocked" : null) ?? "unknown";
const contractVersion = firstNonEmptyString(nodePayload?.contractVersion, specPayload?.nodeOpsContractVersion, "hwpod-node-ops-v1") ?? "hwpod-node-ops-v1";
const hwpodName = firstNonEmptyString(selectedSpec?.name, selectedSpec?.hwpodId, nodePayload?.hwpodId) ?? "未发现";
const nodeId = firstNonEmptyString(selectedSpec?.nodeId, nodeBinding?.nodeId, nodePayload?.nodeId, asRecord(nodePayload?.requestMeta)?.nodeId) ?? "未发现";
const workspacePath = firstNonEmptyString(selectedSpec?.workspacePath, workspace?.path) ?? "未发现";
const caseId = firstNonEmptyString(source?.caseId) ?? "未声明";
const route = firstNonEmptyString(nodePayload?.route, availability?.nodeOpsRoute, "/v1/hwpod-node-ops") ?? "/v1/hwpod-node-ops";
const updatedAt = firstNonEmptyString(nodePayload?.observedAt, specPayload?.observedAt, availability?.checkedAt, input.live?.loadedAt);
const supportedOpsCount = recordArray(nodePayload?.supportedOps).length || stringArray(nodePayload?.supportedOps).length;
const connectedCount = numberFrom(websocket?.connectedCount, 0);
const pendingCount = numberFrom(websocket?.pendingCount, 0);
const specsCount = numberFrom(specPayload?.count, specs.length);
const availableSpecsCount = numberFrom(specPayload?.availableCount, specs.filter((item) => asRecord(item.availability)?.ok === true).length);
const errors = collectHwpodErrors(input, availability, results);
const tone: Tone = errors.length > 0 || statusTone(status) === "blocked" || statusTone(status) === "error" ? "blocked" : statusTone(status) as Tone;
const details = hwpodDetails({ specPayload, selectedSpec, nodePayload, results });
const tiles = hwpodTiles({ contractVersion, specsCount, availableSpecsCount, hwpodName, nodeId, supportedOpsCount, results, details, errors, tone });
return { status, tone, contractVersion, hwpodName, nodeId, workspacePath, caseId, route, updatedAt, specsCount, availableSpecsCount, supportedOpsCount, connectedCount, pendingCount, tiles, details, errors, streamLines: hwpodStreamLines({ selectedSpec, results, errors, websocket, status, hwpodName, nodeId, workspacePath }) };
}
export function formatTimestamp(value: unknown): string {
const text = firstNonEmptyString(value);
if (!text) return "未观测";
const date = new Date(text);
if (!Number.isFinite(date.getTime())) return text;
return date.toLocaleString("zh-CN", { hour12: false, timeZone: "Asia/Shanghai" });
}
export function shortToken(value: unknown, size = 12): string | null {
const text = firstNonEmptyString(value);
if (!text) return null;
return text.length <= size ? text : `${text.slice(0, size)}...`;
}
export function jsonPreview(value: unknown, limit = 2200): string {
if (value === null || value === undefined) return "null";
const text = JSON.stringify(value, null, 2) ?? String(value);
return text.length <= limit ? text : `${text.slice(0, limit)}\n... truncated ${text.length - limit} chars`;
}
function rowFromProbe(definition: { key: string; label: string; method: string; path: string }, probe: ApiResult<LiveProbePayload> | null): ProbeRow {
const data = probe?.data ?? null;
const ok = Boolean(probe?.ok) && (data?.ready === true || data?.status === "ok" || data?.status === "ready" || data?.status === "healthy");
const status: Tone = ok ? "ok" : probe?.ok === false ? probe.status >= 500 || probe.status === 0 ? "blocked" : "warn" : "pending";
return { ...definition, status, detail: probeDetail(probe, data, definition.path), lastCheckedAt: firstNonEmptyString(data?.observedAt, asRecord(data?.readiness)?.observedAt) };
}
function probeDetail(probe: ApiResult<LiveProbePayload> | null, data: LiveProbePayload | null, path: string): string {
if (!probe) return `等待 ${path} 实时元数据`;
if (!probe.ok) return probe.error ?? `${path} HTTP ${probe.status}`;
if (!data) return `${path} 响应为空`;
const revision = shortToken(firstNonEmptyString(data.revision, data.commitId), 12);
const serviceId = firstNonEmptyString(data.serviceId, asRecord(data.service)?.id);
const ready = data.ready === true ? "ready=true" : "ready=false";
return `service=${serviceId ?? "unknown"}; env=${firstNonEmptyString(data.environment) ?? "unknown"}; ${ready}; revision=${revision ?? "unknown"}`;
}
function rowFromLiveBuildRecord(record: Record<string, unknown>): BuildRow {
const build = asRecord(record.build);
const image = asRecord(record.image);
const commit = asRecord(record.commit);
const desiredState = asRecord(record.desiredState);
const metadata = asRecord(asRecord(build?.liveMetadataMatch)?.metadata);
const serviceId = firstNonEmptyString(record.serviceId, record.name) ?? "unknown-service";
const actualCommit = firstNonEmptyString(commit?.id, record.revision);
const envCommit = firstNonEmptyString(metadata?.commitId, metadata?.revision);
const builtAt = firstNonEmptyString(build?.createdAt);
const reason = firstNonEmptyString(build?.unavailableReason, record.reason);
const tag = firstNonEmptyString(image?.tag);
return {
key: serviceId,
label: serviceId,
tag,
envImageTag: firstNonEmptyString(tag, envImageFromSource(build?.source)),
commitShort: shortToken(actualCommit, 12),
actualCommitShort: shortToken(actualCommit, 12),
envCommitShort: shortToken(envCommit, 12),
revisionShort: shortToken(record.revision, 12),
digestShort: shortToken(image?.digest, 18),
builtAt,
status: builtAt ? "ok" : reason ? "warn" : statusTone(firstNonEmptyString(record.status)) as Tone,
reason,
source: sourceLabel(firstNonEmptyString(build?.metadataSource), record.healthUrl),
rollout: rolloutLabel(desiredState)
};
}
function rowFromLiveHealth(payload: LiveProbePayload | null): BuildRow {
const service = asRecord(payload?.service);
const commit = asRecord(payload?.commit);
const image = asRecord(payload?.image);
const build = asRecord(payload?.build);
const serviceId = firstNonEmptyString(service?.id, payload?.serviceId) ?? "cloud-api";
const actualCommit = firstNonEmptyString(commit?.id, payload?.revision, payload?.commitId);
const tag = firstNonEmptyString(image?.tag);
const builtAt = firstNonEmptyString(build?.createdAt);
const reason = firstNonEmptyString(build?.unavailableReason);
return { key: serviceId, label: serviceId, tag, envImageTag: tag, commitShort: shortToken(actualCommit, 12), actualCommitShort: shortToken(actualCommit, 12), envCommitShort: null, revisionShort: shortToken(payload?.revision, 12), digestShort: shortToken(image?.digest, 18), builtAt, status: builtAt ? "ok" : reason ? "warn" : "pending", reason, source: sourceLabel(firstNonEmptyString(build?.metadataSource), "/health/live"), rollout: null };
}
function latestBuildText(row: BuildRow): string {
const time = row.builtAt ? `${formatTimestamp(row.builtAt)} 北京时间` : "构建时间不可用";
return `最新镜像构建时间:${time} · ${row.label} · tag ${row.tag ?? "unknown"} · commit ${row.commitShort ?? "unknown"} · env ${row.envImageTag ?? row.envCommitShort ?? "unknown"} · revision ${row.revisionShort ?? "unknown"}`;
}
function countsText(counts: Record<string, unknown> | null): string | null {
if (!counts) return null;
return `服务 ${counts.total ?? "unknown"} 个,可见构建时间 ${counts.withBuildTime ?? "unknown"} 个,缺失 ${counts.unavailable ?? "unknown"} 个,外部 ${counts.external ?? "unknown"}`;
}
function emptyBuildRow(): BuildRow {
return { key: "unknown", label: "unknown-service", tag: null, envImageTag: null, commitShort: null, actualCommitShort: null, envCommitShort: null, revisionShort: null, digestShort: null, builtAt: null, status: "pending", reason: null, source: null, rollout: null };
}
function hwpodDetails(input: { specPayload: HwpodSpecsResponse | null; selectedSpec: Record<string, unknown> | null; nodePayload: HwpodNodeOpsResponse | null; results: Record<string, unknown>[] }): HwpodDetail[] {
return [
{ key: "contract", title: "Contract", body: jsonPreview(input.nodePayload, 2200) },
{ key: "specs", title: "Specs", body: jsonPreview(input.specPayload, 2600) },
{ key: "spec", title: "Spec", body: jsonPreview(input.selectedSpec, 2600) },
{ key: "node", title: "Node", body: jsonPreview({ selectedSpec: input.selectedSpec, nodeOps: input.nodePayload }, 2600) },
{ key: "ops", title: "Ops", body: jsonPreview(input.results, 2200) },
{ key: "probe", title: "probe", body: jsonPreview({ hwpodSpecs: input.specPayload, hwpodNodeOps: input.nodePayload }, 2600) }
];
}
function hwpodTiles(input: { contractVersion: string; specsCount: number; availableSpecsCount: number; hwpodName: string; nodeId: string; supportedOpsCount: number; results: Record<string, unknown>[]; details: HwpodDetail[]; errors: HwpodErrorSummary[]; tone: Tone }): HwpodTile[] {
const fallbackDetail: HwpodDetail = input.details[0] ?? { key: "probe", title: "probe", body: "null" };
const detail = (key: string): HwpodDetail => input.details.find((item) => item.key === key) ?? fallbackDetail;
const tiles: HwpodTile[] = [
{ key: "contract", label: "Contract", value: input.contractVersion, detail: detail("contract"), tone: input.tone },
{ key: "specs", label: "Specs", value: `${input.availableSpecsCount}/${input.specsCount}`, detail: detail("specs"), tone: input.availableSpecsCount > 0 ? "ok" : "blocked" },
{ key: "spec", label: "Spec", value: input.hwpodName, detail: detail("spec"), tone: input.tone },
{ key: "node", label: "Node", value: input.nodeId, detail: detail("node"), tone: input.tone },
{ key: "ops", label: "Ops", value: String(input.results.length || input.supportedOpsCount), detail: detail("ops"), tone: input.errors.length ? "blocked" : "ok" }
];
if (input.results.length > 0) {
tiles.push(...input.results.map((item, index) => {
const blocker = asRecord(item.blocker);
const op = firstNonEmptyString(item.op, item.opId) ?? `op ${index + 1}`;
const value = item.ok === false ? firstNonEmptyString(blocker?.code, item.status, "blocked") ?? "blocked" : firstNonEmptyString(item.status, "completed") ?? "completed";
return { key: firstNonEmptyString(item.opId) ?? `op-${index + 1}`, label: op, value, tone: item.ok === false ? "blocked" : statusTone(value) as Tone, detail: { key: firstNonEmptyString(item.opId) ?? `op-${index + 1}`, title: op, body: jsonPreview(item, 1800) } };
}));
} else {
tiles.push({ key: "probe", label: "probe", value: input.errors[0]?.code ?? "等待结果", detail: detail("probe"), tone: input.errors.length ? "blocked" : "pending" });
}
return tiles;
}
function hwpodStreamLines(input: { selectedSpec: Record<string, unknown> | null; results: Record<string, unknown>[]; errors: HwpodErrorSummary[]; websocket: Record<string, unknown> | null; status: string; hwpodName: string; nodeId: string; workspacePath: string }): string[] {
const lines = [`spec ${input.hwpodName} ${input.nodeId} ${input.status} ${input.workspacePath}`.trim()];
for (const item of input.results) {
const blocker = asRecord(item.blocker);
const status = item.ok === false ? firstNonEmptyString(blocker?.code, item.status, "blocked") : firstNonEmptyString(item.status, "completed");
lines.push(`${firstNonEmptyString(item.opId, "op") ?? "op"} ${firstNonEmptyString(item.op, "unknown") ?? "unknown"} ${status ?? "observed"}`);
}
if (input.results.length === 0 && input.websocket) lines.push(`websocket connected=${numberFrom(input.websocket.connectedCount, 0)} pending=${numberFrom(input.websocket.pendingCount, 0)}`);
for (const error of input.errors) lines.push(`blocked ${error.code} ${error.message}`.trim());
return uniqueStrings(lines.filter(Boolean));
}
function collectHwpodErrors(input: { live?: LiveSurface | null; error?: string | null }, availability: Record<string, unknown> | null, results: Record<string, unknown>[]): HwpodErrorSummary[] {
const errors: HwpodErrorSummary[] = [];
addError(errors, input.error, "store_error");
addError(errors, input.live?.hwpodSpecs.error, "hwpod_specs_error");
addError(errors, input.live?.hwpodNodeOps.error, "hwpod_node_ops_error");
addBlocker(errors, asRecord(availability?.blocker));
for (const result of results) addBlocker(errors, asRecord(result.blocker));
return uniqueErrors(errors);
}
function addError(errors: HwpodErrorSummary[], message: unknown, code: string): void {
const text = firstNonEmptyString(message);
if (text) errors.push({ key: `${code}:${text}`, code, layer: null, message: text, retryable: null });
}
function addBlocker(errors: HwpodErrorSummary[], blocker: Record<string, unknown> | null): void {
if (!blocker) return;
const code = firstNonEmptyString(blocker.code, "blocked") ?? "blocked";
const message = firstNonEmptyString(blocker.userMessage, blocker.summary, blocker.message, code) ?? code;
errors.push({ key: `${code}:${message}`, code, layer: firstNonEmptyString(blocker.layer), message, retryable: typeof blocker.retryable === "boolean" ? blocker.retryable : null });
}
function uniqueErrors(errors: HwpodErrorSummary[]): HwpodErrorSummary[] {
const seen = new Set<string>();
return errors.filter((error) => {
if (seen.has(error.key)) return false;
seen.add(error.key);
return true;
});
}
function recordArray(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.map((item) => asRecord(item)).filter((item): item is Record<string, unknown> => Boolean(item)) : [];
}
function stringArray(value: unknown): string[] {
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
}
function uniqueStrings(values: string[]): string[] {
return Array.from(new Set(values));
}
function numberFrom(value: unknown, fallback: number): number {
const number = Number(value);
return Number.isFinite(number) ? number : fallback;
}
function envImageFromSource(value: unknown): string | null {
const text = firstNonEmptyString(value);
return text?.match(/@env:([^\s/]+)/u)?.[1] ?? null;
}
function sourceLabel(metadataSource: string | null, healthUrl: unknown): string | null {
const parts = [firstNonEmptyString(healthUrl) ? "live health" : null, metadataSource].filter(Boolean);
return parts.length ? parts.join(" / ") : null;
}
function rolloutLabel(desiredState: Record<string, unknown> | null): string | null {
if (!desiredState) return null;
const replicas = Number(desiredState.replicas);
return [firstNonEmptyString(desiredState.namespace), firstNonEmptyString(desiredState.profile), Number.isFinite(replicas) ? `${replicas} replicas` : null, firstNonEmptyString(desiredState.source)].filter(Boolean).join(" / ") || null;
}
+7
View File
@@ -124,6 +124,13 @@
color: #92400e;
}
.status-badge[data-tone="warn"],
.status-badge[data-tone="dry-run"] {
border-color: #fed7aa;
background: #fff7ed;
color: #9a3412;
}
.status-badge[data-tone="blocked"],
.status-badge[data-tone="error"] {
border-color: #fecaca;
@@ -188,6 +188,156 @@
font-size: 13px;
}
.workbench-live-strip {
display: flex;
flex-wrap: wrap;
align-items: stretch;
gap: 10px;
}
.probe-card,
.live-build-summary {
min-width: min(320px, 100%);
flex: 1 1 320px;
}
.status-chip-button {
display: flex;
width: 100%;
min-height: 42px;
min-width: 0;
align-items: center;
gap: 8px;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: white;
padding: 8px 10px;
color: #334155;
text-align: left;
}
.status-chip-label,
.status-chip-meta {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.status-chip-label {
color: #0f172a;
font-size: 12px;
font-weight: 800;
}
.status-chip-meta {
margin-left: auto;
color: #64748b;
font-size: 12px;
}
.workbench-dialog-backdrop {
position: fixed;
inset: 0;
z-index: 80;
display: grid;
place-items: center;
background: rgba(15, 23, 42, 0.34);
padding: 18px;
}
.workbench-dialog {
display: grid;
width: min(720px, 100%);
max-height: min(760px, calc(100vh - 36px));
gap: 12px;
overflow: auto;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: white;
padding: 16px;
box-shadow: 0 20px 45px rgba(15, 23, 42, 0.2);
}
.workbench-dialog.wide {
width: min(920px, 100%);
}
.workbench-dialog header,
.hwpod-event-panel header {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.dialog-close {
width: 30px;
height: 30px;
border: 1px solid #cbd5e1;
border-radius: 8px;
background: #f8fafc;
color: #334155;
font-weight: 800;
}
.probe-list,
.live-build-list,
.hwpod-errors ul {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.probe-row,
.live-build-row,
.hwpod-errors {
display: grid;
gap: 8px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
padding: 10px;
}
.probe-row-head,
.live-build-row-head {
display: flex;
min-width: 0;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.probe-row-label,
.live-build-row strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.probe-row-meta,
.live-build-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
color: #64748b;
font-size: 12px;
}
.probe-row p,
.live-build-dialog-summary p,
.live-build-reason {
margin: 0;
color: #475569;
font-size: 12px;
line-height: 1.45;
}
.workbench-grid {
display: grid;
min-height: calc(100vh - 180px);
@@ -679,6 +829,133 @@
font-weight: 750;
}
.hwpod-node-status,
.hwpod-event-panel {
display: grid;
gap: 10px;
}
.hwpod-node-summary {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.summary-tile {
display: grid;
min-width: 0;
gap: 4px;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: #f8fafc;
padding: 9px;
color: #334155;
text-align: left;
}
.summary-tile span,
.summary-tile strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.summary-tile span {
color: #64748b;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.summary-tile strong {
color: #0f172a;
font-size: 13px;
}
.hwpod-node-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
margin: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: hidden;
}
.hwpod-node-meta div {
display: grid;
gap: 3px;
border-bottom: 1px solid #e2e8f0;
padding: 7px 8px;
background: #f8fafc;
}
.hwpod-node-meta dt,
.hwpod-node-meta dd {
min-width: 0;
margin: 0;
overflow-wrap: anywhere;
}
.hwpod-node-meta dt {
color: #64748b;
font-size: 11px;
font-weight: 800;
}
.hwpod-node-meta dd {
color: #0f172a;
font-size: 12px;
}
.hwpod-errors li {
display: grid;
gap: 4px;
color: #475569;
font-size: 12px;
}
.hwpod-errors code {
color: #991b1b;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.hwpod-event-panel header button {
border: 1px solid #cbd5e1;
border-radius: 8px;
background: white;
color: #334155;
padding: 5px 8px;
font-size: 12px;
font-weight: 750;
}
.hwpod-event-panel header div {
display: flex;
gap: 6px;
}
.hwpod-event-scroll {
max-height: 210px;
overflow: auto;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: #0f172a;
color: #dbeafe;
}
.hwpod-event-scroll pre,
#hwpod-detail-body {
margin: 0;
padding: 12px;
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.mini-log,
.json-panel,
.help-body {
+1
View File
@@ -59,6 +59,7 @@ export function statusTone(status: string | undefined | null): string {
const text = String(status ?? "").toLowerCase();
if (["completed", "ok", "healthy", "ready", "active"].includes(text)) return "ok";
if (["running", "accepted", "pending", "processing"].includes(text)) return "pending";
if (["warn", "warning", "degraded", "build_time_unavailable"].includes(text)) return "warn";
if (["blocked", "stale"].includes(text)) return "blocked";
if (["failed", "error", "timeout", "canceled", "cancelled"].includes(text)) return "error";
return "source";
@@ -5,6 +5,8 @@ import StatusBadge from "@/components/common/StatusBadge.vue";
import CommandComposer from "@/components/workbench/CommandComposer.vue";
import ConversationPanel from "@/components/workbench/ConversationPanel.vue";
import SessionRail from "@/components/workbench/SessionRail.vue";
import WorkbenchBuildSummary from "@/components/workbench/WorkbenchBuildSummary.vue";
import WorkbenchProbePanel from "@/components/workbench/WorkbenchProbePanel.vue";
import HwpodNodeOpsPanel from "@/components/hwpod/HwpodNodeOpsPanel.vue";
import { useAutoRefresh } from "@/composables/useAutoRefresh";
import { useWorkbenchStore } from "@/stores/workbench";
@@ -23,6 +25,10 @@ useAutoRefresh(() => workbench.refreshLive(), 30_000);
<span>Composer: {{ workbench.composer.submitMode }}{{ workbench.composer.targetTraceId ? ` ${workbench.composer.targetTraceId}` : '' }}</span>
<span>Timeout: inactivity {{ Math.round(workbench.codeAgentTimeoutMs / 1000) }}s</span>
</div>
<div class="workbench-live-strip" aria-label="Workbench live status">
<WorkbenchProbePanel :live="workbench.live" />
<WorkbenchBuildSummary :live="workbench.live" />
</div>
<div class="workbench-grid">
<SessionRail />
<main class="workbench-center">