Merge pull request #382 from pikasTech/feat/live-build-times-359
feat: show live HWLAB build metadata
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
import { createServer } from "node:http";
|
||||
|
||||
import { buildMetadataFromEnv } from "../../internal/build-metadata.mjs";
|
||||
import {
|
||||
AGENT_MGR_SERVICE_ID,
|
||||
createSkillsManifest
|
||||
@@ -155,6 +156,9 @@ function createHealthResponse(opts) {
|
||||
return {
|
||||
serviceId: AGENT_MGR_SERVICE_ID,
|
||||
ok: true,
|
||||
status: health.status,
|
||||
environment: process.env.HWLAB_ENVIRONMENT || "dev",
|
||||
...buildMetadataFromEnv(process.env, { serviceId: AGENT_MGR_SERVICE_ID }),
|
||||
health,
|
||||
skillsManifest: managerSkills.status === "ready"
|
||||
? createSkillsManifest({ commitId: managerSkills.commitId, version: managerSkills.version })
|
||||
@@ -171,19 +175,7 @@ async function serveHealth(opts) {
|
||||
const server = createServer((request, response) => {
|
||||
const url = new URL(request.url || "/", "http://hwlab-agent-mgr.local");
|
||||
if (url.pathname === "/health" || url.pathname === "/health/live") {
|
||||
sendJson(response, 200, {
|
||||
...createHealthResponse(opts),
|
||||
environment: process.env.HWLAB_ENVIRONMENT || "dev",
|
||||
commit: {
|
||||
id: process.env.HWLAB_COMMIT_ID || "unknown",
|
||||
source: "runtime-env"
|
||||
},
|
||||
image: {
|
||||
reference: process.env.HWLAB_IMAGE || "unknown",
|
||||
tag: process.env.HWLAB_IMAGE_TAG || "unknown",
|
||||
digest: process.env.HWLAB_IMAGE_DIGEST || "unknown"
|
||||
}
|
||||
});
|
||||
sendJson(response, 200, createHealthResponse(opts));
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/" || url.pathname === "/help") {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { buildMetadataFromEnv } from "../../internal/build-metadata.mjs";
|
||||
import { AGENT_WORKER_SERVICE_ID } from "../../internal/agent/index.mjs";
|
||||
import {
|
||||
assessSkillsInjection,
|
||||
@@ -35,6 +36,9 @@ async function dispatch(name, opts) {
|
||||
return {
|
||||
serviceId: AGENT_WORKER_SERVICE_ID,
|
||||
ok: true,
|
||||
status: "ok",
|
||||
environment: process.env.HWLAB_ENVIRONMENT || "dev",
|
||||
...buildMetadataFromEnv(process.env, { serviceId: AGENT_WORKER_SERVICE_ID }),
|
||||
runtimeMode: "local-dry-run",
|
||||
skills: assessSkillsInjection({
|
||||
skillCommitId: opts.skillCommitId,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
const unknownValue = "unknown";
|
||||
|
||||
export function normalizeIsoTimestamp(value) {
|
||||
const source = String(value ?? "").trim();
|
||||
if (!source) return null;
|
||||
if (/^\d+$/u.test(source)) {
|
||||
const seconds = Number.parseInt(source, 10);
|
||||
if (Number.isSafeInteger(seconds)) {
|
||||
const date = new Date(seconds * 1000);
|
||||
if (Number.isFinite(date.getTime())) return date.toISOString();
|
||||
}
|
||||
}
|
||||
const date = new Date(source);
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||
}
|
||||
|
||||
export function imageTagFromReference(imageReference) {
|
||||
const source = String(imageReference ?? "").trim();
|
||||
const slashIndex = source.lastIndexOf("/");
|
||||
const colonIndex = source.lastIndexOf(":");
|
||||
if (colonIndex <= slashIndex) return null;
|
||||
return source.slice(colonIndex + 1) || null;
|
||||
}
|
||||
|
||||
export function shortRevision(value) {
|
||||
const source = String(value ?? "").trim();
|
||||
return source.length >= 7 ? source.slice(0, 7) : source || unknownValue;
|
||||
}
|
||||
|
||||
export function buildMetadataFromEnv(env = process.env, { serviceId = "hwlab-unknown", fallbackImageRepository = null } = {}) {
|
||||
const commitId = firstNonEmpty(env.HWLAB_COMMIT_ID, env.HWLAB_GIT_SHA, env.GIT_COMMIT) ?? unknownValue;
|
||||
const revision = firstNonEmpty(env.HWLAB_REVISION, commitId) ?? unknownValue;
|
||||
const fallbackRepository = fallbackImageRepository ?? `ghcr.io/pikastech/${serviceId}`;
|
||||
const imageReference = firstNonEmpty(env.HWLAB_IMAGE, `${fallbackRepository}:${shortRevision(commitId)}`) ?? unknownValue;
|
||||
const imageTag = firstNonEmpty(env.HWLAB_IMAGE_TAG, imageTagFromReference(imageReference), shortRevision(commitId)) ?? unknownValue;
|
||||
const buildCreatedAt = normalizeIsoTimestamp(
|
||||
firstNonEmpty(env.HWLAB_BUILD_CREATED_AT, env.HWLAB_IMAGE_CREATED_AT, env.SOURCE_DATE_EPOCH, env.BUILD_DATE)
|
||||
);
|
||||
const buildSource = firstNonEmpty(env.HWLAB_BUILD_SOURCE, "runtime-env") ?? "runtime-env";
|
||||
|
||||
return {
|
||||
revision,
|
||||
commit: {
|
||||
id: commitId,
|
||||
source: buildSource
|
||||
},
|
||||
image: {
|
||||
reference: imageReference,
|
||||
tag: imageTag,
|
||||
digest: firstNonEmpty(env.HWLAB_IMAGE_DIGEST, unknownValue) ?? unknownValue
|
||||
},
|
||||
build: {
|
||||
createdAt: buildCreatedAt,
|
||||
source: buildSource,
|
||||
provenance: firstNonEmpty(env.HWLAB_BUILD_PROVENANCE, null),
|
||||
metadataSource: buildCreatedAt ? "runtime-env:HWLAB_BUILD_CREATED_AT" : "unavailable",
|
||||
unavailableReason: buildCreatedAt ? null : "HWLAB_BUILD_CREATED_AT missing from runtime environment"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function firstNonEmpty(...values) {
|
||||
for (const value of values) {
|
||||
if (value === null || value === undefined) continue;
|
||||
const text = String(value).trim();
|
||||
if (text) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
+289
-13
@@ -1,6 +1,11 @@
|
||||
import { createServer } from "node:http";
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import {
|
||||
buildMetadataFromEnv,
|
||||
imageTagFromReference,
|
||||
normalizeIsoTimestamp
|
||||
} from "../build-metadata.mjs";
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV, ERROR_CODES } from "../protocol/index.mjs";
|
||||
import {
|
||||
AUDIT_FIELD_NAMES,
|
||||
@@ -35,6 +40,23 @@ import { createConfiguredCloudRuntimeStore } from "../db/runtime-store.mjs";
|
||||
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" })
|
||||
]);
|
||||
|
||||
export function createCloudApiServer(options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
@@ -61,9 +83,10 @@ export function createCloudApiServer(options = {}) {
|
||||
export async function buildHealthPayload(options = {}) {
|
||||
const serviceId = CLOUD_API_SERVICE_ID;
|
||||
const env = options.env ?? process.env;
|
||||
const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown";
|
||||
const imageReference = process.env.HWLAB_IMAGE || "unknown";
|
||||
const imageTag = process.env.HWLAB_IMAGE_TAG || commitId.slice(0, 7) || "unknown";
|
||||
const metadata = buildMetadataFromEnv(env, {
|
||||
serviceId,
|
||||
fallbackImageRepository: "ghcr.io/pikastech/hwlab-cloud-api"
|
||||
});
|
||||
const dbProbe = await buildDbRuntimeReadiness(env, options.dbProbe);
|
||||
const codeAgent = describeCodeAgentAvailability(env, options);
|
||||
const runtime = await runtimeReadiness(options.runtimeStore ?? createConfiguredCloudRuntimeStore({ ...options, env }));
|
||||
@@ -75,6 +98,7 @@ export async function buildHealthPayload(options = {}) {
|
||||
environment: ENVIRONMENT_DEV,
|
||||
status: readiness.status,
|
||||
ready: readiness.ready,
|
||||
revision: metadata.revision,
|
||||
readiness,
|
||||
blockers: readiness.blockers,
|
||||
blockerCodes: readiness.blockerCodes,
|
||||
@@ -84,16 +108,10 @@ export async function buildHealthPayload(options = {}) {
|
||||
healthPath: "/health",
|
||||
livePath: "/health/live"
|
||||
},
|
||||
commit: {
|
||||
id: commitId,
|
||||
source: process.env.HWLAB_BUILD_SOURCE || "runtime-env"
|
||||
},
|
||||
image: {
|
||||
reference: imageReference,
|
||||
tag: imageTag,
|
||||
digest: process.env.HWLAB_IMAGE_DIGEST || "unknown"
|
||||
},
|
||||
endpoint: process.env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT,
|
||||
commit: metadata.commit,
|
||||
image: metadata.image,
|
||||
build: metadata.build,
|
||||
endpoint: env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT,
|
||||
observedAt: new Date().toISOString(),
|
||||
db,
|
||||
codeAgent,
|
||||
@@ -218,6 +236,11 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/v1/live-builds") {
|
||||
sendJson(response, 200, await buildLiveBuildsPayload(options));
|
||||
return;
|
||||
}
|
||||
|
||||
if (request.method === "GET" && url.pathname === "/v1/gateway/sessions") {
|
||||
sendJson(response, 200, options.gatewayRegistry.describe());
|
||||
return;
|
||||
@@ -311,6 +334,259 @@ async function handleRestAdapter(request, response, url, options) {
|
||||
sendJson(response, statusForRpcResponse(rpcResponse), rpcResponse);
|
||||
}
|
||||
|
||||
async function buildLiveBuildsPayload(options = {}) {
|
||||
const env = options.env ?? process.env;
|
||||
const services = await Promise.all(
|
||||
LIVE_BUILD_SERVICE_ENDPOINTS.map((service) => observeLiveBuildService(service, { ...options, env }))
|
||||
);
|
||||
const hwlabServices = services.filter((service) => service.kind === "hwlab");
|
||||
const latest = [...hwlabServices]
|
||||
.filter((service) => service.build.createdAt)
|
||||
.sort((left, right) => Date.parse(right.build.createdAt) - Date.parse(left.build.createdAt))[0] ?? null;
|
||||
|
||||
return {
|
||||
serviceId: CLOUD_API_SERVICE_ID,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
status: "ok",
|
||||
contractVersion: "live-builds-v1",
|
||||
observedAt: new Date().toISOString(),
|
||||
source: {
|
||||
kind: "live-health",
|
||||
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."
|
||||
},
|
||||
latest,
|
||||
counts: {
|
||||
total: services.length,
|
||||
hwlab: hwlabServices.length,
|
||||
withBuildTime: hwlabServices.filter((service) => Boolean(service.build.createdAt)).length,
|
||||
unavailable: hwlabServices.filter((service) => service.build.createdAt === null).length,
|
||||
external: services.filter((service) => service.kind === "external").length
|
||||
},
|
||||
services
|
||||
};
|
||||
}
|
||||
|
||||
async function observeLiveBuildService(service, options) {
|
||||
if (service.externalImage) {
|
||||
return externalLiveBuildRecord(service, {
|
||||
healthUrl: null,
|
||||
httpStatus: null,
|
||||
imageReference: service.defaultImage ?? "unknown",
|
||||
reason: "外部镜像或非 HWLAB 构建产物"
|
||||
});
|
||||
}
|
||||
|
||||
if (service.serviceId === CLOUD_API_SERVICE_ID) {
|
||||
const health = await buildHealthPayload(options);
|
||||
return liveBuildRecordFromHealth(service, {
|
||||
ok: true,
|
||||
status: 200,
|
||||
url: "/health/live",
|
||||
payload: health
|
||||
});
|
||||
}
|
||||
|
||||
const baseUrl = String(options.env?.[service.urlEnv] || service.defaultUrl || "").replace(/\/+$/u, "");
|
||||
if (!baseUrl) {
|
||||
return unavailableLiveBuildRecord(service, {
|
||||
reasonCode: "service_url_unavailable",
|
||||
reason: `${service.serviceId} 没有配置 live health URL`
|
||||
});
|
||||
}
|
||||
|
||||
const healthPath = service.healthPath ?? "/health/live";
|
||||
return liveBuildRecordFromHealth(
|
||||
service,
|
||||
await fetchServiceHealth(`${baseUrl}${healthPath}`, options.fetchImpl ?? fetch)
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchServiceHealth(url, fetchImpl) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), LIVE_BUILD_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetchImpl(url, {
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
try {
|
||||
payload = text ? JSON.parse(text) : null;
|
||||
} catch {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
url,
|
||||
reasonCode: "invalid_json",
|
||||
reason: `${url} returned non-JSON health payload`
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: response.ok,
|
||||
status: response.status,
|
||||
url,
|
||||
payload,
|
||||
reasonCode: response.ok ? null : "http_error",
|
||||
reason: response.ok ? null : `${url} returned HTTP ${response.status}`
|
||||
};
|
||||
} catch (error) {
|
||||
const timedOut = error?.name === "AbortError";
|
||||
return {
|
||||
ok: false,
|
||||
status: null,
|
||||
url,
|
||||
reasonCode: timedOut ? "timeout" : "request_failed",
|
||||
reason: timedOut ? `${url} timed out after ${LIVE_BUILD_TIMEOUT_MS}ms` : error.message
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
}
|
||||
|
||||
function liveBuildRecordFromHealth(service, result) {
|
||||
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 payload = result.payload ?? {};
|
||||
const payloadServiceId = payload.serviceId ?? service.serviceId;
|
||||
if (!String(payloadServiceId).startsWith("hwlab-")) {
|
||||
return externalLiveBuildRecord(service, {
|
||||
healthUrl: result.url,
|
||||
httpStatus: result.status,
|
||||
imageReference: payload.image?.reference ?? payload.image ?? "unknown",
|
||||
imageTag: payload.image?.tag ?? null,
|
||||
imageDigest: payload.image?.digest ?? "unknown",
|
||||
commitId: payload.commit?.id ?? payload.revision ?? payload.version ?? "unknown",
|
||||
commitSource: payload.commit?.source ?? "external-health",
|
||||
revision: payload.revision ?? payload.commit?.id ?? payload.version ?? "unknown",
|
||||
reason: "外部镜像或非 HWLAB 构建产物"
|
||||
});
|
||||
}
|
||||
|
||||
const imageReference = payload.image?.reference ?? payload.image ?? "unknown";
|
||||
const commitId = payload.commit?.id ?? payload.revision ?? "unknown";
|
||||
const createdAt = normalizeHealthBuildTime(payload);
|
||||
return {
|
||||
serviceId: service.serviceId,
|
||||
name: service.serviceName,
|
||||
kind: "hwlab",
|
||||
status: createdAt ? "ok" : "build_time_unavailable",
|
||||
healthUrl: result.url,
|
||||
httpStatus: result.status,
|
||||
build: {
|
||||
createdAt,
|
||||
source: payload.build?.source ?? payload.commit?.source ?? "health-payload",
|
||||
metadataSource: payload.build?.metadataSource ?? (createdAt ? "health:build.createdAt" : "unavailable"),
|
||||
unavailableReason: createdAt ? null : "构建时间不可用:health payload 缺少 build.createdAt / image.createdAt / buildCreatedAt"
|
||||
},
|
||||
image: {
|
||||
reference: imageReference,
|
||||
tag: payload.image?.tag ?? imageTagFromReference(imageReference) ?? "unknown",
|
||||
digest: payload.image?.digest ?? "unknown"
|
||||
},
|
||||
commit: {
|
||||
id: commitId,
|
||||
source: payload.commit?.source ?? "health-payload"
|
||||
},
|
||||
revision: payload.revision ?? commitId
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableLiveBuildRecord(service, { healthUrl = null, httpStatus = null, reasonCode, reason }) {
|
||||
return {
|
||||
serviceId: service.serviceId,
|
||||
name: service.serviceName,
|
||||
kind: "hwlab",
|
||||
status: "unavailable",
|
||||
healthUrl,
|
||||
httpStatus,
|
||||
build: {
|
||||
createdAt: null,
|
||||
metadataSource: "unavailable",
|
||||
unavailableReason: `构建时间不可用:${reason}`
|
||||
},
|
||||
image: {
|
||||
reference: "unknown",
|
||||
tag: "unknown",
|
||||
digest: "unknown"
|
||||
},
|
||||
commit: {
|
||||
id: "unknown",
|
||||
source: "unavailable"
|
||||
},
|
||||
revision: "unknown",
|
||||
error: {
|
||||
code: reasonCode,
|
||||
message: reason
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function externalLiveBuildRecord(service, {
|
||||
healthUrl = null,
|
||||
httpStatus = null,
|
||||
imageReference = "unknown",
|
||||
imageTag = null,
|
||||
imageDigest = "unknown",
|
||||
commitId = "unknown",
|
||||
commitSource = "external-image",
|
||||
revision = commitId,
|
||||
reason
|
||||
}) {
|
||||
return {
|
||||
serviceId: service.serviceId,
|
||||
name: service.serviceName,
|
||||
kind: "external",
|
||||
status: "external",
|
||||
healthUrl,
|
||||
httpStatus,
|
||||
build: {
|
||||
createdAt: null,
|
||||
metadataSource: "external-image",
|
||||
unavailableReason: reason
|
||||
},
|
||||
image: {
|
||||
reference: imageReference,
|
||||
tag: imageTag ?? imageTagFromReference(imageReference) ?? "unknown",
|
||||
digest: imageDigest
|
||||
},
|
||||
commit: {
|
||||
id: commitId,
|
||||
source: commitSource
|
||||
},
|
||||
revision
|
||||
};
|
||||
}
|
||||
|
||||
function healthUnavailableReason(service, reason) {
|
||||
if (service.activation === "suspended-job-template") {
|
||||
return `${service.serviceId} 是 suspended Job template,当前 live desired replicas=0,无可读取的常驻 /health/live`;
|
||||
}
|
||||
if (service.activation === "manual") {
|
||||
return `${service.serviceId} 是手动激活服务,当前 live health 不可用:${reason}`;
|
||||
}
|
||||
return reason;
|
||||
}
|
||||
|
||||
function normalizeHealthBuildTime(payload) {
|
||||
return normalizeIsoTimestamp(
|
||||
payload?.build?.createdAt ??
|
||||
payload?.image?.createdAt ??
|
||||
payload?.buildCreatedAt ??
|
||||
payload?.metadata?.buildCreatedAt ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function handleGatewayPollHttp(request, response, options) {
|
||||
const body = await readBody(request, options.bodyLimitBytes);
|
||||
let params = {};
|
||||
|
||||
@@ -55,6 +55,9 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
assert.equal(healthPayload.status, "degraded");
|
||||
assert.equal(healthPayload.commit.id.length > 0, true);
|
||||
assert.equal(healthPayload.image.reference.length > 0, true);
|
||||
assert.equal(typeof healthPayload.revision, "string");
|
||||
assert.equal(typeof healthPayload.build, "object");
|
||||
assert.equal(Object.hasOwn(healthPayload.build, "createdAt"), true);
|
||||
assert.equal(healthPayload.service.id, "hwlab-cloud-api");
|
||||
assert.equal(healthPayload.db.status, "blocked");
|
||||
assert.equal(healthPayload.db.connected, false);
|
||||
@@ -138,6 +141,7 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
assert.equal(healthLivePayload.serviceId, "hwlab-cloud-api");
|
||||
assert.equal(healthLivePayload.status, "degraded");
|
||||
assert.equal(healthLivePayload.commit.id.length > 0, true);
|
||||
assert.equal(typeof healthLivePayload.build, "object");
|
||||
assert.equal(healthLivePayload.db.ready, false);
|
||||
assert.equal(healthLivePayload.codeAgent.status, "partial");
|
||||
assert.equal(healthLivePayload.codeAgent.ready, false);
|
||||
@@ -168,6 +172,114 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api aggregates live HWLAB image build times from health payloads", async () => {
|
||||
const healthByPath = new Map([
|
||||
["/hwlab-cloud-web/health/live", {
|
||||
serviceId: "hwlab-cloud-web",
|
||||
status: "ok",
|
||||
revision: "webabcdef123456",
|
||||
commit: { id: "webabcdef123456", source: "test" },
|
||||
image: {
|
||||
reference: "127.0.0.1:5000/hwlab/hwlab-cloud-web:webabcd",
|
||||
tag: "webabcd",
|
||||
digest: "sha256:" + "1".repeat(64)
|
||||
},
|
||||
build: {
|
||||
createdAt: "2026-05-23T02:10:00.000Z",
|
||||
metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT"
|
||||
}
|
||||
}],
|
||||
["/hwlab-agent-mgr/health/live", {
|
||||
serviceId: "hwlab-agent-mgr",
|
||||
status: "ok",
|
||||
revision: "mgrabcdef123456",
|
||||
commit: { id: "mgrabcdef123456", source: "test" },
|
||||
image: {
|
||||
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", {
|
||||
serviceId: "postgres",
|
||||
status: "ok",
|
||||
image: {
|
||||
reference: "postgres:16",
|
||||
tag: "16"
|
||||
}
|
||||
}]
|
||||
]);
|
||||
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
PATH: "",
|
||||
HWLAB_COMMIT_ID: "apiabcdef123456",
|
||||
HWLAB_IMAGE: "127.0.0.1:5000/hwlab/hwlab-cloud-api:apiabcd",
|
||||
HWLAB_IMAGE_TAG: "apiabcd",
|
||||
HWLAB_BUILD_CREATED_AT: "2026-05-23T01:00:00.000Z",
|
||||
HWLAB_CLOUD_WEB_SERVICE_URL: "http://live.test/hwlab-cloud-web",
|
||||
HWLAB_AGENT_MGR_URL: "http://live.test/hwlab-agent-mgr",
|
||||
HWLAB_AGENT_WORKER_URL: "http://live.test/missing-agent-worker",
|
||||
HWLAB_GATEWAY_URL: "http://live.test/missing-gateway",
|
||||
HWLAB_GATEWAY_SIMU_URL: "http://live.test/missing-gateway-simu",
|
||||
HWLAB_BOX_SIMU_URL: "http://live.test/missing-box-simu",
|
||||
HWLAB_PATCH_PANEL_URL: "http://live.test/missing-patch-panel",
|
||||
HWLAB_ROUTER_URL: "http://live.test/missing-router",
|
||||
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"
|
||||
},
|
||||
fetchImpl: async (url) => {
|
||||
const path = new URL(url).pathname;
|
||||
const payload = healthByPath.get(path);
|
||||
if (!payload) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
async text() {
|
||||
return JSON.stringify({ error: "offline" });
|
||||
}
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
async text() {
|
||||
return JSON.stringify(payload);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/v1/live-builds`);
|
||||
assert.equal(response.status, 200);
|
||||
const payload = await response.json();
|
||||
assert.equal(payload.contractVersion, "live-builds-v1");
|
||||
assert.equal(payload.latest.serviceId, "hwlab-agent-mgr");
|
||||
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.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.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 {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api health reports DB env presence and live connection classification without leaking values", async () => {
|
||||
const originalUrl = process.env.HWLAB_CLOUD_DB_URL;
|
||||
const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createServer, request as httpRequest } from "node:http";
|
||||
|
||||
import { buildMetadataFromEnv } from "../build-metadata.mjs";
|
||||
import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../protocol/index.mjs";
|
||||
|
||||
const DEFAULT_PROXY_TIMEOUT_MS = 150000;
|
||||
@@ -21,29 +22,25 @@ export function resolveHostPort({ listenEnv, hostEnv, portEnv, fallbackPort }) {
|
||||
}
|
||||
|
||||
export function healthPayload({ serviceId, role, details = {} }) {
|
||||
const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown";
|
||||
const imageReference = process.env.HWLAB_IMAGE || `ghcr.io/pikastech/${serviceId}:${commitId.slice(0, 7) || "unknown"}`;
|
||||
const imageTag = process.env.HWLAB_IMAGE_TAG || commitId.slice(0, 7) || "unknown";
|
||||
const metadata = buildMetadataFromEnv(process.env, {
|
||||
serviceId,
|
||||
fallbackImageRepository: `ghcr.io/pikastech/${serviceId}`
|
||||
});
|
||||
|
||||
return {
|
||||
serviceId,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
status: "ok",
|
||||
revision: metadata.revision,
|
||||
service: {
|
||||
id: serviceId,
|
||||
role,
|
||||
healthPath: "/health",
|
||||
livePath: "/health/live"
|
||||
},
|
||||
commit: {
|
||||
id: commitId,
|
||||
source: process.env.HWLAB_BUILD_SOURCE || "runtime-env"
|
||||
},
|
||||
image: {
|
||||
reference: imageReference,
|
||||
tag: imageTag,
|
||||
digest: process.env.HWLAB_IMAGE_DIGEST || "unknown"
|
||||
},
|
||||
commit: metadata.commit,
|
||||
image: metadata.image,
|
||||
build: metadata.build,
|
||||
endpoint: process.env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT,
|
||||
observedAt: new Date().toISOString(),
|
||||
details
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
|
||||
import { buildMetadataFromEnv } from "../build-metadata.mjs";
|
||||
|
||||
export const DEFAULT_PROJECT_ID = "prj_mvp_topology";
|
||||
export const DEFAULT_GATEWAY_SESSION_ID = "gws_mvp_simu";
|
||||
@@ -356,11 +357,17 @@ export function createGatewayState({
|
||||
}
|
||||
|
||||
export function createHealth({ serviceId, name, now = isoNow() }) {
|
||||
const metadata = buildMetadataFromEnv(process.env, { serviceId });
|
||||
return {
|
||||
serviceId,
|
||||
name,
|
||||
environment: ENVIRONMENT_DEV,
|
||||
live: true,
|
||||
status: "ok",
|
||||
revision: metadata.revision,
|
||||
commit: metadata.commit,
|
||||
image: metadata.image,
|
||||
build: metadata.build,
|
||||
observedAt: now
|
||||
};
|
||||
}
|
||||
|
||||
+1
-1
File diff suppressed because one or more lines are too long
@@ -381,15 +381,68 @@ function nonEmptyEnv(name, fallback) {
|
||||
}
|
||||
|
||||
function healthPayload() {
|
||||
const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown";
|
||||
const imageReference = process.env.HWLAB_IMAGE || "unknown";
|
||||
const imageTag = process.env.HWLAB_IMAGE_TAG || imageTagFromReference(imageReference) || shortRevision(commitId);
|
||||
const buildCreatedAt = normalizeIsoTimestamp(
|
||||
process.env.HWLAB_BUILD_CREATED_AT ||
|
||||
process.env.HWLAB_IMAGE_CREATED_AT ||
|
||||
process.env.SOURCE_DATE_EPOCH ||
|
||||
process.env.BUILD_DATE
|
||||
);
|
||||
const buildSource = process.env.HWLAB_BUILD_SOURCE || "runtime-env";
|
||||
return {
|
||||
serviceId,
|
||||
environment,
|
||||
status: "ok",
|
||||
artifactKind: runtimeKind,
|
||||
revision: process.env.HWLAB_COMMIT_ID || "unknown"
|
||||
revision: process.env.HWLAB_REVISION || commitId,
|
||||
commit: {
|
||||
id: commitId,
|
||||
source: buildSource
|
||||
},
|
||||
image: {
|
||||
reference: imageReference,
|
||||
tag: imageTag,
|
||||
digest: process.env.HWLAB_IMAGE_DIGEST || "unknown"
|
||||
},
|
||||
build: {
|
||||
createdAt: buildCreatedAt,
|
||||
source: buildSource,
|
||||
provenance: process.env.HWLAB_BUILD_PROVENANCE || null,
|
||||
metadataSource: buildCreatedAt ? "runtime-env:HWLAB_BUILD_CREATED_AT" : "unavailable",
|
||||
unavailableReason: buildCreatedAt ? null : "HWLAB_BUILD_CREATED_AT missing from runtime environment"
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeIsoTimestamp(value) {
|
||||
const source = String(value || "").trim();
|
||||
if (!source) return null;
|
||||
if (/^\d+$/u.test(source)) {
|
||||
const seconds = Number.parseInt(source, 10);
|
||||
if (Number.isSafeInteger(seconds)) {
|
||||
const date = new Date(seconds * 1000);
|
||||
if (Number.isFinite(date.getTime())) return date.toISOString();
|
||||
}
|
||||
}
|
||||
const date = new Date(source);
|
||||
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
|
||||
}
|
||||
|
||||
function imageTagFromReference(imageReference) {
|
||||
const source = String(imageReference || "").trim();
|
||||
const slashIndex = source.lastIndexOf("/");
|
||||
const colonIndex = source.lastIndexOf(":");
|
||||
if (colonIndex <= slashIndex) return null;
|
||||
return source.slice(colonIndex + 1) || null;
|
||||
}
|
||||
|
||||
function shortRevision(value) {
|
||||
const source = String(value || "").trim();
|
||||
return source.length >= 7 ? source.slice(0, 7) : source || "unknown";
|
||||
}
|
||||
|
||||
function runNodeEntrypoint(file) {
|
||||
const child = spawn(process.execPath, [file], {
|
||||
cwd: "/app",
|
||||
@@ -730,6 +783,13 @@ function dockerfile(baseImage, port) {
|
||||
"ARG HWLAB_ARTIFACT_KIND",
|
||||
"ARG HWLAB_SERVICE_ENTRYPOINT",
|
||||
"ARG HWLAB_COMMIT_ID",
|
||||
"ARG HWLAB_REVISION",
|
||||
"ARG HWLAB_IMAGE",
|
||||
"ARG HWLAB_IMAGE_TAG",
|
||||
"ARG HWLAB_IMAGE_DIGEST",
|
||||
"ARG HWLAB_BUILD_CREATED_AT",
|
||||
"ARG HWLAB_BUILD_SOURCE",
|
||||
"ARG HWLAB_BUILD_PROVENANCE",
|
||||
"ARG PORT",
|
||||
"ARG HWLAB_PORT",
|
||||
"ENV CODEX_HOME=/codex-home",
|
||||
@@ -745,6 +805,13 @@ function dockerfile(baseImage, port) {
|
||||
"ENV HWLAB_ARTIFACT_KIND=$HWLAB_ARTIFACT_KIND",
|
||||
"ENV HWLAB_SERVICE_ENTRYPOINT=$HWLAB_SERVICE_ENTRYPOINT",
|
||||
"ENV HWLAB_COMMIT_ID=$HWLAB_COMMIT_ID",
|
||||
"ENV HWLAB_REVISION=$HWLAB_REVISION",
|
||||
"ENV HWLAB_IMAGE=$HWLAB_IMAGE",
|
||||
"ENV HWLAB_IMAGE_TAG=$HWLAB_IMAGE_TAG",
|
||||
"ENV HWLAB_IMAGE_DIGEST=$HWLAB_IMAGE_DIGEST",
|
||||
"ENV HWLAB_BUILD_CREATED_AT=$HWLAB_BUILD_CREATED_AT",
|
||||
"ENV HWLAB_BUILD_SOURCE=$HWLAB_BUILD_SOURCE",
|
||||
"ENV HWLAB_BUILD_PROVENANCE=$HWLAB_BUILD_PROVENANCE",
|
||||
"ENV PORT=$PORT",
|
||||
"ENV HWLAB_PORT=$HWLAB_PORT",
|
||||
"COPY package.json ./package.json",
|
||||
@@ -895,14 +962,17 @@ function hasFatalBlocker(blockers) {
|
||||
return blockers.some((item) => fatalBlockerTypes.has(item.type));
|
||||
}
|
||||
|
||||
async function buildService({ args, repo, commitId, shortCommit, service }) {
|
||||
async function buildService({ args, repo, commitId, shortCommit, service, buildCreatedAt }) {
|
||||
const tag = shortCommit;
|
||||
const ref = imageRef(args.registryPrefix, service.serviceId, tag);
|
||||
const buildSource = `${repo}@${commitId}`;
|
||||
if (!buildableImplementationStates.has(service.implementationState)) {
|
||||
return {
|
||||
...service,
|
||||
image: ref,
|
||||
imageTag: tag,
|
||||
buildCreatedAt,
|
||||
buildSource,
|
||||
status: "blocked_missing_runtime",
|
||||
digest: "not_published"
|
||||
};
|
||||
@@ -915,6 +985,8 @@ async function buildService({ args, repo, commitId, shortCommit, service }) {
|
||||
...service,
|
||||
image: ref,
|
||||
imageTag: tag,
|
||||
buildCreatedAt,
|
||||
buildSource,
|
||||
status: "build_failed",
|
||||
digest: "not_published",
|
||||
blocker: blocker({
|
||||
@@ -933,6 +1005,8 @@ async function buildService({ args, repo, commitId, shortCommit, service }) {
|
||||
...service,
|
||||
image: ref,
|
||||
imageTag: tag,
|
||||
buildCreatedAt,
|
||||
buildSource,
|
||||
status: "build_failed",
|
||||
digest: "not_published",
|
||||
distFreshness,
|
||||
@@ -955,9 +1029,12 @@ async function buildService({ args, repo, commitId, shortCommit, service }) {
|
||||
const labels = [
|
||||
["org.opencontainers.image.source", "https://github.com/pikasTech/HWLAB"],
|
||||
["org.opencontainers.image.revision", commitId],
|
||||
["org.opencontainers.image.created", buildCreatedAt],
|
||||
["org.opencontainers.image.title", service.serviceId],
|
||||
["hwlab.pikastech.local/repo", repo],
|
||||
["hwlab.pikastech.local/commit", commitId],
|
||||
["hwlab.pikastech.local/build-created-at", buildCreatedAt],
|
||||
["hwlab.pikastech.local/build-source", buildSource],
|
||||
["hwlab.pikastech.local/service-id", service.serviceId],
|
||||
["hwlab.pikastech.local/environment", ENVIRONMENT_DEV]
|
||||
];
|
||||
@@ -967,6 +1044,13 @@ async function buildService({ args, repo, commitId, shortCommit, service }) {
|
||||
["HWLAB_ARTIFACT_KIND", service.runtimeKind],
|
||||
["HWLAB_SERVICE_ENTRYPOINT", service.entrypoint ?? ""],
|
||||
["HWLAB_COMMIT_ID", commitId],
|
||||
["HWLAB_REVISION", commitId],
|
||||
["HWLAB_IMAGE", ref],
|
||||
["HWLAB_IMAGE_TAG", tag],
|
||||
["HWLAB_IMAGE_DIGEST", "unknown"],
|
||||
["HWLAB_BUILD_CREATED_AT", buildCreatedAt],
|
||||
["HWLAB_BUILD_SOURCE", buildSource],
|
||||
["HWLAB_BUILD_PROVENANCE", "scripts/dev-artifact-publish.mjs"],
|
||||
["PORT", String(port)],
|
||||
["HWLAB_PORT", String(port)]
|
||||
];
|
||||
@@ -992,6 +1076,8 @@ async function buildService({ args, repo, commitId, shortCommit, service }) {
|
||||
...service,
|
||||
image: ref,
|
||||
imageTag: tag,
|
||||
buildCreatedAt,
|
||||
buildSource,
|
||||
status: "build_failed",
|
||||
digest: "not_published",
|
||||
blocker: blocker({
|
||||
@@ -1008,6 +1094,8 @@ async function buildService({ args, repo, commitId, shortCommit, service }) {
|
||||
...service,
|
||||
image: ref,
|
||||
imageTag: tag,
|
||||
buildCreatedAt,
|
||||
buildSource,
|
||||
status: "built",
|
||||
localImageId: result.stdout.trim(),
|
||||
digest: "not_published"
|
||||
@@ -1132,13 +1220,15 @@ function artifactNotPublishedReason(artifact, mode, fatalBlocked) {
|
||||
return artifact.notPublishedReason ?? "publish_not_run";
|
||||
}
|
||||
|
||||
function artifactRecord({ args, service, commitId, shortCommit, status, digest = "not_published" }) {
|
||||
function artifactRecord({ args, service, commitId, shortCommit, buildCreatedAt = null, buildSource = null, status, digest = "not_published" }) {
|
||||
return {
|
||||
...service,
|
||||
sourceCommitId: commitId,
|
||||
status,
|
||||
image: imageRef(args.registryPrefix, service.serviceId, shortCommit),
|
||||
imageTag: shortCommit,
|
||||
buildCreatedAt,
|
||||
buildSource,
|
||||
digest,
|
||||
repositoryDigest: null
|
||||
};
|
||||
@@ -1165,6 +1255,8 @@ function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlo
|
||||
registryTarget: imageRepository(args.registryPrefix, artifact.serviceId),
|
||||
image,
|
||||
imageTag: artifact.imageTag ?? shortCommit,
|
||||
buildCreatedAt: artifact.buildCreatedAt ?? null,
|
||||
buildSource: artifact.buildSource ?? null,
|
||||
digest,
|
||||
repositoryDigest: artifact.repositoryDigest ?? null,
|
||||
digestStatus: /^sha256:[a-f0-9]{64}$/u.test(digest) ? "real" : "placeholder",
|
||||
@@ -1262,6 +1354,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
|
||||
baseImage: args.baseImage ?? null,
|
||||
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
|
||||
environment: ENVIRONMENT_DEV,
|
||||
buildCreatedAt: artifacts.find((artifact) => artifact.buildCreatedAt)?.buildCreatedAt ?? null,
|
||||
serviceInventory,
|
||||
publishPlan,
|
||||
serviceCount: artifacts.length,
|
||||
@@ -1275,6 +1368,8 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
|
||||
status: artifact.status,
|
||||
image: artifact.image ?? imageRef(args.registryPrefix, artifact.serviceId, shortCommit),
|
||||
imageTag: artifact.imageTag ?? shortCommit,
|
||||
buildCreatedAt: artifact.buildCreatedAt ?? null,
|
||||
buildSource: artifact.buildSource ?? null,
|
||||
digest: artifact.digest ?? "not_published",
|
||||
repositoryDigest: artifact.repositoryDigest ?? null,
|
||||
runtimeKind: artifact.runtimeKind,
|
||||
@@ -1337,6 +1432,7 @@ async function main() {
|
||||
gitValue(["remote", "get-url", "origin"]).catch(() => "git@github.com:pikasTech/HWLAB.git")
|
||||
]);
|
||||
const shortCommit = commitId.slice(0, 7);
|
||||
const buildCreatedAt = new Date().toISOString();
|
||||
const repo = repoLabelFromRemote(remoteUrl);
|
||||
const services = await resolveServices(args.services, catalog);
|
||||
|
||||
@@ -1357,7 +1453,7 @@ async function main() {
|
||||
const artifactByServiceId = new Map(artifacts.map((artifact) => [artifact.serviceId, artifact]));
|
||||
for (const service of services.filter((item) => item.artifactRequired)) {
|
||||
emit("build_start", { serviceId: service.serviceId, image: imageRef(args.registryPrefix, service.serviceId, shortCommit) });
|
||||
const artifact = await buildService({ args, repo, commitId, shortCommit, service });
|
||||
const artifact = await buildService({ args, repo, commitId, shortCommit, service, buildCreatedAt });
|
||||
artifactByServiceId.set(service.serviceId, artifact);
|
||||
if (artifact.blocker) blockers.push(artifact.blocker);
|
||||
emit("build_done", { serviceId: service.serviceId, status: artifact.status, image: artifact.image });
|
||||
@@ -1397,6 +1493,7 @@ async function main() {
|
||||
status: service.status,
|
||||
image: service.image,
|
||||
imageTag: service.imageTag,
|
||||
buildCreatedAt: service.buildCreatedAt,
|
||||
digest: service.digest,
|
||||
distFreshness: service.distFreshness,
|
||||
notPublishedReason: service.notPublishedReason
|
||||
|
||||
@@ -182,6 +182,8 @@ function publishRecordsFromReport(report, target) {
|
||||
serviceId: service.serviceId,
|
||||
image: service.image,
|
||||
imageTag: image.tag,
|
||||
buildCreatedAt: service.buildCreatedAt ?? null,
|
||||
buildSource: service.buildSource ?? null,
|
||||
digest: service.digest,
|
||||
repositoryDigest: service.repositoryDigest ?? `${image.repository}@${service.digest}`
|
||||
});
|
||||
@@ -196,6 +198,9 @@ function updateEnvObject(env, service, target, publishRecord) {
|
||||
if (Object.hasOwn(env, "HWLAB_COMMIT_ID")) env.HWLAB_COMMIT_ID = target.shortCommitId;
|
||||
if (Object.hasOwn(env, "HWLAB_IMAGE")) env.HWLAB_IMAGE = service.image;
|
||||
if (Object.hasOwn(env, "HWLAB_IMAGE_TAG")) env.HWLAB_IMAGE_TAG = service.imageTag;
|
||||
if (Object.hasOwn(env, "HWLAB_REVISION")) env.HWLAB_REVISION = target.commitId;
|
||||
if (Object.hasOwn(env, "HWLAB_BUILD_CREATED_AT")) env.HWLAB_BUILD_CREATED_AT = publishRecord?.buildCreatedAt ?? "";
|
||||
if (Object.hasOwn(env, "HWLAB_BUILD_SOURCE")) env.HWLAB_BUILD_SOURCE = publishRecord?.buildSource ?? "";
|
||||
if (Object.hasOwn(env, "HWLAB_SKILLS_COMMIT_ID")) env.HWLAB_SKILLS_COMMIT_ID = target.shortCommitId;
|
||||
if (Object.hasOwn(env, "HWLAB_IMAGE_DIGEST")) env.HWLAB_IMAGE_DIGEST = publishRecord?.digest ?? "not_published";
|
||||
}
|
||||
@@ -214,6 +219,9 @@ function updateEnvList(envList, service, target, publishRecord) {
|
||||
if (entry.name === "HWLAB_COMMIT_ID") entry.value = target.shortCommitId;
|
||||
if (entry.name === "HWLAB_IMAGE") entry.value = service.image;
|
||||
if (entry.name === "HWLAB_IMAGE_TAG") entry.value = service.imageTag;
|
||||
if (entry.name === "HWLAB_REVISION") entry.value = target.commitId;
|
||||
if (entry.name === "HWLAB_BUILD_CREATED_AT") entry.value = publishRecord?.buildCreatedAt ?? "";
|
||||
if (entry.name === "HWLAB_BUILD_SOURCE") entry.value = publishRecord?.buildSource ?? "";
|
||||
if (entry.name === "HWLAB_SKILLS_COMMIT_ID") entry.value = target.shortCommitId;
|
||||
if (entry.name === "HWLAB_IMAGE_DIGEST") entry.value = publishRecord?.digest ?? "not_published";
|
||||
}
|
||||
@@ -264,6 +272,8 @@ function refreshDocuments({ deploy, catalog, workloads, target, publishRecords,
|
||||
catalogService.commitId = target.shortCommitId;
|
||||
catalogService.image = image;
|
||||
catalogService.imageTag = imageTag;
|
||||
catalogService.buildCreatedAt = publishRecord?.buildCreatedAt ?? null;
|
||||
catalogService.buildSource = publishRecord?.buildSource ?? null;
|
||||
catalogService.digest = publishRecord?.digest ?? "not_published";
|
||||
catalogService.publishState = publishRecord ? "published" : "skeleton-only";
|
||||
catalogService.publishEnabled = inventory?.publishEnabled ?? required;
|
||||
@@ -276,6 +286,7 @@ function refreshDocuments({ deploy, catalog, workloads, target, publishRecords,
|
||||
serviceId,
|
||||
image,
|
||||
imageTag,
|
||||
buildCreatedAt: catalogService.buildCreatedAt,
|
||||
digest: catalogService.digest,
|
||||
publishState: catalogService.publishState,
|
||||
artifactRequired: catalogService.artifactRequired,
|
||||
|
||||
@@ -585,7 +585,7 @@ function runStaticSmoke() {
|
||||
|
||||
addCheck(checks, blockers, "same-origin-readonly-boundary", hasSameOriginReadOnlyBoundary(files.app), "Workbench data access stays same-origin and JSON-RPC diagnostics stay read-only.", {
|
||||
blocker: "safety_blocker",
|
||||
evidence: ["/health/live", "/v1", "/v1/diagnostics/gate", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods]
|
||||
evidence: ["/health/live", "/v1", "/v1/live-builds", "/v1/diagnostics/gate", "/v1/agent/chat", "/json-rpc", ...readOnlyRpcMethods]
|
||||
});
|
||||
|
||||
addCheck(checks, blockers, "api-live-status-attribution", hasApiLiveStatusAttribution(files), "Default workbench status maps raw API health into pass, error, unverified, or read-only with concrete service/API attribution.", {
|
||||
@@ -2329,6 +2329,7 @@ function hasSameOriginReadOnlyBoundary(app) {
|
||||
return (
|
||||
/fetchJson\("\/health\/live"\)/u.test(app) &&
|
||||
/fetchJson\("\/v1"\)/u.test(app) &&
|
||||
/fetchJson\("\/v1\/live-builds"\)/u.test(app) &&
|
||||
/fetchJson\("\/v1\/m3\/status"\)/u.test(app) &&
|
||||
/fetchJson\("\/v1\/diagnostics\/gate"/u.test(app) &&
|
||||
/fetchJson\("\/v1\/agent\/chat"/u.test(app) &&
|
||||
@@ -3087,6 +3088,18 @@ async function inspectLiveDom(url, options = {}) {
|
||||
diagnosticsHidden: diagnostics ? diagnostics.hidden : null,
|
||||
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: (() => {
|
||||
const details = document.querySelector("#live-build-summary");
|
||||
const list = document.querySelector("#live-build-list");
|
||||
if (!details || !list) return false;
|
||||
details.open = true;
|
||||
return visible("#live-build-list") && list.scrollHeight >= list.clientHeight && list.clientHeight <= Math.ceil(window.innerHeight * 0.42) + 4;
|
||||
})(),
|
||||
firstViewportForbiddenPresent: forbiddenTerms.filter((term) => textHasForbiddenTerm(firstViewportText, term)),
|
||||
firstViewportTextSample: firstViewportText.slice(0, 400),
|
||||
labelsPresent: ["硬件资源", "Agent 对话", "工作清单", "可信记录", "使用说明"].every((label) => text.includes(label)),
|
||||
@@ -3143,6 +3156,14 @@ async function inspectLiveDom(url, options = {}) {
|
||||
dom.rootAfterScrollAttempt.windowScrollY === 0 &&
|
||||
dom.rootAfterScrollAttempt.htmlScrollTop === 0 &&
|
||||
dom.rootAfterScrollAttempt.bodyScrollTop === 0 &&
|
||||
dom.liveBuildDefaultVisible &&
|
||||
/最新镜像构建时间:\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 &&
|
||||
dom.liveBuildSummaryOpen === false &&
|
||||
dom.liveBuildListVisibleWhenClosed === false &&
|
||||
dom.liveBuildListScrollableWhenOpen &&
|
||||
dom.firstViewportForbiddenPresent.length === 0 &&
|
||||
dom.labelsPresent &&
|
||||
dom.navLabelsPresent &&
|
||||
@@ -3176,6 +3197,8 @@ async function inspectLiveDom(url, options = {}) {
|
||||
`title=${dom.title}`,
|
||||
`bodyOverflow=${dom.bodyOverflow}`,
|
||||
`htmlOverflow=${dom.htmlOverflow}`,
|
||||
`liveBuildLatest=${dom.liveBuildLatest}`,
|
||||
`liveBuildRowsClosed=${dom.liveBuildRows}`,
|
||||
`firstViewportForbidden=${dom.firstViewportForbiddenPresent.join(",") || "none"}`,
|
||||
`gateRouteAvailable=${dom.gateRouteAvailable}`,
|
||||
`wiringLayout=${dom.wiringLayout}`,
|
||||
@@ -5236,6 +5259,10 @@ async function handleQuickPromptsFixtureApi({ request, response, url }) {
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/v1/live-builds") {
|
||||
jsonResponse(response, 200, liveBuildsFixturePayload());
|
||||
return true;
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/v1/m3/io") {
|
||||
jsonResponse(response, 200, {
|
||||
status: "blocked",
|
||||
@@ -5290,6 +5317,10 @@ async function handleM3StatusFixtureApi({ request, response, url, options = {} }
|
||||
jsonResponse(response, 200, m3StatusFixtureHealth(options.m3State));
|
||||
return true;
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/v1/live-builds") {
|
||||
jsonResponse(response, 200, liveBuildsFixturePayload());
|
||||
return true;
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/v1/m3/io") {
|
||||
jsonResponse(response, 200, {
|
||||
status: "available",
|
||||
@@ -5498,6 +5529,132 @@ function m3StatusFixturePayload(m3State = { doValue: false, diValue: false, obse
|
||||
};
|
||||
}
|
||||
|
||||
function liveBuildsFixturePayload() {
|
||||
return {
|
||||
serviceId: "hwlab-cloud-api",
|
||||
environment: "dev",
|
||||
status: "ok",
|
||||
contractVersion: "live-builds-v1",
|
||||
observedAt: "2026-05-23T00:30:00.000Z",
|
||||
source: {
|
||||
kind: "live-health",
|
||||
route: "/v1/live-builds",
|
||||
healthPath: "/health/live"
|
||||
},
|
||||
latest: {
|
||||
serviceId: "hwlab-agent-mgr",
|
||||
name: "hwlab-agent-mgr",
|
||||
kind: "hwlab",
|
||||
status: "ok",
|
||||
build: {
|
||||
createdAt: "2026-05-23T00:20:00.000Z",
|
||||
metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT"
|
||||
},
|
||||
image: {
|
||||
reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:f09ad05",
|
||||
tag: "f09ad05",
|
||||
digest: "sha256:" + "1".repeat(64)
|
||||
},
|
||||
commit: {
|
||||
id: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4",
|
||||
source: "fixture"
|
||||
},
|
||||
revision: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4"
|
||||
},
|
||||
counts: {
|
||||
total: 4,
|
||||
hwlab: 4,
|
||||
withBuildTime: 2,
|
||||
unavailable: 2,
|
||||
external: 0
|
||||
},
|
||||
services: [
|
||||
{
|
||||
serviceId: "hwlab-cloud-api",
|
||||
name: "hwlab-cloud-api",
|
||||
kind: "hwlab",
|
||||
status: "ok",
|
||||
build: {
|
||||
createdAt: "2026-05-23T00:10:00.000Z",
|
||||
metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT"
|
||||
},
|
||||
image: {
|
||||
reference: "127.0.0.1:5000/hwlab/hwlab-cloud-api:f09ad05",
|
||||
tag: "f09ad05",
|
||||
digest: "sha256:" + "0".repeat(64)
|
||||
},
|
||||
commit: {
|
||||
id: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4",
|
||||
source: "fixture"
|
||||
},
|
||||
revision: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4"
|
||||
},
|
||||
{
|
||||
serviceId: "hwlab-agent-mgr",
|
||||
name: "hwlab-agent-mgr",
|
||||
kind: "hwlab",
|
||||
status: "ok",
|
||||
build: {
|
||||
createdAt: "2026-05-23T00:20:00.000Z",
|
||||
metadataSource: "runtime-env:HWLAB_BUILD_CREATED_AT"
|
||||
},
|
||||
image: {
|
||||
reference: "127.0.0.1:5000/hwlab/hwlab-agent-mgr:f09ad05",
|
||||
tag: "f09ad05",
|
||||
digest: "sha256:" + "1".repeat(64)
|
||||
},
|
||||
commit: {
|
||||
id: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4",
|
||||
source: "fixture"
|
||||
},
|
||||
revision: "f09ad05dec5f2bbca12ae661b140a87dfcbf54a4"
|
||||
},
|
||||
{
|
||||
serviceId: "hwlab-agent-worker",
|
||||
name: "hwlab-agent-worker",
|
||||
kind: "hwlab",
|
||||
status: "unavailable",
|
||||
build: {
|
||||
createdAt: null,
|
||||
metadataSource: "unavailable",
|
||||
unavailableReason: "构建时间不可用:hwlab-agent-worker 是 suspended Job template,当前 live desired replicas=0"
|
||||
},
|
||||
image: {
|
||||
reference: "unknown",
|
||||
tag: "unknown",
|
||||
digest: "unknown"
|
||||
},
|
||||
commit: {
|
||||
id: "unknown",
|
||||
source: "unavailable"
|
||||
},
|
||||
revision: "unknown"
|
||||
},
|
||||
{
|
||||
serviceId: "hwlab-cli",
|
||||
name: "hwlab-cli",
|
||||
kind: "hwlab",
|
||||
status: "unavailable",
|
||||
build: {
|
||||
createdAt: null,
|
||||
metadataSource: "unavailable",
|
||||
unavailableReason: "构建时间不可用:hwlab-cli 是 suspended Job template,当前 live desired replicas=0"
|
||||
},
|
||||
image: {
|
||||
reference: "unknown",
|
||||
tag: "unknown",
|
||||
digest: "unknown"
|
||||
},
|
||||
commit: {
|
||||
id: "unknown",
|
||||
source: "unavailable"
|
||||
},
|
||||
revision: "unknown"
|
||||
}
|
||||
]
|
||||
};
|
||||
}
|
||||
|
||||
async function handleLocalAgentFixtureApi({ request, response, url, options = {} }) {
|
||||
if (request.method === "GET" && (url.pathname === "/health/live" || url.pathname === "/v1")) {
|
||||
jsonResponse(response, 200, {
|
||||
@@ -5536,6 +5693,10 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/v1/live-builds") {
|
||||
jsonResponse(response, 200, liveBuildsFixturePayload());
|
||||
return true;
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/v1/m3/io") {
|
||||
jsonResponse(response, 200, {
|
||||
status: "available",
|
||||
@@ -5613,6 +5774,10 @@ async function handleLocalAgentFixtureApi({ request, response, url, options = {}
|
||||
});
|
||||
return true;
|
||||
}
|
||||
if (request.method === "GET" && url.pathname === "/v1/live-builds") {
|
||||
jsonResponse(response, 200, liveBuildsFixturePayload());
|
||||
return true;
|
||||
}
|
||||
if (request.method === "POST" && url.pathname === "/json-rpc") {
|
||||
const body = await readJsonBody(request);
|
||||
jsonResponse(response, 200, {
|
||||
|
||||
@@ -332,6 +332,7 @@ export function checkFrontendNoDirectRuntimeCalls({ appSource, htmlSource = "",
|
||||
"/health/live",
|
||||
"/v1",
|
||||
"/v1/diagnostics/gate",
|
||||
"/v1/live-builds",
|
||||
"/v1/agent/chat",
|
||||
"/v1/m3/io",
|
||||
"/v1/m3/status",
|
||||
|
||||
+134
-1
@@ -99,6 +99,9 @@ const el = {
|
||||
routePath: byId("route-path"),
|
||||
liveStatus: byId("live-status"),
|
||||
liveDetail: byId("live-detail"),
|
||||
liveBuildSummary: byId("live-build-summary"),
|
||||
liveBuildLatest: byId("live-build-latest"),
|
||||
liveBuildList: byId("live-build-list"),
|
||||
agentChatStatus: byId("agent-chat-status"),
|
||||
codeAgentSummary: byId("code-agent-summary"),
|
||||
codeAgentSummaryIcon: byId("code-agent-summary-icon"),
|
||||
@@ -646,6 +649,7 @@ function renderProbePending() {
|
||||
el.liveStatus.textContent = "等待验证";
|
||||
el.liveStatus.className = "tone-pending";
|
||||
el.liveDetail.textContent = "正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/m3/io;未完成前不标为通过。";
|
||||
renderLiveBuilds(null);
|
||||
renderCodeAgentSummary();
|
||||
}
|
||||
|
||||
@@ -683,8 +687,9 @@ async function sendAgentMessage(message, conversationId, traceId = nextProtocolI
|
||||
|
||||
async function loadLiveSurface() {
|
||||
const projectId = gateSummary.topology.projectId;
|
||||
const [healthLive, restIndex, m3Control, m3Status, health, adapter, audit, evidence] = await Promise.all([
|
||||
const [healthLive, liveBuilds, restIndex, m3Control, m3Status, health, adapter, audit, evidence] = await Promise.all([
|
||||
fetchJson("/health/live"),
|
||||
fetchJson("/v1/live-builds"),
|
||||
fetchJson("/v1"),
|
||||
fetchJson("/v1/m3/io"),
|
||||
fetchJson("/v1/m3/status"),
|
||||
@@ -697,6 +702,7 @@ async function loadLiveSurface() {
|
||||
return {
|
||||
observedAt: new Date().toISOString(),
|
||||
healthLive,
|
||||
liveBuilds,
|
||||
restIndex,
|
||||
m3Control,
|
||||
m3Status,
|
||||
@@ -938,6 +944,7 @@ function renderLiveSurface(live) {
|
||||
state.codeAgentAvailability = codeAgentAvailabilityFrom(live);
|
||||
el.liveDetail.textContent = surfaceStatus.detail ?? codeAgentAvailabilitySummary();
|
||||
|
||||
renderLiveBuilds(live.liveBuilds);
|
||||
renderAgentChatStatus(deriveAgentChatStatus());
|
||||
renderCodeAgentSummary();
|
||||
renderHardwareStatus(state.m3Control.status);
|
||||
@@ -948,6 +955,132 @@ function renderLiveSurface(live) {
|
||||
renderDrafts();
|
||||
}
|
||||
|
||||
function renderLiveBuilds(response) {
|
||||
const payload = response?.ok ? response.data : null;
|
||||
const services = Array.isArray(payload?.services) ? payload.services : [];
|
||||
const latest = payload?.latest ?? null;
|
||||
|
||||
if (latest?.build?.createdAt) {
|
||||
el.liveBuildLatest.textContent = [
|
||||
`最新镜像构建时间:${formatBeijingTime(latest.build.createdAt)}`,
|
||||
latest.name || latest.serviceId || "未知服务",
|
||||
`tag ${latest.image?.tag || "unknown"}`,
|
||||
`commit ${shortToken(latest.commit?.id)}`,
|
||||
`revision ${shortToken(latest.revision)}`
|
||||
].join(" · ");
|
||||
} else {
|
||||
el.liveBuildLatest.textContent = "最新镜像构建时间:构建时间不可用";
|
||||
}
|
||||
|
||||
if (!response) {
|
||||
replaceChildren(
|
||||
el.liveBuildList,
|
||||
liveBuildUnavailableRow("等待实时元数据聚合返回。")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
replaceChildren(
|
||||
el.liveBuildList,
|
||||
liveBuildUnavailableRow(`构建时间不可用:${response.error || "实时元数据聚合不可用"}`)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!services.length) {
|
||||
replaceChildren(
|
||||
el.liveBuildList,
|
||||
liveBuildUnavailableRow("构建时间不可用:当前实时元数据未返回微服务明细。")
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
replaceChildren(el.liveBuildList, ...services.map(liveBuildRow));
|
||||
}
|
||||
|
||||
function liveBuildRow(service) {
|
||||
const row = document.createElement("div");
|
||||
row.className = `live-build-row live-build-row-${toneClass(service.status)}`;
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "live-build-row-head";
|
||||
header.append(
|
||||
textSpan(service.name || service.serviceId || "未知服务", "live-build-service"),
|
||||
textSpan(liveBuildTimeText(service), "live-build-time")
|
||||
);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "live-build-meta";
|
||||
meta.append(
|
||||
textSpan(`tag ${service.image?.tag || "unknown"}`),
|
||||
textSpan(`commit ${shortToken(service.commit?.id)}`),
|
||||
textSpan(`revision ${shortToken(service.revision)}`)
|
||||
);
|
||||
|
||||
const reasonText = liveBuildReasonText(service);
|
||||
if (reasonText) {
|
||||
row.append(header, meta, textSpan(reasonText, "live-build-reason"));
|
||||
} else {
|
||||
row.append(header, meta);
|
||||
}
|
||||
return row;
|
||||
}
|
||||
|
||||
function liveBuildUnavailableRow(reason) {
|
||||
return liveBuildRow({
|
||||
serviceId: "live-build-metadata",
|
||||
name: "实时元数据",
|
||||
kind: "hwlab",
|
||||
status: "unavailable",
|
||||
build: {
|
||||
createdAt: null,
|
||||
unavailableReason: reason
|
||||
},
|
||||
image: {
|
||||
tag: "unknown"
|
||||
},
|
||||
commit: {
|
||||
id: "unknown"
|
||||
},
|
||||
revision: "unknown"
|
||||
});
|
||||
}
|
||||
|
||||
function liveBuildTimeText(service) {
|
||||
if (service.kind === "external") return "外部镜像";
|
||||
const createdAt = service.build?.createdAt;
|
||||
return createdAt ? formatBeijingTime(createdAt) : "构建时间不可用";
|
||||
}
|
||||
|
||||
function liveBuildReasonText(service) {
|
||||
if (service.kind === "external") return "外部镜像或非 HWLAB 构建产物,不参与最新 HWLAB 构建时间计算。";
|
||||
if (service.build?.unavailableReason) return service.build.unavailableReason;
|
||||
if (service.error?.message) return `构建时间不可用:${service.error.message}`;
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatBeijingTime(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "构建时间不可用";
|
||||
const parts = Object.fromEntries(new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
hour12: false
|
||||
}).formatToParts(date).map((part) => [part.type, part.value]));
|
||||
return `${parts.year}-${parts.month}-${parts.day} ${parts.hour}:${parts.minute}:${parts.second} 北京时间`;
|
||||
}
|
||||
|
||||
function shortToken(value) {
|
||||
const text = String(value ?? "unknown").trim() || "unknown";
|
||||
return text.length > 12 ? text.slice(0, 12) : text;
|
||||
}
|
||||
|
||||
function workbenchApiSurfaceStatus(live, coreProbes = [live.healthLive, live.restIndex, live.health, live.adapter]) {
|
||||
const status = classifyWorkbenchLiveStatus({
|
||||
...live,
|
||||
|
||||
@@ -49,6 +49,12 @@
|
||||
<span class="metric-label">工作区状态</span>
|
||||
<strong id="live-status" class="tone-pending">等待验证</strong>
|
||||
<small id="live-detail">正在验证 hwlab-cloud-api /health/live、/v1、/v1/agent/chat 与 /v1/m3/io;未完成前不标为通过。</small>
|
||||
<details class="live-build-summary" id="live-build-summary">
|
||||
<summary>
|
||||
<span class="live-build-summary-label" id="live-build-latest">最新镜像构建时间:等待实时元数据</span>
|
||||
</summary>
|
||||
<div class="live-build-list" id="live-build-list" aria-label="live HWLAB 微服务构建时间明细"></div>
|
||||
</details>
|
||||
</div>
|
||||
<button class="logout-button" id="logout-button" type="button">退出</button>
|
||||
</header>
|
||||
|
||||
@@ -878,6 +878,14 @@ assert.match(styles, /@media \(max-width: 860px\)[\s\S]*?\.right-sidebar\s*{[\s\
|
||||
assert.match(styles, /@media \(max-width: 520px\)[\s\S]*?\.command-bar\s*{[\s\S]*?grid-template-columns:\s*minmax\(0, 1fr\) auto auto;/);
|
||||
assert.match(app, /fetchJson\("\/v1"\)/);
|
||||
assert.match(app, /fetchJson\("\/v1\/diagnostics\/gate"/);
|
||||
assert.match(app, /fetchJson\("\/v1\/live-builds"\)/);
|
||||
assert.match(html, /id="live-build-latest"/);
|
||||
assert.match(html, /id="live-build-list"/);
|
||||
assert.match(styles, /\.live-build-summary\s*{/);
|
||||
assert.match(app, /formatBeijingTime/);
|
||||
assert.match(app, /Asia\/Shanghai/);
|
||||
assert.match(app, /最新镜像构建时间/);
|
||||
assert.match(app, /构建时间不可用/);
|
||||
assert.match(app, /fetchJson\("\/health\/live"\)/);
|
||||
assert.match(app, /callRpc\("system\.health"\)/);
|
||||
assert.match(app, /callRpc\("cloud\.adapter\.describe"\)/);
|
||||
|
||||
@@ -416,7 +416,7 @@ h3 {
|
||||
.topbar {
|
||||
min-height: 82px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(260px, 360px) auto;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 460px) auto;
|
||||
gap: 12px;
|
||||
align-items: stretch;
|
||||
padding: 12px;
|
||||
@@ -465,6 +465,91 @@ h3 {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.live-build-summary {
|
||||
min-width: 0;
|
||||
margin-top: 4px;
|
||||
padding-top: 7px;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.live-build-summary summary {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--text);
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
line-height: 1.35;
|
||||
cursor: pointer;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.live-build-summary-label {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.live-build-list {
|
||||
max-height: min(280px, 42dvh);
|
||||
margin-top: 7px;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
.live-build-row {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding: 7px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.live-build-row-head {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 0.8fr) minmax(0, 1.2fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.live-build-service,
|
||||
.live-build-time {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.live-build-service {
|
||||
color: var(--text);
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.live-build-time {
|
||||
color: var(--accent-2);
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.live-build-meta {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 7px;
|
||||
color: var(--dim);
|
||||
font-family: var(--mono);
|
||||
font-size: 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.live-build-reason {
|
||||
color: var(--muted);
|
||||
font-size: 10px;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.view {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
@@ -1855,6 +1940,15 @@ tbody tr:last-child td {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.live-build-row-head {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.live-build-time {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hardware-list,
|
||||
.m3-control-grid,
|
||||
.m3-control-actions {
|
||||
|
||||
Reference in New Issue
Block a user