Merge PR #393: show live build metadata on workbench
Refs #359 #99 #239 #235 #116 #7 #78 #131
This commit is contained in:
+288
-31
@@ -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 = {};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 "构建时间不可用";
|
||||
|
||||
@@ -910,6 +910,10 @@ assert.match(app, /formatBeijingTime/);
|
||||
assert.match(app, /Asia\/Shanghai/);
|
||||
assert.match(app, /最新镜像构建时间/);
|
||||
assert.match(app, /构建时间不可用/);
|
||||
assert.match(app, /liveBuildSourceLabel/);
|
||||
assert.match(app, /liveBuildDesiredStateText/);
|
||||
assert.match(app, /外部镜像或非 HWLAB 构建产物/);
|
||||
assert.match(styles, /\.live-build-meta span\s*{[^}]*overflow-wrap:\s*anywhere;/s);
|
||||
assert.match(app, /fetchJson\("\/health\/live"\)/);
|
||||
assert.match(app, /callRpc\("system\.health"\)/);
|
||||
assert.match(app, /callRpc\("cloud\.adapter\.describe"\)/);
|
||||
|
||||
@@ -196,6 +196,21 @@ const okM3Status = Object.freeze({
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud web live build surface renders Beijing summary, details, missing time, and external images", async () => {
|
||||
const { readFileSync } = await import("node:fs");
|
||||
const html = readFileSync(new URL("../index.html", import.meta.url), "utf8");
|
||||
const app = readFileSync(new URL("../app.mjs", import.meta.url), "utf8");
|
||||
|
||||
assert.match(html, /id="live-build-summary"/u);
|
||||
assert.match(html, /id="live-build-latest"/u);
|
||||
assert.match(html, /id="live-build-list"/u);
|
||||
assert.match(app, /formatBeijingTime/u);
|
||||
assert.match(app, /Asia\/Shanghai/u);
|
||||
assert.match(app, /liveBuildSourceLabel/u);
|
||||
assert.match(app, /liveBuildDesiredStateText/u);
|
||||
assert.match(app, /外部镜像或非 HWLAB 构建产物/u);
|
||||
});
|
||||
|
||||
test("classifies fully ready workbench runtime as API 正常", () => {
|
||||
const status = classifyWorkbenchLiveStatus({
|
||||
healthLive: okApi,
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user