Merge PR #393: show live build metadata on workbench

Refs #359 #99 #239 #235 #116 #7 #78 #131
This commit is contained in:
Lyon
2026-05-24 04:42:16 +08:00
committed by GitHub
8 changed files with 497 additions and 53 deletions
+288 -31
View File
@@ -1,12 +1,15 @@
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
import {
buildMetadataFromEnv,
imageTagFromReference,
normalizeIsoTimestamp
} from "../build-metadata.mjs";
import { DEV_ENDPOINT, ENVIRONMENT_DEV, ERROR_CODES } from "../protocol/index.mjs";
import { DEV_ENDPOINT, ENVIRONMENT_DEV, ERROR_CODES, SERVICE_IDS } from "../protocol/index.mjs";
import {
AUDIT_FIELD_NAMES,
CLOUD_API_SERVICE_ID,
@@ -41,21 +44,36 @@ import { createGatewayDemoRegistry } from "./gateway-demo-registry.mjs";
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
const LIVE_BUILD_TIMEOUT_MS = 900;
const LIVE_BUILD_SERVICE_ENDPOINTS = Object.freeze([
Object.freeze({ serviceId: "hwlab-cloud-api", serviceName: "hwlab-cloud-api", urlEnv: null, defaultUrl: "http://127.0.0.1:6667" }),
Object.freeze({ serviceId: "hwlab-cloud-web", serviceName: "hwlab-cloud-web", urlEnv: "HWLAB_CLOUD_WEB_SERVICE_URL", defaultUrl: "http://hwlab-cloud-web.hwlab-dev.svc.cluster.local:8080" }),
Object.freeze({ serviceId: "hwlab-agent-mgr", serviceName: "hwlab-agent-mgr", urlEnv: "HWLAB_AGENT_MGR_URL", defaultUrl: "http://hwlab-agent-mgr.hwlab-dev.svc.cluster.local:7410" }),
Object.freeze({ serviceId: "hwlab-agent-worker", serviceName: "hwlab-agent-worker", urlEnv: "HWLAB_AGENT_WORKER_URL", defaultUrl: "http://hwlab-agent-worker.hwlab-dev.svc.cluster.local:7411", activation: "suspended-job-template" }),
Object.freeze({ serviceId: "hwlab-gateway", serviceName: "hwlab-gateway", urlEnv: "HWLAB_GATEWAY_URL", defaultUrl: "http://hwlab-gateway.hwlab-dev.svc.cluster.local:7001", activation: "manual" }),
Object.freeze({ serviceId: "hwlab-gateway-simu", serviceName: "hwlab-gateway-simu", urlEnv: "HWLAB_GATEWAY_SIMU_URL", defaultUrl: "http://hwlab-gateway-simu.hwlab-dev.svc.cluster.local:7101" }),
Object.freeze({ serviceId: "hwlab-box-simu", serviceName: "hwlab-box-simu", urlEnv: "HWLAB_BOX_SIMU_URL", defaultUrl: "http://hwlab-box-simu.hwlab-dev.svc.cluster.local:7201" }),
Object.freeze({ serviceId: "hwlab-patch-panel", serviceName: "hwlab-patch-panel", urlEnv: "HWLAB_PATCH_PANEL_URL", defaultUrl: "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301" }),
Object.freeze({ serviceId: "hwlab-router", serviceName: "hwlab-router", urlEnv: "HWLAB_ROUTER_URL", defaultUrl: "http://hwlab-router.hwlab-dev.svc.cluster.local:7401" }),
Object.freeze({ serviceId: "hwlab-tunnel-client", serviceName: "hwlab-tunnel-client", urlEnv: "HWLAB_TUNNEL_CLIENT_URL", defaultUrl: "http://hwlab-tunnel-client.hwlab-dev.svc.cluster.local:7402" }),
Object.freeze({ serviceId: "hwlab-frpc", serviceName: "hwlab-frpc", externalImage: true, defaultImage: "127.0.0.1:5000/hwlab/frpc:v0.68.1" }),
Object.freeze({ serviceId: "hwlab-edge-proxy", serviceName: "hwlab-edge-proxy", urlEnv: "HWLAB_EDGE_PROXY_URL", defaultUrl: "http://hwlab-edge-proxy.hwlab-dev.svc.cluster.local:6667", healthPath: "/health" }),
Object.freeze({ serviceId: "hwlab-cli", serviceName: "hwlab-cli", urlEnv: "HWLAB_CLI_URL", defaultUrl: "http://hwlab-cli.hwlab-dev.svc.cluster.local:7501", activation: "suspended-job-template" }),
Object.freeze({ serviceId: "hwlab-agent-skills", serviceName: "hwlab-agent-skills", urlEnv: "HWLAB_AGENT_SKILLS_URL", defaultUrl: "http://hwlab-agent-skills.hwlab-dev.svc.cluster.local:7430" })
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const LIVE_BUILD_METADATA_PATHS = Object.freeze({
deployManifest: "deploy/deploy.json",
artifactCatalog: "deploy/artifact-catalog.dev.json",
artifactReport: "reports/dev-gate/dev-artifacts.json"
});
const LIVE_BUILD_SERVICE_DEFAULTS = Object.freeze({
"hwlab-cloud-api": Object.freeze({ urlEnv: null, defaultUrl: "http://127.0.0.1:6667" }),
"hwlab-cloud-web": Object.freeze({ urlEnv: "HWLAB_CLOUD_WEB_SERVICE_URL", defaultUrl: "http://hwlab-cloud-web.hwlab-dev.svc.cluster.local:8080" }),
"hwlab-agent-mgr": Object.freeze({ urlEnv: "HWLAB_AGENT_MGR_URL", defaultUrl: "http://hwlab-agent-mgr.hwlab-dev.svc.cluster.local:7410" }),
"hwlab-agent-worker": Object.freeze({ urlEnv: "HWLAB_AGENT_WORKER_URL", defaultUrl: "http://hwlab-agent-worker.hwlab-dev.svc.cluster.local:7411" }),
"hwlab-gateway": Object.freeze({ urlEnv: "HWLAB_GATEWAY_URL", defaultUrl: "http://hwlab-gateway.hwlab-dev.svc.cluster.local:7001" }),
"hwlab-gateway-simu": Object.freeze({ urlEnv: "HWLAB_GATEWAY_SIMU_URL", defaultUrl: "http://hwlab-gateway-simu.hwlab-dev.svc.cluster.local:7101" }),
"hwlab-box-simu": Object.freeze({ urlEnv: "HWLAB_BOX_SIMU_URL", defaultUrl: "http://hwlab-box-simu.hwlab-dev.svc.cluster.local:7201" }),
"hwlab-patch-panel": Object.freeze({ urlEnv: "HWLAB_PATCH_PANEL_URL", defaultUrl: "http://hwlab-patch-panel.hwlab-dev.svc.cluster.local:7301" }),
"hwlab-router": Object.freeze({ urlEnv: "HWLAB_ROUTER_URL", defaultUrl: "http://hwlab-router.hwlab-dev.svc.cluster.local:7401" }),
"hwlab-tunnel-client": Object.freeze({ urlEnv: "HWLAB_TUNNEL_CLIENT_URL", defaultUrl: "http://hwlab-tunnel-client.hwlab-dev.svc.cluster.local:7402" }),
"hwlab-edge-proxy": Object.freeze({ urlEnv: "HWLAB_EDGE_PROXY_URL", defaultUrl: "http://hwlab-edge-proxy.hwlab-dev.svc.cluster.local:6667", healthPath: "/health" }),
"hwlab-cli": Object.freeze({ urlEnv: "HWLAB_CLI_URL", defaultUrl: "http://hwlab-cli.hwlab-dev.svc.cluster.local:7501" }),
"hwlab-agent-skills": Object.freeze({ urlEnv: "HWLAB_AGENT_SKILLS_URL", defaultUrl: "http://hwlab-agent-skills.hwlab-dev.svc.cluster.local:7430" })
});
const LIVE_BUILD_EXTERNAL_COMPONENTS = Object.freeze([
Object.freeze({
serviceId: "hwlab-frpc",
serviceName: "hwlab-frpc",
kind: "external",
externalImage: true,
defaultImage: "127.0.0.1:5000/hwlab/frpc:v0.68.1",
externalReason: "外部镜像或非 HWLAB 构建产物"
})
]);
export function createCloudApiServer(options = {}) {
@@ -336,8 +354,10 @@ async function handleRestAdapter(request, response, url, options) {
async function buildLiveBuildsPayload(options = {}) {
const env = options.env ?? process.env;
const metadata = await loadLiveBuildMetadata(options);
const inventory = liveBuildServiceInventory(metadata);
const services = await Promise.all(
LIVE_BUILD_SERVICE_ENDPOINTS.map((service) => observeLiveBuildService(service, { ...options, env }))
inventory.map((service) => observeLiveBuildService(service, { ...options, env }))
);
const hwlabServices = services.filter((service) => service.kind === "hwlab");
const latest = [...hwlabServices]
@@ -351,10 +371,14 @@ async function buildLiveBuildsPayload(options = {}) {
contractVersion: "live-builds-v1",
observedAt: new Date().toISOString(),
source: {
kind: "live-health",
kind: "live-health+artifact-catalog",
route: "/v1/live-builds",
healthPath: "/health/live",
note: "Each row is read from the current live service health payload or marked unavailable/external; observedAt is not used as build time."
metadataPaths: LIVE_BUILD_METADATA_PATHS,
desiredStateSource: metadata.deployManifest.status === "ok" ? LIVE_BUILD_METADATA_PATHS.deployManifest : "unavailable",
artifactCatalogSource: metadata.artifactCatalog.status === "ok" ? LIVE_BUILD_METADATA_PATHS.artifactCatalog : "unavailable",
artifactReportSource: metadata.artifactReport.status === "ok" ? LIVE_BUILD_METADATA_PATHS.artifactReport : "unavailable",
note: "Each row prefers current live service health build metadata and falls back only to repo-owned deploy/artifact metadata; observedAt is not used as build time."
},
latest,
counts: {
@@ -378,6 +402,7 @@ async function observeLiveBuildService(service, options) {
});
}
const metadataRecord = liveBuildRecordFromMetadata(service);
if (service.serviceId === CLOUD_API_SERVICE_ID) {
const health = await buildHealthPayload(options);
return liveBuildRecordFromHealth(service, {
@@ -385,12 +410,12 @@ async function observeLiveBuildService(service, options) {
status: 200,
url: "/health/live",
payload: health
});
}, metadataRecord);
}
const baseUrl = String(options.env?.[service.urlEnv] || service.defaultUrl || "").replace(/\/+$/u, "");
if (!baseUrl) {
return unavailableLiveBuildRecord(service, {
return metadataRecord ?? unavailableLiveBuildRecord(service, {
reasonCode: "service_url_unavailable",
reason: `${service.serviceId} 没有配置 live health URL`
});
@@ -399,7 +424,8 @@ async function observeLiveBuildService(service, options) {
const healthPath = service.healthPath ?? "/health/live";
return liveBuildRecordFromHealth(
service,
await fetchServiceHealth(`${baseUrl}${healthPath}`, options.fetchImpl ?? fetch)
await fetchServiceHealth(`${baseUrl}${healthPath}`, options.fetchImpl ?? fetch),
metadataRecord
);
}
@@ -446,14 +472,21 @@ async function fetchServiceHealth(url, fetchImpl) {
}
}
function liveBuildRecordFromHealth(service, result) {
function liveBuildRecordFromHealth(service, result, metadataRecord = null) {
if (!result.ok) {
return unavailableLiveBuildRecord(service, {
healthUrl: result.url,
httpStatus: result.status,
reasonCode: result.reasonCode ?? "health_unavailable",
reason: healthUnavailableReason(service, result.reason ?? `${service.serviceId} health unavailable`)
});
const reason = healthUnavailableReason(service, result.reason ?? `${service.serviceId} health unavailable`);
return metadataRecord
? metadataRecordWithHealthGap(metadataRecord, {
healthUrl: result.url,
httpStatus: result.status,
reason
})
: unavailableLiveBuildRecord(service, {
healthUrl: result.url,
httpStatus: result.status,
reasonCode: result.reasonCode ?? "health_unavailable",
reason
});
}
const payload = result.payload ?? {};
@@ -475,6 +508,15 @@ function liveBuildRecordFromHealth(service, result) {
const imageReference = payload.image?.reference ?? payload.image ?? "unknown";
const commitId = payload.commit?.id ?? payload.revision ?? "unknown";
const createdAt = normalizeHealthBuildTime(payload);
if (!createdAt && metadataRecord?.build?.createdAt) {
return mergeLiveHealthOntoMetadataRecord(metadataRecord, {
service,
result,
payload,
imageReference,
commitId
});
}
return {
serviceId: service.serviceId,
name: service.serviceName,
@@ -482,6 +524,7 @@ function liveBuildRecordFromHealth(service, result) {
status: createdAt ? "ok" : "build_time_unavailable",
healthUrl: result.url,
httpStatus: result.status,
desiredState: desiredStateSummary(service),
build: {
createdAt,
source: payload.build?.source ?? payload.commit?.source ?? "health-payload",
@@ -501,6 +544,102 @@ function liveBuildRecordFromHealth(service, result) {
};
}
function metadataRecordWithHealthGap(metadataRecord, { healthUrl, httpStatus, reason }) {
return {
...metadataRecord,
healthUrl,
httpStatus,
build: {
...metadataRecord.build,
liveHealthMissingReason: `live health 不可用,已使用 repo-owned artifact metadata${reason}`
}
};
}
function mergeLiveHealthOntoMetadataRecord(metadataRecord, { service, result, payload, imageReference, commitId }) {
return {
...metadataRecord,
serviceId: service.serviceId,
name: service.serviceName,
kind: "hwlab",
status: "ok",
healthUrl: result.url,
httpStatus: result.status,
build: {
...metadataRecord.build,
metadataSource: metadataRecord.build.metadataSource,
unavailableReason: null,
liveHealthMissingReason: "health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt,已使用 repo-owned artifact metadata"
},
image: {
reference: imageReference !== "unknown" ? imageReference : metadataRecord.image.reference,
tag: payload.image?.tag ?? imageTagFromReference(imageReference) ?? metadataRecord.image.tag,
digest: payload.image?.digest ?? metadataRecord.image.digest
},
commit: {
id: commitId !== "unknown" ? commitId : metadataRecord.commit.id,
source: payload.commit?.source ?? metadataRecord.commit.source
},
revision: payload.revision ?? (commitId !== "unknown" ? commitId : metadataRecord.revision)
};
}
function liveBuildRecordFromMetadata(service) {
const createdAt = normalizeIsoTimestamp(
service.artifact?.buildCreatedAt ??
service.report?.buildCreatedAt ??
service.deploy?.env?.HWLAB_BUILD_CREATED_AT ??
null
);
if (!createdAt) return null;
const imageReference = service.artifact?.image ?? service.deploy?.image ?? "unknown";
const commitId = service.artifact?.commitId ?? service.report?.sourceCommitId ?? service.deploy?.env?.HWLAB_COMMIT_ID ?? imageTagFromReference(imageReference) ?? "unknown";
const digest = service.artifact?.digest ?? service.report?.digest ?? service.deploy?.env?.HWLAB_IMAGE_DIGEST ?? "unknown";
const source = service.artifact?.buildSource ?? service.report?.buildSource ?? service.deploy?.env?.HWLAB_BUILD_SOURCE ?? "repo-owned-artifact-metadata";
return {
serviceId: service.serviceId,
name: service.serviceName,
kind: "hwlab",
status: "ok",
healthUrl: null,
httpStatus: null,
desiredState: desiredStateSummary(service),
build: {
createdAt,
source,
metadataSource: artifactMetadataSource(service),
unavailableReason: null
},
image: {
reference: imageReference,
tag: service.artifact?.imageTag ?? service.deploy?.env?.HWLAB_IMAGE_TAG ?? imageTagFromReference(imageReference) ?? "unknown",
digest
},
commit: {
id: commitId,
source: service.artifact ? "artifact-catalog" : service.report ? "artifact-report" : "deploy-desired-state"
},
revision: service.deploy?.env?.HWLAB_REVISION ?? commitId
};
}
function desiredStateSummary(service) {
return {
namespace: service.deploy?.namespace ?? service.artifact?.namespace ?? "hwlab-dev",
profile: service.deploy?.profile ?? service.artifact?.profile ?? ENVIRONMENT_DEV,
replicas: Number.isFinite(Number(service.deploy?.replicas)) ? Number(service.deploy.replicas) : null,
healthPath: service.healthPath ?? service.deploy?.healthPath ?? service.artifact?.healthPath ?? "/health/live",
source: service.deploy ? LIVE_BUILD_METADATA_PATHS.deployManifest : "service-defaults"
};
}
function artifactMetadataSource(service) {
if (service.artifact?.buildCreatedAt) return "artifact-catalog:buildCreatedAt";
if (service.report?.buildCreatedAt) return "artifact-report:buildCreatedAt";
return "deploy-env:HWLAB_BUILD_CREATED_AT";
}
function unavailableLiveBuildRecord(service, { healthUrl = null, httpStatus = null, reasonCode, reason }) {
return {
serviceId: service.serviceId,
@@ -509,6 +648,7 @@ function unavailableLiveBuildRecord(service, { healthUrl = null, httpStatus = nu
status: "unavailable",
healthUrl,
httpStatus,
desiredState: desiredStateSummary(service),
build: {
createdAt: null,
metadataSource: "unavailable",
@@ -568,8 +708,8 @@ function externalLiveBuildRecord(service, {
}
function healthUnavailableReason(service, reason) {
if (service.activation === "suspended-job-template") {
return `${service.serviceId} 是 suspended Job template,当前 live desired replicas=0,无可读取的常驻 /health/live`;
if (Number(service.deploy?.replicas) === 0) {
return `${service.serviceId} 当前 hwlab-dev desired replicas=0,无可读取的常驻 /health/live${reason}`;
}
if (service.activation === "manual") {
return `${service.serviceId} 是手动激活服务,当前 live health 不可用:${reason}`;
@@ -587,6 +727,123 @@ function normalizeHealthBuildTime(payload) {
);
}
async function loadLiveBuildMetadata(options = {}) {
if (options.liveBuildMetadata) return normalizeLiveBuildMetadata(options.liveBuildMetadata);
const root = options.repoRoot ?? process.env.HWLAB_REPO_ROOT ?? repoRoot;
const [deployManifest, artifactCatalog, artifactReport] = await Promise.all([
readJsonMetadata(root, LIVE_BUILD_METADATA_PATHS.deployManifest),
readJsonMetadata(root, LIVE_BUILD_METADATA_PATHS.artifactCatalog),
readJsonMetadata(root, LIVE_BUILD_METADATA_PATHS.artifactReport)
]);
return normalizeLiveBuildMetadata({ deployManifest, artifactCatalog, artifactReport });
}
function normalizeLiveBuildMetadata(metadata) {
return {
deployManifest: normalizeMetadataFile(metadata.deployManifest),
artifactCatalog: normalizeMetadataFile(metadata.artifactCatalog),
artifactReport: normalizeMetadataFile(metadata.artifactReport)
};
}
async function readJsonMetadata(root, relativePath) {
try {
const payload = JSON.parse(await readFile(path.join(root, relativePath), "utf8"));
return { status: "ok", path: relativePath, payload };
} catch (error) {
return {
status: "unavailable",
path: relativePath,
error: error.message,
payload: null
};
}
}
function normalizeMetadataFile(file) {
if (file?.status === "ok" || file?.status === "unavailable") {
return {
status: file.status,
path: file.path ?? null,
error: file.error ?? null,
payload: file.payload ?? null
};
}
return {
status: file ? "ok" : "unavailable",
path: null,
error: null,
payload: file ?? null
};
}
function liveBuildServiceInventory(metadata) {
const deployServices = Array.isArray(metadata.deployManifest.payload?.services)
? metadata.deployManifest.payload.services
: [];
const deployByServiceId = new Map(deployServices.map((service) => [service.serviceId, service]));
const artifactByServiceId = new Map(
(Array.isArray(metadata.artifactCatalog.payload?.services) ? metadata.artifactCatalog.payload.services : [])
.map((service) => [service.serviceId, service])
);
const reportByServiceId = new Map(
(Array.isArray(metadata.artifactReport.payload?.artifactPublish?.services) ? metadata.artifactReport.payload.artifactPublish.services : [])
.map((service) => [service.serviceId, service])
);
const serviceIds = uniqueStrings([
...SERVICE_IDS,
...deployServices.map((service) => service.serviceId),
...artifactByServiceId.keys()
]).filter((serviceId) => String(serviceId).startsWith("hwlab-"));
return [
...serviceIds.map((serviceId) => {
const deploy = deployByServiceId.get(serviceId) ?? null;
const defaults = LIVE_BUILD_SERVICE_DEFAULTS[serviceId] ?? {};
const healthPath = deploy?.healthPath ?? defaults.healthPath ?? "/health/live";
return Object.freeze({
serviceId,
serviceName: deploy?.name ?? serviceId,
urlEnv: defaults.urlEnv ?? serviceUrlEnvName(serviceId),
defaultUrl: defaults.defaultUrl ?? serviceDefaultUrl(serviceId),
healthPath,
activation: deploy?.replicas === 0 ? "zero-replica" : defaults.activation,
deploy,
artifact: artifactByServiceId.get(serviceId) ?? null,
report: reportByServiceId.get(serviceId) ?? null
});
}),
...LIVE_BUILD_EXTERNAL_COMPONENTS
];
}
function uniqueStrings(values) {
return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))];
}
function serviceUrlEnvName(serviceId) {
return `${serviceId.replace(/^hwlab-/u, "HWLAB_").replace(/-/gu, "_").toUpperCase()}_URL`;
}
function serviceDefaultUrl(serviceId) {
const port = {
"hwlab-cloud-api": 6667,
"hwlab-cloud-web": 8080,
"hwlab-agent-mgr": 7410,
"hwlab-agent-worker": 7411,
"hwlab-gateway": 7001,
"hwlab-gateway-simu": 7101,
"hwlab-box-simu": 7201,
"hwlab-patch-panel": 7301,
"hwlab-router": 7401,
"hwlab-tunnel-client": 7402,
"hwlab-edge-proxy": 6667,
"hwlab-cli": 7501,
"hwlab-agent-skills": 7430
}[serviceId] ?? 8080;
return `http://${serviceId}.hwlab-dev.svc.cluster.local:${port}`;
}
async function handleGatewayPollHttp(request, response, options) {
const body = await readBody(request, options.bodyLimitBytes);
let params = {};
+91 -9
View File
@@ -173,7 +173,7 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
}
});
test("cloud api aggregates live HWLAB image build times from health payloads", async () => {
test("cloud api aggregates live HWLAB build times from health and repo-owned artifact metadata", async () => {
const healthByPath = new Map([
["/hwlab-cloud-web/health/live", {
serviceId: "hwlab-cloud-web",
@@ -199,10 +199,6 @@ test("cloud api aggregates live HWLAB image build times from health payloads", a
reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:mgrabcd",
tag: "mgrabcd",
digest: "sha256:" + "2".repeat(64)
},
build: {
createdAt: "2026-05-23T04:20:00.000Z",
metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT"
}
}],
["/external/health", {
@@ -214,6 +210,85 @@ test("cloud api aggregates live HWLAB image build times from health payloads", a
}
}]
]);
const liveBuildMetadata = {
deployManifest: {
services: [
{
serviceId: "hwlab-cloud-web",
image: "127.0.0.1:5000/hwlab/hwlab-cloud-web:webcat",
namespace: "hwlab-dev",
profile: "dev",
healthPath: "/health/live",
replicas: 1
},
{
serviceId: "hwlab-agent-mgr",
image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:mgrcat",
namespace: "hwlab-dev",
profile: "dev",
healthPath: "/health/live",
replicas: 1
},
{
serviceId: "hwlab-agent-worker",
image: "127.0.0.1:5000/hwlab/hwlab-agent-worker:workcat",
namespace: "hwlab-dev",
profile: "dev",
healthPath: "/health/live",
replicas: 0
},
{
serviceId: "hwlab-edge-proxy",
image: "127.0.0.1:5000/hwlab/hwlab-edge-proxy:edgecat",
namespace: "hwlab-dev",
profile: "dev",
healthPath: "/health",
replicas: 1
},
{
serviceId: "hwlab-extra-lab",
image: "127.0.0.1:5000/hwlab/hwlab-extra-lab:extracat",
namespace: "hwlab-dev",
profile: "dev",
healthPath: "/health/live",
replicas: 1
}
]
},
artifactCatalog: {
services: [
{
serviceId: "hwlab-agent-mgr",
commitId: "mgrcatabcdef123456",
image: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:mgrcat",
imageTag: "mgrcat",
digest: "sha256:" + "3".repeat(64),
namespace: "hwlab-dev",
profile: "dev",
healthPath: "/health/live",
buildCreatedAt: "2026-05-23T04:20:00.000Z",
buildSource: "artifact-catalog-test"
},
{
serviceId: "hwlab-extra-lab",
commitId: "extracatabcdef123456",
image: "127.0.0.1:5000/hwlab/hwlab-extra-lab:extracat",
imageTag: "extracat",
digest: "sha256:" + "4".repeat(64),
namespace: "hwlab-dev",
profile: "dev",
healthPath: "/health/live",
buildCreatedAt: "2026-05-23T03:00:00.000Z",
buildSource: "artifact-catalog-test"
}
]
},
artifactReport: {
artifactPublish: {
services: []
}
}
};
const server = createCloudApiServer({
env: {
@@ -233,8 +308,10 @@ test("cloud api aggregates live HWLAB image build times from health payloads", a
HWLAB_TUNNEL_CLIENT_URL: "http://live.test/missing-tunnel-client",
HWLAB_EDGE_PROXY_URL: "http://live.test/external",
HWLAB_CLI_URL: "http://live.test/missing-cli",
HWLAB_AGENT_SKILLS_URL: "http://live.test/missing-agent-skills"
HWLAB_AGENT_SKILLS_URL: "http://live.test/missing-agent-skills",
HWLAB_EXTRA_LAB_URL: "http://live.test/missing-extra-lab"
},
liveBuildMetadata,
fetchImpl: async (url) => {
const path = new URL(url).pathname;
const payload = healthByPath.get(path);
@@ -268,10 +345,15 @@ test("cloud api aggregates live HWLAB image build times from health payloads", a
assert.equal(payload.latest.build.createdAt, "2026-05-23T04:20:00.000Z");
assert.equal(payload.latest.image.tag, "mgrabcd");
assert.equal(payload.latest.commit.id, "mgrabcdef123456");
assert.equal(payload.counts.total, 14);
assert.equal(payload.latest.build.metadataSource, "artifact-catalog:buildCreatedAt");
assert.match(payload.latest.build.liveHealthMissingReason, /health payload 缺少 build\.createdAt/u);
assert.equal(payload.counts.total, 15);
assert.equal(payload.counts.external, 2);
assert.ok(payload.counts.withBuildTime >= 3);
assert.ok(payload.services.some((service) => service.serviceId === "hwlab-agent-worker" && /suspended Job template/u.test(service.build.unavailableReason)));
assert.equal(payload.counts.withBuildTime, 4);
const extra = payload.services.find((service) => service.serviceId === "hwlab-extra-lab");
assert.equal(extra.build.createdAt, "2026-05-23T03:00:00.000Z");
assert.match(extra.build.liveHealthMissingReason, /live health 不可用/u);
assert.ok(payload.services.some((service) => service.serviceId === "hwlab-agent-worker" && /构建时间不可用:hwlab-agent-worker 当前 hwlab-dev desired replicas=0/u.test(service.build.unavailableReason)));
assert.ok(payload.services.some((service) => service.kind === "external" && service.serviceId === "hwlab-edge-proxy" && service.healthUrl.endsWith("/health")));
assert.ok(payload.services.some((service) => service.kind === "external" && service.serviceId === "hwlab-frpc" && service.image.tag === "v0.68.1"));
} finally {
+2
View File
@@ -844,6 +844,8 @@ function dockerfile(baseImage, port) {
"COPY web ./web",
"COPY tools ./tools",
"COPY skills ./skills",
"COPY deploy ./deploy",
"COPY reports ./reports",
"RUN mkdir -p /workspace /codex-home && rm -rf /workspace/hwlab && ln -s /app /workspace/hwlab && chmod -R a+rwX /app /workspace /codex-home && test -x /app/node_modules/.bin/codex && /app/node_modules/.bin/codex --version >/tmp/hwlab-codex-version.txt",
`RUN node -e "const fs=require('node:fs'); fs.writeFileSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', Buffer.from('${runtimeScriptBase64()}', 'base64')); fs.chmodSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', 0o755);"`,
`EXPOSE ${port}`,
+57 -11
View File
@@ -3118,16 +3118,30 @@ async function inspectLiveDom(url, options = {}) {
outerScrollLocked: document.documentElement.scrollHeight <= document.documentElement.clientHeight + 2,
rootAfterScrollAttempt,
liveBuildLatest: document.querySelector("#live-build-latest")?.textContent?.replace(/\s+/gu, " ").trim() ?? "",
liveBuildRows: document.querySelectorAll("#live-build-list .live-build-row").length,
liveBuildDefaultVisible: visible("#live-build-latest"),
liveBuildSummaryOpen: document.querySelector("#live-build-summary")?.open === true,
liveBuildListVisibleWhenClosed: visible("#live-build-list"),
liveBuildListScrollableWhenOpen: (() => {
liveBuildDetailWhenOpen: (() => {
const details = document.querySelector("#live-build-summary");
const list = document.querySelector("#live-build-list");
if (!details || !list) return false;
if (!details || !list) {
return {
visible: false,
rows: 0,
scrollContained: false,
text: "",
overflowX: 0
};
}
details.open = true;
return visible("#live-build-list") && list.scrollHeight >= list.clientHeight && list.clientHeight <= Math.ceil(window.innerHeight * 0.42) + 4;
const rows = [...list.querySelectorAll(".live-build-row")];
return {
visible: visible("#live-build-list"),
rows: rows.length,
scrollContained: list.clientHeight <= Math.ceil(window.innerHeight * 0.42) + 4,
text: list.textContent?.replace(/\s+/gu, " ").trim() ?? "",
overflowX: Math.max(0, ...rows.map((row) => row.scrollWidth - row.clientWidth), list.scrollWidth - list.clientWidth)
};
})(),
firstViewportForbiddenPresent: forbiddenTerms.filter((term) => textHasForbiddenTerm(firstViewportText, term)),
firstViewportTextSample: firstViewportText.slice(0, 400),
@@ -3189,10 +3203,19 @@ async function inspectLiveDom(url, options = {}) {
/最新镜像构建时间:\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} 北京时间/u.test(dom.liveBuildLatest) &&
/hwlab-agent-mgr/u.test(dom.liveBuildLatest) &&
/tag f09ad05/u.test(dom.liveBuildLatest) &&
dom.liveBuildRows === 0 &&
/commit f09ad05/u.test(dom.liveBuildLatest) &&
/revision f09ad05/u.test(dom.liveBuildLatest) &&
dom.liveBuildSummaryOpen === false &&
dom.liveBuildListVisibleWhenClosed === false &&
dom.liveBuildListScrollableWhenOpen &&
dom.liveBuildDetailWhenOpen.visible &&
dom.liveBuildDetailWhenOpen.rows >= 4 &&
dom.liveBuildDetailWhenOpen.scrollContained &&
dom.liveBuildDetailWhenOpen.overflowX <= 1 &&
/hwlab-cloud-api/u.test(dom.liveBuildDetailWhenOpen.text) &&
/hwlab-agent-worker/u.test(dom.liveBuildDetailWhenOpen.text) &&
/构建时间不可用/u.test(dom.liveBuildDetailWhenOpen.text) &&
/外部镜像或非 HWLAB 构建产物/u.test(dom.liveBuildDetailWhenOpen.text) &&
/来源 health metadata/u.test(dom.liveBuildDetailWhenOpen.text) &&
dom.firstViewportForbiddenPresent.length === 0 &&
dom.labelsPresent &&
dom.navLabelsPresent &&
@@ -3227,7 +3250,8 @@ async function inspectLiveDom(url, options = {}) {
`bodyOverflow=${dom.bodyOverflow}`,
`htmlOverflow=${dom.htmlOverflow}`,
`liveBuildLatest=${dom.liveBuildLatest}`,
`liveBuildRowsClosed=${dom.liveBuildRows}`,
`liveBuildRowsOpen=${dom.liveBuildDetailWhenOpen.rows}`,
`liveBuildOverflowX=${dom.liveBuildDetailWhenOpen.overflowX}`,
`firstViewportForbidden=${dom.firstViewportForbiddenPresent.join(",") || "none"}`,
`gateRouteAvailable=${dom.gateRouteAvailable}`,
`wiringLayout=${dom.wiringLayout}`,
@@ -5566,7 +5590,7 @@ function liveBuildsFixturePayload() {
contractVersion: "live-builds-v1",
observedAt: "2026-05-23T00:30:00.000Z",
source: {
kind: "live-health",
kind: "live-health+artifact-catalog",
route: "/v1/live-builds",
healthPath: "/health/live"
},
@@ -5577,7 +5601,8 @@ function liveBuildsFixturePayload() {
status: "ok",
build: {
createdAt: "2026-05-23T00:20:00.000Z",
metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT"
metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT",
liveHealthMissingReason: "health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt,已使用 repo-owned artifact metadata"
},
image: {
reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:f09ad05",
@@ -5591,11 +5616,11 @@ function liveBuildsFixturePayload() {
revision: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4"
},
counts: {
total: 4,
total: 5,
hwlab: 4,
withBuildTime: 2,
unavailable: 2,
external: 0
external: 1
},
services: [
{
@@ -5638,6 +5663,27 @@ function liveBuildsFixturePayload() {
},
revision: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4"
},
{
serviceId: "hwlab-frpc",
name: "hwlab-frpc",
kind: "external",
status: "external",
build: {
createdAt: null,
metadataSource: "external-image",
unavailableReason: "外部镜像或非 HWLAB 构建产物"
},
image: {
reference: "127.0.0.1:5000/hwlab/frpc:v0.68.1",
tag: "v0.68.1",
digest: "unknown"
},
commit: {
id: "unknown",
source: "external-image"
},
revision: "unknown"
},
{
serviceId: "hwlab-agent-worker",
name: "hwlab-agent-worker",
+29 -2
View File
@@ -991,7 +991,8 @@ function renderLiveBuilds(response) {
latest.name || latest.serviceId || "未知服务",
`tag ${latest.image?.tag || "unknown"}`,
`commit ${shortToken(latest.commit?.id)}`,
`revision ${shortToken(latest.revision)}`
`revision ${shortToken(latest.revision)}`,
`来源 ${liveBuildSourceLabel(latest.build?.metadataSource)}`
].join(" · ");
} else {
el.liveBuildLatest.textContent = "最新镜像构建时间:构建时间不可用";
@@ -1040,9 +1041,13 @@ function liveBuildRow(service) {
meta.append(
textSpan(`tag ${service.image?.tag || "unknown"}`),
textSpan(`commit ${shortToken(service.commit?.id)}`),
textSpan(`revision ${shortToken(service.revision)}`)
textSpan(`revision ${shortToken(service.revision)}`),
textSpan(`来源 ${liveBuildSourceLabel(service.build?.metadataSource)}`)
);
const desired = liveBuildDesiredStateText(service);
if (desired) meta.append(textSpan(desired));
const reasonText = liveBuildReasonText(service);
if (reasonText) {
row.append(header, meta, textSpan(reasonText, "live-build-reason"));
@@ -1080,11 +1085,33 @@ function liveBuildTimeText(service) {
function liveBuildReasonText(service) {
if (service.kind === "external") return "外部镜像或非 HWLAB 构建产物,不参与最新 HWLAB 构建时间计算。";
if (service.build?.liveHealthMissingReason) return service.build.liveHealthMissingReason;
if (service.build?.unavailableReason) return service.build.unavailableReason;
if (service.error?.message) return `构建时间不可用:${service.error.message}`;
return "";
}
function liveBuildSourceLabel(source) {
const text = String(source ?? "").trim();
if (text.includes("artifact-catalog")) return "artifact catalog";
if (text.includes("artifact-report")) return "artifact report";
if (text.includes("runtime-env") || text.includes("health")) return "health metadata";
if (text.includes("deploy")) return "desired-state";
if (text.includes("external")) return "external image";
if (!text || text === "unavailable") return "不可用";
return text;
}
function liveBuildDesiredStateText(service) {
if (!service.desiredState || service.kind === "external") return "";
const parts = [];
if (Number.isFinite(Number(service.desiredState.replicas))) {
parts.push(`replicas ${Number(service.desiredState.replicas)}`);
}
if (service.desiredState.healthPath) parts.push(service.desiredState.healthPath);
return parts.length ? `desired ${parts.join(" / ")}` : "";
}
function formatBeijingTime(value) {
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "构建时间不可用";
+4
View File
@@ -910,6 +910,10 @@ assert.match(app, /formatBeijingTime/);
assert.match(app, /Asia\/Shanghai/);
assert.match(app, /最新镜像构建时间/);
assert.match(app, /构建时间不可用/);
assert.match(app, /liveBuildSourceLabel/);
assert.match(app, /liveBuildDesiredStateText/);
assert.match(app, /外部镜像或非 HWLAB 构建产物/);
assert.match(styles, /\.live-build-meta span\s*{[^}]*overflow-wrap:\s*anywhere;/s);
assert.match(app, /fetchJson\("\/health\/live"\)/);
assert.match(app, /callRpc\("system\.health"\)/);
assert.match(app, /callRpc\("cloud\.adapter\.describe"\)/);
@@ -196,6 +196,21 @@ const okM3Status = Object.freeze({
}
});
test("cloud web live build surface renders Beijing summary, details, missing time, and external images", async () => {
const { readFileSync } = await import("node:fs");
const html = readFileSync(new URL("../index.html", import.meta.url), "utf8");
const app = readFileSync(new URL("../app.mjs", import.meta.url), "utf8");
assert.match(html, /id="live-build-summary"/u);
assert.match(html, /id="live-build-latest"/u);
assert.match(html, /id="live-build-list"/u);
assert.match(app, /formatBeijingTime/u);
assert.match(app, /Asia\/Shanghai/u);
assert.match(app, /liveBuildSourceLabel/u);
assert.match(app, /liveBuildDesiredStateText/u);
assert.match(app, /外部镜像或非 HWLAB 构建产物/u);
});
test("classifies fully ready workbench runtime as API 正常", () => {
const status = classifyWorkbenchLiveStatus({
healthLive: okApi,
+11
View File
@@ -503,6 +503,7 @@ h3 {
.live-build-summary {
min-width: 0;
max-width: 100%;
margin-top: 4px;
padding-top: 7px;
border-top: 1px solid var(--line);
@@ -523,11 +524,13 @@ h3 {
.live-build-summary-label {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
}
.live-build-list {
max-height: min(280px, 42dvh);
min-width: 0;
margin-top: 7px;
display: grid;
gap: 6px;
@@ -537,6 +540,7 @@ h3 {
.live-build-row {
min-width: 0;
max-width: 100%;
display: grid;
gap: 3px;
padding: 7px;
@@ -571,6 +575,7 @@ h3 {
.live-build-meta {
min-width: 0;
max-width: 100%;
display: flex;
flex-wrap: wrap;
gap: 4px 7px;
@@ -580,6 +585,12 @@ h3 {
overflow-wrap: anywhere;
}
.live-build-meta span {
min-width: 0;
max-width: 100%;
overflow-wrap: anywhere;
}
.live-build-reason {
color: var(--muted);
font-size: 10px;