Merge pull request #1167 from pikasTech/fix/v03-r3-live-hwpod-parity-1153
R3: 补齐 v0.3 Live 与 HWPOD parity
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user