diff --git a/web/hwlab-cloud-web/scripts/workbench-r3-live-hwpod-parity.test.ts b/web/hwlab-cloud-web/scripts/workbench-r3-live-hwpod-parity.test.ts new file mode 100644 index 00000000..18b34a2d --- /dev/null +++ b/web/hwlab-cloud-web/scripts/workbench-r3-live-hwpod-parity.test.ts @@ -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({ 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({ 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({ 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(data: T): ApiResult { + return { ok: true, status: 200, data, error: null }; +} diff --git a/web/hwlab-cloud-web/src/components/hwpod/HwpodNodeOpsPanel.vue b/web/hwlab-cloud-web/src/components/hwpod/HwpodNodeOpsPanel.vue index 861356ef..c16faced 100644 --- a/web/hwlab-cloud-web/src/components/hwpod/HwpodNodeOpsPanel.vue +++ b/web/hwlab-cloud-web/src/components/hwpod/HwpodNodeOpsPanel.vue @@ -1,28 +1,100 @@ diff --git a/web/hwlab-cloud-web/src/components/workbench/WorkbenchBuildSummary.vue b/web/hwlab-cloud-web/src/components/workbench/WorkbenchBuildSummary.vue new file mode 100644 index 00000000..df00b1f0 --- /dev/null +++ b/web/hwlab-cloud-web/src/components/workbench/WorkbenchBuildSummary.vue @@ -0,0 +1,50 @@ + + + diff --git a/web/hwlab-cloud-web/src/components/workbench/WorkbenchProbePanel.vue b/web/hwlab-cloud-web/src/components/workbench/WorkbenchProbePanel.vue new file mode 100644 index 00000000..894a4c4f --- /dev/null +++ b/web/hwlab-cloud-web/src/components/workbench/WorkbenchProbePanel.vue @@ -0,0 +1,42 @@ + + + diff --git a/web/hwlab-cloud-web/src/stores/workbench-live.ts b/web/hwlab-cloud-web/src/stores/workbench-live.ts new file mode 100644 index 00000000..04b313ab --- /dev/null +++ b/web/hwlab-cloud-web/src/stores/workbench-live.ts @@ -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 | 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 | 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): 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 | 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 | null; nodePayload: HwpodNodeOpsResponse | null; results: Record[] }): 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[]; 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 | null; results: Record[]; errors: HwpodErrorSummary[]; websocket: Record | 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 | null, results: Record[]): 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 | 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(); + return errors.filter((error) => { + if (seen.has(error.key)) return false; + seen.add(error.key); + return true; + }); +} + +function recordArray(value: unknown): Record[] { + return Array.isArray(value) ? value.map((item) => asRecord(item)).filter((item): item is Record => 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 | 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; +} diff --git a/web/hwlab-cloud-web/src/style.css b/web/hwlab-cloud-web/src/style.css index e644d21f..1d73a53f 100644 --- a/web/hwlab-cloud-web/src/style.css +++ b/web/hwlab-cloud-web/src/style.css @@ -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; diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index f54499f6..7f568fa1 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -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 { diff --git a/web/hwlab-cloud-web/src/utils/index.ts b/web/hwlab-cloud-web/src/utils/index.ts index 01716306..0ad8e584 100644 --- a/web/hwlab-cloud-web/src/utils/index.ts +++ b/web/hwlab-cloud-web/src/utils/index.ts @@ -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"; diff --git a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue index 4f1a02b9..60293d9c 100644 --- a/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue +++ b/web/hwlab-cloud-web/src/views/workbench/CodeWorkbenchView.vue @@ -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); Composer: {{ workbench.composer.submitMode }}{{ workbench.composer.targetTraceId ? ` ${workbench.composer.targetTraceId}` : '' }} Timeout: inactivity {{ Math.round(workbench.codeAgentTimeoutMs / 1000) }}s +
+ + +