diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 711cafc9..db86fac0 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -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 = {}; diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index f773fa81..99386aa3 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -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 { diff --git a/scripts/dev-artifact-publish.mjs b/scripts/dev-artifact-publish.mjs index f4d636b9..86cb19a9 100644 --- a/scripts/dev-artifact-publish.mjs +++ b/scripts/dev-artifact-publish.mjs @@ -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}`, diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index a3fa7449..9eec5b24 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -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", diff --git a/web/hwlab-cloud-web/app.mjs b/web/hwlab-cloud-web/app.mjs index 9a7d256a..2118f15e 100644 --- a/web/hwlab-cloud-web/app.mjs +++ b/web/hwlab-cloud-web/app.mjs @@ -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 "构建时间不可用"; diff --git a/web/hwlab-cloud-web/scripts/check.mjs b/web/hwlab-cloud-web/scripts/check.mjs index 75be8345..659559fe 100644 --- a/web/hwlab-cloud-web/scripts/check.mjs +++ b/web/hwlab-cloud-web/scripts/check.mjs @@ -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"\)/); diff --git a/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs b/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs index 1e423105..35bc28a8 100644 --- a/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs +++ b/web/hwlab-cloud-web/scripts/live-status-contract.test.mjs @@ -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, diff --git a/web/hwlab-cloud-web/styles.css b/web/hwlab-cloud-web/styles.css index a78061ac..6e3591fd 100644 --- a/web/hwlab-cloud-web/styles.css +++ b/web/hwlab-cloud-web/styles.css @@ -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;