From c73b19fcaa752b4d2f18d3eed123ae0ecc7fcb3e Mon Sep 17 00:00:00 2001 From: lyon Date: Sat, 20 Jun 2026 16:20:41 +0800 Subject: [PATCH] feat(web): configure trace explorer links --- deploy/deploy.schema.json | 12 ++++ deploy/deploy.yaml | 2 + internal/dev-entrypoint/cloud-web-runtime.mjs | 30 ++++++++- .../dev-entrypoint/cloud-web-runtime.test.mjs | 62 ++++++++++++++++++- scripts/gitops-render.mjs | 10 +++ scripts/src/runtime-lane.ts | 3 + .../workbench-r3-live-hwpod-parity.test.ts | 16 +++++ .../components/workbench/MessageActions.vue | 5 +- web/hwlab-cloud-web/src/config/runtime.ts | 43 +++++++++++++ web/hwlab-cloud-web/src/types/global.d.ts | 1 + .../specs/trace-rendering.spec.ts | 22 +++++++ 11 files changed, 202 insertions(+), 4 deletions(-) diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index b6ee3104..385ba631 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -334,6 +334,7 @@ "runtimePath": { "type": "string" }, "imageTagMode": { "type": "string", "enum": ["short", "full"] }, "workbench": { "$ref": "#/$defs/workbenchUiPolicy" }, + "observability": { "$ref": "#/$defs/runtimeObservabilityPolicy" }, "runtimeStore": { "$ref": "#/$defs/runtimeStore" }, "sourceRepo": { "type": "string" }, "envReuseServices": { @@ -360,6 +361,17 @@ }, "additionalProperties": false }, + "runtimeObservabilityPolicy": { + "type": "object", + "properties": { + "traceExplorerUrlTemplate": { + "type": "string", + "minLength": 1, + "pattern": "\\{trace_id\\}" + } + }, + "additionalProperties": false + }, "runtimeStore": { "type": "object", "properties": { diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index 454f7f7d..62aac9b7 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -255,6 +255,8 @@ lanes: traceTimeline: autoExpandRunning: false autoCollapseTerminal: false + observability: + traceExplorerUrlTemplate: /v1/workbench/traces/{trace_id}/events externalPostgres: enabled: true serviceName: g14-platform-postgres diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index ef3bc12b..1eb4be35 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -18,6 +18,8 @@ const CLIENT_DISCONNECT_ERROR_PATTERN = /\b(?:socket hang up|client disconnected const CLOUD_WEB_ERROR_HANDLER_INSTALLED = Symbol.for("hwlab.cloud-web.error-handler-installed"); const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/gate", "/diagnostics/gate", "/help", "/skills", "/access"]); const STATIC_ASSET_EXTENSION_PATTERN = /\.[A-Za-z0-9][A-Za-z0-9_-]*$/u; +const TRACE_EXPLORER_TEMPLATE_TOKEN = "{trace_id}"; +const TRACE_EXPLORER_TEMPLATE_PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_:-]+)\}/gu; export async function serveCloudWeb(options) { installCloudWebProcessErrorHandlers({ serviceId: options.serviceId }); @@ -167,11 +169,37 @@ function validateDisplayLocale(locale) { function workbenchRuntimeConfigFromEnv() { const traceTimeline = {}; + const result = {}; const autoExpandRunning = parseEnvBoolean(process.env.HWLAB_WORKBENCH_TRACE_AUTO_EXPAND_RUNNING); const autoCollapseTerminal = parseEnvBoolean(process.env.HWLAB_WORKBENCH_TRACE_AUTO_COLLAPSE_TERMINAL); + const traceExplorerUrlTemplate = traceExplorerUrlTemplateFromEnv(process.env.HWLAB_WORKBENCH_TRACE_EXPLORER_URL_TEMPLATE); if (typeof autoExpandRunning === "boolean") traceTimeline.autoExpandRunning = autoExpandRunning; if (typeof autoCollapseTerminal === "boolean") traceTimeline.autoCollapseTerminal = autoCollapseTerminal; - return Object.keys(traceTimeline).length > 0 ? { traceTimeline } : null; + if (Object.keys(traceTimeline).length > 0) result.traceTimeline = traceTimeline; + if (traceExplorerUrlTemplate) result.traceExplorerUrlTemplate = traceExplorerUrlTemplate; + return Object.keys(result).length > 0 ? result : null; +} + +function traceExplorerUrlTemplateFromEnv(value) { + if (value === undefined || value === null || value === "") return null; + const template = String(value).trim(); + if (!isSafeTraceExplorerUrlTemplate(template)) { + throw new Error("invalid HWLAB_WORKBENCH_TRACE_EXPLORER_URL_TEMPLATE: template must be http(s) or same-origin relative URL and contain only {trace_id}"); + } + return template; +} + +function isSafeTraceExplorerUrlTemplate(template) { + if (!template.includes(TRACE_EXPLORER_TEMPLATE_TOKEN)) return false; + const placeholders = Array.from(template.matchAll(TRACE_EXPLORER_TEMPLATE_PLACEHOLDER_PATTERN), (match) => match[0]); + if (placeholders.length === 0 || placeholders.some((placeholder) => placeholder !== TRACE_EXPLORER_TEMPLATE_TOKEN)) return false; + try { + const rendered = template.replaceAll(TRACE_EXPLORER_TEMPLATE_TOKEN, "trc_template_probe"); + const url = new URL(rendered, "http://hwlab-cloud-web.local"); + return (url.protocol === "http:" || url.protocol === "https:") && !template.startsWith("//"); + } catch { + return false; + } } function parseEnvBoolean(value) { diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs index aeae96ba..c4ce86e6 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -341,6 +341,11 @@ test("cloud web remains healthy after a client disconnects during static respons }); test("cloud web serves client deep links through the Vue shell", async () => { + const restoreEnv = withEnv({ + HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE: "Asia/Shanghai", + HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE: "zh-CN", + HWLAB_CLOUD_WEB_DISPLAY_TIME_LABEL: "北京时间" + }); const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-cloud-web-runtime-")); await writeFile(path.join(root, "index.html"), "
\n", "utf8"); await writeFile(path.join(root, "asset.txt"), "asset body\n", "utf8"); @@ -367,7 +372,9 @@ test("cloud web serves client deep links through the Vue shell", async () => { }); assert.equal(response.status, 200, route); assert.equal(response.headers.get("content-type"), "text/html; charset=utf-8", route); - assert.equal(await response.text(), "
\n", route); + const html = await response.text(); + assert.match(html, /
<\/div>/u, route); + assert.match(html, /HWLAB_CLOUD_WEB_CONFIG/u, route); } const assetResponse = await fetch(`${serverUrl(cloudWeb)}/asset.txt`, { @@ -381,11 +388,64 @@ test("cloud web serves client deep links through the Vue shell", async () => { }); assert.equal(missingAssetResponse.status, 404); } finally { + restoreEnv(); await close(cloudWeb); await rm(root, { recursive: true, force: true }); } }); +test("cloud web injects trace explorer runtime config from env", async () => { + const restoreEnv = withEnv({ + HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE: "Asia/Shanghai", + HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE: "zh-CN", + HWLAB_CLOUD_WEB_DISPLAY_TIME_LABEL: "北京时间", + HWLAB_WORKBENCH_TRACE_EXPLORER_URL_TEMPLATE: "/v1/workbench/traces/{trace_id}/events" + }); + const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-cloud-web-runtime-")); + await writeFile(path.join(root, "index.html"), "
\n", "utf8"); + const cloudWeb = createCloudWebServer({ + serviceId: "hwlab-cloud-web", + roots: [root], + cloudApiBaseUrl: "http://127.0.0.1:1", + healthPayload: () => ({ status: "ok" }), + sendJson(response, statusCode, body) { + const payload = JSON.stringify(body); + response.writeHead(statusCode, { + "content-type": "application/json", + "content-length": Buffer.byteLength(payload) + }); + response.end(payload); + } + }); + await listen(cloudWeb); + + try { + const response = await fetch(`${serverUrl(cloudWeb)}/workbench`, { headers: { accept: "text/html" } }); + assert.equal(response.status, 200); + const html = await response.text(); + assert.match(html, /traceExplorerUrlTemplate":"\/v1\/workbench\/traces\/\{trace_id\}\/events/u); + assert.doesNotMatch(html, /api key|secret|password/iu); + } finally { + restoreEnv(); + await close(cloudWeb); + await rm(root, { recursive: true, force: true }); + } +}); + +function withEnv(values) { + const previous = new Map(); + for (const [key, value] of Object.entries(values)) { + previous.set(key, process.env[key]); + process.env[key] = value; + } + return () => { + for (const [key, value] of previous.entries()) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }; +} + function listen(server) { return new Promise((resolve, reject) => { server.once("error", reject); diff --git a/scripts/gitops-render.mjs b/scripts/gitops-render.mjs index 0fd2ee74..357cdb27 100644 --- a/scripts/gitops-render.mjs +++ b/scripts/gitops-render.mjs @@ -979,6 +979,12 @@ function runtimeWorkbenchTraceTimelinePolicy(deploy, profile) { return Object.keys(result).length > 0 ? result : null; } +function runtimeTraceExplorerUrlTemplate(deploy, profile) { + if (!isRuntimeLane(profile)) return null; + const template = runtimeLaneConfig(deploy, profile)?.observability?.traceExplorerUrlTemplate; + return typeof template === "string" && template.trim().length > 0 ? template.trim() : null; +} + function runtimeStoreEnvForProfile(deploy, profile, serviceId) { if (!isRuntimeLane(profile) || serviceId !== "hwlab-cloud-api") return {}; const postgres = runtimeLaneConfig(deploy, profile)?.runtimeStore?.postgres; @@ -1225,6 +1231,10 @@ function transformWorkloads({ workloads, deploy, catalog, source, sourceBranch = if (typeof traceTimelinePolicy?.autoCollapseTerminal === "boolean") { upsertEnv(container.env, "HWLAB_WORKBENCH_TRACE_AUTO_COLLAPSE_TERMINAL", booleanEnv(traceTimelinePolicy.autoCollapseTerminal)); } + const traceExplorerUrlTemplate = runtimeTraceExplorerUrlTemplate(deploy, profile); + if (traceExplorerUrlTemplate) { + upsertEnv(container.env, "HWLAB_WORKBENCH_TRACE_EXPLORER_URL_TEMPLATE", traceExplorerUrlTemplate); + } } } rewritePodSecretRefs(podTemplate.spec, profile); diff --git a/scripts/src/runtime-lane.ts b/scripts/src/runtime-lane.ts index 4d3ecf50..4df980b1 100644 --- a/scripts/src/runtime-lane.ts +++ b/scripts/src/runtime-lane.ts @@ -44,6 +44,9 @@ export type RuntimeLaneConfig = { api?: string; [key: string]: string | undefined; }; + observability?: { + traceExplorerUrlTemplate?: string; + }; sourceRepo?: string; }; 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 index 9312901f..947c207d 100644 --- 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 @@ -2,6 +2,7 @@ import assert from "node:assert/strict"; import test from "node:test"; import type { ApiResult, HwpodNodeOpsResponse, HwpodSpecsResponse, LiveProbePayload, LiveSurface } from "../src/types/index.ts"; +import { traceExplorerHref } from "../src/config/runtime.ts"; import { summarizeBuilds, summarizeHwpodNodeOps, summarizeProbeRows } from "../src/stores/workbench-live.ts"; import { liveCall } from "../src/stores/workbench.ts"; @@ -56,6 +57,21 @@ test("R3 live refresh keeps partial evidence when one endpoint throws", async () assert.equal(passed.data?.revision, "155530d8d9355887a23a08e341291994cc312f54"); }); +test("R3 trace explorer href is config gated and trace_id only", () => { + window.HWLAB_CLOUD_WEB_CONFIG = { + displayTime: { timeZone: "Asia/Shanghai", locale: "zh-CN", label: "北京时间" }, + workbench: { traceExplorerUrlTemplate: "/v1/workbench/traces/{trace_id}/events" } + }; + assert.equal(traceExplorerHref("trc_completed"), "/v1/workbench/traces/trc_completed/events"); + assert.equal(traceExplorerHref("bad/trace"), null); + + window.HWLAB_CLOUD_WEB_CONFIG.workbench = { traceExplorerUrlTemplate: "/trace/{trace_id}?prompt={prompt}" }; + assert.equal(traceExplorerHref("trc_completed"), null); + + window.HWLAB_CLOUD_WEB_CONFIG.workbench = {}; + assert.equal(traceExplorerHref("trc_completed"), null); +}); + 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 { diff --git a/web/hwlab-cloud-web/src/components/workbench/MessageActions.vue b/web/hwlab-cloud-web/src/components/workbench/MessageActions.vue index 0c128801..cca5a76d 100644 --- a/web/hwlab-cloud-web/src/components/workbench/MessageActions.vue +++ b/web/hwlab-cloud-web/src/components/workbench/MessageActions.vue @@ -1,6 +1,7 @@