From bffe8dc87cc3c68e8a41895acc501ffc674c9998 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 20:32:11 +0200 Subject: [PATCH] fix: migrate HWLAB v03 development public entry --- config/hwlab-node-lanes.yaml | 21 +++++--- scripts/src/hwlab-node-lanes.ts | 63 +++++++++++++++++++++-- scripts/src/hwlab-node/public-exposure.ts | 47 ++++++++++++++--- scripts/src/hwlab-node/render.ts | 49 +++++++++++++++--- scripts/src/hwlab-node/web-probe.ts | 13 ++++- 5 files changed, 166 insertions(+), 27 deletions(-) diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 0e718449..bb3faaa0 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -82,6 +82,7 @@ lanes: deployment: hwlab-cloud-api targets: NC01: + environment: development node: NC01 workspace: /root/hwlab-v03 sourceWorkspace: @@ -158,8 +159,8 @@ lanes: extends: templates.hwlabV03.webProbeWorkbench playwrightBrowsersPath: "0" public: - webUrl: https://hwlab.pikapython.com - apiUrl: https://hwlab.pikapython.com + webUrl: http://152.53.229.148:32009 + apiUrl: http://152.53.229.148:32009 observability: prometheusOperator: false webProbe: @@ -237,12 +238,16 @@ lanes: transactionalProjectorConsumerGroupId: hwlab-v03-agentrun-event-projector hwlabEventConsumerGroupId: hwlab-v03-workbench-live-sse publicExposure: - extends: templates.hwlabV03.publicExposurePk01 - frpc: - webProxy: - remotePort: 22082 - apiProxy: - remotePort: 22083 + mode: node-port-http + publicBaseUrl: http://152.53.229.148:32009 + publicAddress: 152.53.229.148 + service: + name: hwlab-cloud-web-public + selector: + app.kubernetes.io/name: hwlab-cloud-web + port: 80 + targetPort: 8080 + nodePort: 32009 externalPostgres: extends: templates.hwlabV03.externalPostgresConsumer provider: NC01 diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index 4271d3e5..3d3d9be3 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -487,7 +487,7 @@ export interface HwlabRuntimePublicExposureFrpcExtraProxySpec extends HwlabRunti readonly cloudWebEnvName?: string; } -export interface HwlabRuntimePublicExposureSpec { +export interface HwlabRuntimePk01PublicExposureSpec { readonly enabled: boolean; readonly mode: "pk01-caddy-frp"; readonly publicBaseUrl: string; @@ -511,6 +511,20 @@ export interface HwlabRuntimePublicExposureSpec { readonly extraProxies: readonly HwlabRuntimePublicExposureFrpcExtraProxySpec[]; } +export interface HwlabRuntimeNodePortPublicExposureSpec { + readonly enabled: boolean; + readonly mode: "node-port-http"; + readonly publicBaseUrl: string; + readonly publicAddress: string; + readonly serviceName: string; + readonly selector: Record; + readonly port: number; + readonly targetPort: number; + readonly nodePort: number; +} + +export type HwlabRuntimePublicExposureSpec = HwlabRuntimePk01PublicExposureSpec | HwlabRuntimeNodePortPublicExposureSpec; + export interface HwlabRuntimeBootstrapAdminSpec { readonly username: string; readonly displayName: string; @@ -639,6 +653,7 @@ export interface HwlabRuntimePipelineProvenanceSpec { export interface HwlabRuntimeLaneSpec { readonly lane: HwlabRuntimeLane; + readonly environment?: "development" | "production"; readonly nodeId: string; readonly nodeRoute: string; readonly nodeKubeRoute: string; @@ -711,6 +726,7 @@ export const HWLAB_NODE_LANE_CONFIG_PATH = "config/hwlab-node-lanes.yaml"; interface HwlabLaneConfig { readonly id: HwlabRuntimeLane; + readonly environment?: "development" | "production"; readonly node: string; readonly activeTarget?: string; readonly minor: number; @@ -956,6 +972,7 @@ function laneConfig(id: HwlabRuntimeLane, raw: Record): HwlabLa if (minor >= 3 && raw.sourceSnapshot === undefined) throw new Error(`lanes.${id}.sourceSnapshot is required for HWLAB runtime v0.3+`); return { id, + environment: raw.environment === undefined ? undefined : enumStringField(raw, "environment", `lanes.${id}`, ["development", "production"]), node: stringField(raw, "node", `lanes.${id}`), activeTarget: optionalStringField(raw, "activeTarget", `lanes.${id}`), minor, @@ -1952,7 +1969,27 @@ function publicExposureConfig(value: unknown, path: string): HwlabRuntimePublicE if (value === undefined) return null; const raw = asRecord(value, path); const mode = stringField(raw, "mode", path); - if (mode !== "pk01-caddy-frp") throw new Error(`${path}.mode must be pk01-caddy-frp`); + const publicBaseUrl = stringField(raw, "publicBaseUrl", path).replace(/\/+$/u, ""); + if (mode === "node-port-http") { + const service = asRecord(raw.service, `${path}.service`); + const selector = optionalStringRecord(service.selector, `${path}.service.selector`); + if (Object.keys(selector).length === 0) throw new Error(`${path}.service.selector must declare at least one label`); + const publicAddress = stringField(raw, "publicAddress", path); + const nodePort = numberField(service, "nodePort", `${path}.service`); + validateNodePortPublicExposureOrigin(publicBaseUrl, publicAddress, nodePort, `${path}.publicBaseUrl`); + return { + enabled: true, + mode, + publicBaseUrl, + publicAddress, + serviceName: stringField(service, "name", `${path}.service`), + selector, + port: numberField(service, "port", `${path}.service`), + targetPort: numberField(service, "targetPort", `${path}.service`), + nodePort, + }; + } + if (mode !== "pk01-caddy-frp") throw new Error(`${path}.mode must be pk01-caddy-frp or node-port-http`); const frpc = asRecord(raw.frpc, `${path}.frpc`); const caddy = asRecord(raw.caddy, `${path}.caddy`); const caddyTls = optionalStringField(caddy, "tls", `${path}.caddy`) ?? "auto"; @@ -1960,7 +1997,7 @@ function publicExposureConfig(value: unknown, path: string): HwlabRuntimePublicE return { enabled: true, mode, - publicBaseUrl: stringField(raw, "publicBaseUrl", path).replace(/\/+$/u, ""), + publicBaseUrl, hostname: stringField(raw, "hostname", path), expectedA: stringField(raw, "expectedA", path), serverAddr: stringField(frpc, "serverAddr", `${path}.frpc`), @@ -1982,6 +2019,19 @@ function publicExposureConfig(value: unknown, path: string): HwlabRuntimePublicE }; } +function validateNodePortPublicExposureOrigin(publicBaseUrl: string, publicAddress: string, nodePort: number, path: string): void { + try { + const parsed = new URL(publicBaseUrl); + if (parsed.protocol !== "http:") throw new Error("must use http"); + if (parsed.hostname !== publicAddress) throw new Error(`hostname must match publicAddress ${publicAddress}`); + if (Number(parsed.port) !== nodePort) throw new Error(`port must match service.nodePort ${nodePort}`); + if (parsed.pathname !== "/" || parsed.search.length > 0 || parsed.hash.length > 0) throw new Error("must point to the public origin root"); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new Error(`${path} must be an http NodePort origin root URL: ${message}`); + } +} + function validatePublicExposureOrigin(publicBaseUrl: string, path: string): void { try { const parsed = new URL(publicBaseUrl); @@ -2297,8 +2347,15 @@ function buildRuntimeLaneSpec(config: HwlabLaneConfig): HwlabRuntimeLaneSpec { const node = HWLAB_NODE_LANE_CONFIG.nodes[config.node]; const networkProfile = HWLAB_NODE_LANE_CONFIG.networkProfiles[node.networkProfileId]; const downloadProfile = HWLAB_NODE_LANE_CONFIG.downloadProfiles[node.downloadProfileId]; + if (config.publicExposure?.mode === "node-port-http") { + if (config.environment === undefined) throw new Error(`${HWLAB_NODE_LANE_CONFIG_PATH}.lanes.${config.id}.environment is required for node-port-http public exposure`); + if (config.public.webUrl !== config.publicExposure.publicBaseUrl || config.public.apiUrl !== config.publicExposure.publicBaseUrl) { + throw new Error(`${HWLAB_NODE_LANE_CONFIG_PATH}.lanes.${config.id}.public webUrl/apiUrl must match publicExposure.publicBaseUrl for node-port-http`); + } + } return { lane: config.id, + ...(config.environment === undefined ? {} : { environment: config.environment }), nodeId: node.id, nodeRoute: node.route, nodeKubeRoute: node.kubeRoute, diff --git a/scripts/src/hwlab-node/public-exposure.ts b/scripts/src/hwlab-node/public-exposure.ts index 136b5721..d0f8373b 100644 --- a/scripts/src/hwlab-node/public-exposure.ts +++ b/scripts/src/hwlab-node/public-exposure.ts @@ -13,7 +13,7 @@ import { runCommand, type CommandResult } from "../command"; import { startJob } from "../jobs"; import { classifySshTcpPoolFailure } from "../ssh"; import { HWLAB_NODE_CONTROL_PLANE_CONFIG_PATH, hwlabNodeControlPlaneInfraHelp, runHwlabNodeControlPlaneInfra } from "../hwlab-node-control-plane"; -import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes"; +import { hwlabRuntimeLaneConfigPath, hwlabRuntimeLaneIds, hwlabRuntimeLaneSpec, hwlabRuntimeLaneSpecForNode, hwlabRuntimeNodeIds, isHwlabRuntimeLane, type HwlabRuntimeLane, type HwlabRuntimeLaneSpec, type HwlabRuntimeObservabilityRecordingRuleSpec, type HwlabRuntimeObservabilitySpec, type HwlabRuntimeObservabilityWarningAlertSpec, type HwlabRuntimePk01PublicExposureSpec, type HwlabRuntimePublicExposureSpec, type HwlabRuntimeWebProbeAlertThresholdsSpec, type HwlabRuntimeWebProbeProjectManagementSpec } from "../hwlab-node-lanes"; import { nodeWebProbeScriptRunnerSource } from "../hwlab-node-web-probe-runner-source"; import { nodeWebObserveAnalyzerSource } from "../hwlab-node-web-observe-analyzer-source"; import { nodeWebObserveRunnerSource } from "../hwlab-node-web-observe-runner-source"; @@ -440,6 +440,20 @@ export function runExternalPostgresSecretEnsure(options: NodeSecretOptions, spec } export function publicExposureSummary(exposure: HwlabRuntimePublicExposureSpec): Record { + if (exposure.mode === "node-port-http") { + return { + mode: exposure.mode, + publicBaseUrl: exposure.publicBaseUrl, + publicAddress: exposure.publicAddress, + service: { + name: exposure.serviceName, + selector: exposure.selector, + port: exposure.port, + targetPort: exposure.targetPort, + nodePort: exposure.nodePort, + }, + }; + } return { mode: exposure.mode, publicBaseUrl: exposure.publicBaseUrl, @@ -480,6 +494,23 @@ export function runNodePublicExposure(options: NodePublicExposureOptions): Recor mutation: false, }; } + if (exposure.mode === "node-port-http") { + return { + ok: true, + command: "hwlab nodes control-plane public-exposure", + node: options.node, + lane: options.lane, + mode: "gitops-managed-node-port", + mutation: false, + configPath: hwlabRuntimeLaneConfigPath(), + publicExposure: publicExposureSummary(exposure), + valuesRedacted: true, + next: { + delivery: "merge the source PR and observe the automatic PaC/GitOps/Argo chain", + status: `bun scripts/cli.ts hwlab nodes control-plane status --node ${options.node} --lane ${options.lane}`, + }, + }; + } const source = readPublicExposureTokenSource(exposure); if (!source.ok) { return { @@ -531,7 +562,7 @@ export function runNodePublicExposure(options: NodePublicExposureOptions): Recor }; } -function publicExposureSecretDryRunStatus(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec, tokenBytes: number): Record { +function publicExposureSecretDryRunStatus(options: NodePublicExposureOptions, exposure: HwlabRuntimePk01PublicExposureSpec, tokenBytes: number): Record { return { ok: true, dryRun: true, @@ -566,7 +597,7 @@ function publicExposureSecretDryRunStatus(options: NodePublicExposureOptions, ex }; } -function publicExposureSecretApplyStatus(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec, token: string): Record { +function publicExposureSecretApplyStatus(options: NodePublicExposureOptions, exposure: HwlabRuntimePk01PublicExposureSpec, token: string): Record { const secretApplyResult = runTransScript(options.node, publicExposureSecretScript(options, exposure), token, options.timeoutSeconds); let secretFields = keyValueLinesFromText(statusText(secretApplyResult)); let secretResult = secretApplyResult; @@ -578,7 +609,7 @@ function publicExposureSecretApplyStatus(options: NodePublicExposureOptions, exp return publicExposureSecretStatus(secretFields, secretResult); } -export function readPublicExposureTokenSource(exposure: HwlabRuntimePublicExposureSpec): { ok: boolean; path: string; checkedPaths: string[]; key: string; value: string | null; fingerprint: string | null; error?: string } { +export function readPublicExposureTokenSource(exposure: HwlabRuntimePk01PublicExposureSpec): { ok: boolean; path: string; checkedPaths: string[]; key: string; value: string | null; fingerprint: string | null; error?: string } { const checkedPaths = publicExposureTokenSourcePaths(exposure); const path = checkedPaths.find((candidate) => existsSync(candidate)) ?? checkedPaths[0] ?? rootPath(".state", "secrets", exposure.tokenSourceRef); if (!existsSync(path)) return { ok: false, path, checkedPaths, key: exposure.tokenSourceKey, value: null, fingerprint: null, error: "secret-source-missing" }; @@ -595,7 +626,7 @@ export function readPublicExposureTokenSource(exposure: HwlabRuntimePublicExposu }; } -export function publicExposureTokenSourcePaths(exposure: HwlabRuntimePublicExposureSpec): string[] { +export function publicExposureTokenSourcePaths(exposure: HwlabRuntimePk01PublicExposureSpec): string[] { return [rootPath(".state", "secrets", exposure.tokenSourceRef)]; } @@ -671,7 +702,7 @@ export function publicExposureCaddyStatus(fields: Record, result }; } -export function publicExposureSecretScript(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec): string { +export function publicExposureSecretScript(options: NodePublicExposureOptions, exposure: HwlabRuntimePk01PublicExposureSpec): string { const deployment = `${options.spec.runtimeNamespace}-frpc`; return [ "set +e", @@ -859,7 +890,7 @@ export function combinePublicExposureCommandResults(first: CommandResult, second }; } -export function publicExposureCaddyScript(options: NodePublicExposureOptions, exposure: HwlabRuntimePublicExposureSpec): string { +export function publicExposureCaddyScript(options: NodePublicExposureOptions, exposure: HwlabRuntimePk01PublicExposureSpec): string { const blockB64 = Buffer.from(publicExposureCaddyBlock(exposure), "utf8").toString("base64"); const marker = `unidesk managed ${exposure.hostname}`; return [ @@ -976,7 +1007,7 @@ export function publicExposureCaddyScript(options: NodePublicExposureOptions, ex ].join("\n"); } -export function publicExposureCaddyBlock(exposure: HwlabRuntimePublicExposureSpec): string { +export function publicExposureCaddyBlock(exposure: HwlabRuntimePk01PublicExposureSpec): string { const tlsLines = exposure.caddyTls === "internal" ? " tls internal\n" : ""; const mainBlock = `${exposure.hostname} { ${tlsLines} @api path /health* /v1* /json-rpc* /openapi* /docs* /swagger* diff --git a/scripts/src/hwlab-node/render.ts b/scripts/src/hwlab-node/render.ts index ffd53590..bd3eb10a 100644 --- a/scripts/src/hwlab-node/render.ts +++ b/scripts/src/hwlab-node/render.ts @@ -1626,6 +1626,7 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s " runtimePath: overlay.runtimePath,", " runtimeRenderDir: overlay.runtimeRenderDir,", " runtimeNamespace: overlay.runtimeNamespace,", + " environment: overlay.environment,", " catalogPath: overlay.catalogPath,", " gitUrl: overlay.gitUrl,", " publicWebUrl: overlay.publicWebUrl,", @@ -1723,6 +1724,7 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s " runtimePath: overlay.runtimePath,", " runtimeRenderDir: overlay.runtimeRenderDir,", " runtimeNamespace: overlay.runtimeNamespace,", + " environment: overlay.environment,", " externalPostgres: overlay.externalPostgres,", " codeAgentRuntime: overlay.codeAgentRuntime,", " publicExposure: overlay.publicExposure,", @@ -2049,15 +2051,17 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s " if (!fs.existsSync(file)) return false;", " const doc = readYaml(file) || {};", " const resources = Array.isArray(doc.resources) ? doc.resources : [];", - " const publicExposureEnabled = overlay.publicExposure && overlay.publicExposure.enabled;", + " const publicExposureMode = overlay.publicExposure && overlay.publicExposure.enabled ? overlay.publicExposure.mode : null;", " const next = resources.filter((item) => {", " const resource = String(item);", - " if (!publicExposureEnabled && resource === 'node-frpc.yaml') return false;", + " if (publicExposureMode !== 'pk01-caddy-frp' && resource === 'node-frpc.yaml') return false;", + " if (publicExposureMode !== 'node-port-http' && resource === 'node-public-service.yaml') return false;", " if (overlay.observability && overlay.observability.prometheusOperator === false && resource === 'observability.yaml') return false;", " return !(/\\.ya?ml$/u.test(resource) && !fs.existsSync(path.join(runtimePath, resource)));", " });", " let changed = false;", - " if (next.length !== resources.length) { doc.resources = next; writeYaml(file, doc); changed = true; }", + " if (publicExposureMode === 'node-port-http' && !next.includes('node-public-service.yaml')) next.push('node-public-service.yaml');", + " if (JSON.stringify(next) !== JSON.stringify(resources)) { doc.resources = next; writeYaml(file, doc); changed = true; }", " const observabilityFile = path.join(runtimePath, 'observability.yaml');", " if (overlay.observability && overlay.observability.prometheusOperator === false && fs.existsSync(observabilityFile)) { fs.rmSync(observabilityFile, { force: true }); changed = true; }", " return changed;", @@ -2171,12 +2175,31 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s "}", "function patchPublicExposure() {", " const exposure = overlay.publicExposure;", - " const file = path.join(runtimePath, 'node-frpc.yaml');", + " const frpcFile = path.join(runtimePath, 'node-frpc.yaml');", + " const nodePortFile = path.join(runtimePath, 'node-public-service.yaml');", " if (!exposure || !exposure.enabled) {", - " const changed = fs.existsSync(file);", - " if (changed) fs.rmSync(file, { force: true });", + " const changed = fs.existsSync(frpcFile) || fs.existsSync(nodePortFile);", + " fs.rmSync(frpcFile, { force: true });", + " fs.rmSync(nodePortFile, { force: true });", " return { configured: false, changed };", " }", + " if (exposure.mode === 'node-port-http') {", + " if (typeof overlay.environment !== 'string' || overlay.environment.length === 0) throw new Error('node-port-http public exposure requires owning YAML environment');", + " const service = {", + " apiVersion: 'v1',", + " kind: 'Service',", + " metadata: { name: String(exposure.serviceName), namespace: String(overlay.runtimeNamespace), labels: { 'app.kubernetes.io/name': String(exposure.serviceName), 'app.kubernetes.io/part-of': 'hwlab', 'hwlab.pikastech.local/environment': String(overlay.environment) } },", + " spec: { type: 'NodePort', selector: exposure.selector, ports: [{ name: 'http', protocol: 'TCP', port: Number(exposure.port), targetPort: Number(exposure.targetPort), nodePort: Number(exposure.nodePort) }] },", + " };", + " const rendered = YAML.stringify(service).trimEnd() + '\\n';", + " const before = fs.existsSync(nodePortFile) ? fs.readFileSync(nodePortFile, 'utf8') : '';", + " if (before !== rendered) fs.writeFileSync(nodePortFile, rendered);", + " const frpcRemoved = fs.existsSync(frpcFile);", + " fs.rmSync(frpcFile, { force: true });", + " return { configured: true, mode: exposure.mode, changed: before !== rendered || frpcRemoved, filePath: nodePortFile, serviceName: exposure.serviceName, nodePort: exposure.nodePort };", + " }", + " const file = frpcFile;", + " fs.rmSync(nodePortFile, { force: true });", " if (!fs.existsSync(file)) {", " console.error(JSON.stringify({ event: 'unidesk-public-exposure-postprocess', ok: false, reason: 'node-frpc-yaml-missing', filePath: file, hostname: exposure.hostname }));", " process.exit(49);", @@ -2238,6 +2261,7 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s " const runtimeOverlay = JSON.stringify({", " runtimePath: overlay.runtimePath,", " runtimeNamespace: overlay.runtimeNamespace,", + " environment: overlay.environment,", " externalPostgres: overlay.externalPostgres,", " codeAgentRuntime: overlay.codeAgentRuntime,", " publicExposure: overlay.publicExposure,", @@ -2450,7 +2474,18 @@ export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: s " }", "}", "const exposure = overlay.publicExposure;", - "if (exposure && exposure.enabled) {", + "if (exposure && exposure.enabled && exposure.mode === 'node-port-http') {", + " const file = path.join(runtimePath, 'node-public-service.yaml');", + " if (!fs.existsSync(file)) fail('public-exposure-node-port-service-missing');", + " const service = allItemsFromFile(file).find((item) => item && item.kind === 'Service' && item.metadata && item.metadata.name === exposure.serviceName);", + " if (!service) fail('public-exposure-node-port-service-object-missing', { expected: exposure.serviceName });", + " const port = service.spec && Array.isArray(service.spec.ports) ? service.spec.ports.find((item) => item && Number(item.nodePort) === Number(exposure.nodePort)) : null;", + " if (!service.spec || service.spec.type !== 'NodePort') fail('public-exposure-node-port-service-type-mismatch', { expected: 'NodePort', actual: service.spec && service.spec.type });", + " if (!port || Number(port.port) !== Number(exposure.port) || Number(port.targetPort) !== Number(exposure.targetPort)) fail('public-exposure-node-port-service-port-mismatch', { expectedPort: exposure.port, expectedTargetPort: exposure.targetPort, expectedNodePort: exposure.nodePort });", + " if (JSON.stringify(service.spec.selector || {}) !== JSON.stringify(exposure.selector || {})) fail('public-exposure-node-port-service-selector-mismatch', { expected: exposure.selector, actual: service.spec.selector || {} });", + " checks.push('public-exposure-node-port');", + "}", + "if (exposure && exposure.enabled && exposure.mode === 'pk01-caddy-frp') {", " const file = path.join(runtimePath, 'node-frpc.yaml');", " if (!fs.existsSync(file)) fail('public-exposure-frpc-missing');", " const items = allItemsFromFile(file);", diff --git a/scripts/src/hwlab-node/web-probe.ts b/scripts/src/hwlab-node/web-probe.ts index 7ea2491e..19f2b921 100644 --- a/scripts/src/hwlab-node/web-probe.ts +++ b/scripts/src/hwlab-node/web-probe.ts @@ -78,6 +78,7 @@ export function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record