feat: add admin secrets read-only MVP

This commit is contained in:
root
2026-06-30 09:04:30 +00:00
parent 797e1ef107
commit 207dcc5dc6
12 changed files with 1333 additions and 19 deletions
+35 -5
View File
@@ -3,14 +3,40 @@ kind: hwlab-v03-secret-plane
metadata:
id: hwlab-v03-secret-plane
owner: HWLAB
spec: pikasTech/HWLAB#2234
boundary: D518/v0.3 only
spec: pikasTech/HWLAB#2277
boundary: JD01/v0.3 read-only active target
secretPlane:
enabled: true
issue: pikasTech/HWLAB#2234
issue: pikasTech/HWLAB#2277
enabledOnNodes:
- D518
- JD01
valuesPrinted: false
readModel:
target:
node: JD01
lane: v03
runtimeNamespace: hwlab-v03
infraNamespace: platform-infra
publicUrl: https://hwlab.pikapython.com
source: yaml-active-target
sourceRef: config/hwlab-v03/secrets.yaml#secretPlane.readModel.target
upstreamSourceRef: config/platform-infra/secret-plane.yaml#activeTarget
statusEvidence:
source: pikasTech/unidesk#1282
issue: pikasTech/HWLAB#2277
command: bun scripts/cli.ts platform-infra secret-plane status --target JD01
validatedAt: "2026-06-30T03:21:49Z"
fingerprint: sha256:dbc852e80ed37ccc9d8596eb47976839c9c77e4a5afda972ce62ad29db9132dd
status: Ready
valuesPrinted: false
components:
esoController: Ready
esoWebhook: Ready
esoCertController: Ready
secretStore: Ready
clusterSecretStore: Ready
sync: Ready
consumer: Ready
store:
kind: ClusterSecretStore
name: hwlab-secret-plane-vault-cluster
@@ -19,7 +45,11 @@ secretPlane:
- id: hwlab-secret-plane-smoke
externalSecretName: hwlab-secret-plane-smoke
targetSecretName: hwlab-secret-plane-smoke
purpose: Low-risk D518/v0.3 smoke secret for platform-infra secret-plane integration.
purpose: Low-risk JD01/v0.3 smoke secret for platform-infra secret-plane integration.
statusEvidence:
status: Ready
reason: JD01 platform-infra secret-plane PoC validated by pikasTech/unidesk#1282.
fingerprint: sha256:dbc852e80ed37ccc9d8596eb47976839c9c77e4a5afda972ce62ad29db9132dd
data:
- targetKey: password
remoteRef: hwlab-secret-plane/poc
+573
View File
@@ -0,0 +1,573 @@
/*
* SPEC: HWLAB-ESO-UI-20260630 ESO UI draft-2026-06-30-p1-readonly-api.
* Responsibility: read-only, redacted admin secrets API model for Cloud Web.
*/
import { existsSync, readFileSync } from "node:fs";
import { createHash } from "node:crypto";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { sendJson } from "./server-http-utils.ts";
const CONTRACT_VERSION = "hwlab-admin-secrets-v1";
const DEFAULT_CONFIG_RELATIVE_PATH = "config/hwlab-v03/secrets.yaml";
const DEFAULT_TARGET = Object.freeze({
node: "JD01",
lane: "v03",
runtimeNamespace: "hwlab-v03",
infraNamespace: "platform-infra",
publicUrl: "https://hwlab.pikapython.com",
source: "yaml-active-target"
});
const STATUS_PRIORITY = Object.freeze([
"SecretLeakBlocked",
"StoreUnavailable",
"SyncError",
"TargetMissing",
"RolloutPending",
"ValidationFailed",
"Ready",
"Unknown"
]);
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
export async function handleAdminSecretsHttp(request, response, url, options = {}) {
try {
const auth = await authenticateAdmin(request, options);
if (!auth.ok) return sendJson(response, numericStatus(auth.httpStatus) ?? numericStatus(auth.status) ?? numericStatus(auth.statusCode) ?? 401, auth);
const readModel = buildAdminSecretsReadModel({ env: options.env ?? process.env });
if (readModel.leakBlocked) return sendJson(response, 502, readModel.payload);
if (request.method === "GET" && url.pathname === "/v1/admin/secrets/summary") {
return sendJson(response, 200, readModel.payload);
}
if (request.method === "GET" && url.pathname === "/v1/admin/secrets/external-secrets") {
return sendJson(response, 200, externalSecretsListPayload(readModel.payload, url));
}
const detailMatch = url.pathname.match(/^\/v1\/admin\/secrets\/external-secrets\/([^/]+)\/([^/]+)$/u);
if (request.method === "GET" && detailMatch) {
const detail = externalSecretDetailPayload(readModel.payload, decodeURIComponent(detailMatch[1]), decodeURIComponent(detailMatch[2]));
return sendJson(response, detail.httpStatus ?? 200, detail);
}
if (request.method === "GET" && url.pathname === "/v1/admin/secrets/events") {
return sendJson(response, 200, eventsPayload(readModel.payload, url));
}
return sendJson(response, 404, secretsError("not_found", "Admin secrets route is not implemented", 404));
} catch (error) {
return sendJson(response, error.httpStatus ?? 500, secretsError(error.code ?? "admin_secrets_failed", error.message ?? "Admin secrets read model failed", error.httpStatus ?? 500, error.details));
}
}
export function buildAdminSecretsReadModel({ env = process.env } = {}) {
const { config, sourcePath, sourceRelativePath, sourceError } = readSecretPlaneConfig(env);
const secretPlane = record(config?.secretPlane);
const readModelConfig = record(secretPlane?.readModel);
const target = normalizeTarget(readModelConfig?.target, env, sourceRelativePath);
const evidence = normalizeEvidence(readModelConfig?.statusEvidence);
const store = normalizeStore(secretPlane?.store);
const externalSecrets = normalizeExternalSecrets(secretPlane, target, store, evidence, sourceRelativePath);
const events = buildEvents({ target, store, externalSecrets, evidence, sourceError });
const summaryStatus = sourceError ? "Unknown" : aggregateStatus([
...componentStatuses(evidence),
...externalSecrets.map((item) => item.status)
]);
const leakBlocked = hasLeakRisk({ target, store, externalSecrets, events });
const generatedAt = new Date().toISOString();
const common = {
ok: !leakBlocked,
status: leakBlocked ? "blocked" : "ok",
contractVersion: CONTRACT_VERSION,
valuesPrinted: false,
secretMaterialStored: false,
generatedAt,
target,
source: {
kind: "yaml",
path: sourceRelativePath,
sourceRef: `${sourceRelativePath}#secretPlane`,
present: Boolean(sourcePath && !sourceError),
error: sourceError ? { code: "secret_plane_config_unavailable", message: sourceError } : null
}
};
if (leakBlocked) {
return {
leakBlocked: true,
payload: {
...common,
error: {
code: "SecretLeakBlocked",
message: "Admin secrets read model contained disallowed secret material indicators.",
layer: "admin-secrets-readmodel",
retryable: false,
valuesPrinted: false
}
}
};
}
return {
leakBlocked: false,
payload: {
...common,
summary: {
status: summaryStatus,
priority: STATUS_PRIORITY.indexOf(summaryStatus),
counts: {
externalSecrets: externalSecrets.length,
ready: externalSecrets.filter((item) => item.status === "Ready").length,
pending: externalSecrets.filter((item) => item.status === "RolloutPending" || item.status === "Unknown").length,
errors: externalSecrets.filter((item) => ["SecretLeakBlocked", "StoreUnavailable", "SyncError", "TargetMissing", "ValidationFailed"].includes(item.status)).length,
events: events.length
},
eso: {
controller: evidence.components.esoController,
webhook: evidence.components.esoWebhook,
certController: evidence.components.esoCertController
},
stores: [store],
consumerRollout: consumerRolloutSummary(externalSecrets),
latestEvidence: {
source: evidence.source,
issue: evidence.issue,
command: evidence.command,
validatedAt: evidence.validatedAt,
fingerprint: evidence.fingerprint,
valuesPrinted: false
}
},
externalSecrets,
events
}
};
}
function externalSecretsListPayload(payload, url) {
const node = text(url.searchParams.get("node")).toUpperCase();
const lane = text(url.searchParams.get("lane")).toLowerCase();
const namespace = text(url.searchParams.get("namespace"));
const items = payload.externalSecrets.filter((item) => {
if (node && item.target.node !== node) return false;
if (lane && item.target.lane.toLowerCase() !== lane) return false;
if (namespace && item.namespace !== namespace) return false;
return true;
});
return {
ok: true,
status: "ok",
contractVersion: CONTRACT_VERSION,
valuesPrinted: false,
secretMaterialStored: false,
generatedAt: payload.generatedAt,
target: payload.target,
items,
count: items.length
};
}
function externalSecretDetailPayload(payload, namespace, name) {
const item = payload.externalSecrets.find((secret) => secret.namespace === namespace && secret.name === name);
if (!item) return secretsError("external_secret_not_found", "ExternalSecret read model entry was not found", 404, { namespace, name });
return {
ok: true,
status: "ok",
contractVersion: CONTRACT_VERSION,
valuesPrinted: false,
secretMaterialStored: false,
generatedAt: payload.generatedAt,
target: payload.target,
item
};
}
function eventsPayload(payload, url) {
const limit = positiveInteger(url.searchParams.get("limit"), 50);
const namespace = text(url.searchParams.get("namespace"));
const items = payload.events
.filter((event) => !namespace || event.namespace === namespace)
.slice(0, Math.min(limit, 200));
return {
ok: true,
status: "ok",
contractVersion: CONTRACT_VERSION,
valuesPrinted: false,
secretMaterialStored: false,
generatedAt: payload.generatedAt,
target: payload.target,
events: items,
count: items.length
};
}
async function authenticateAdmin(request, options) {
const controller = options.accessController;
if (!controller || typeof controller.authenticate !== "function") return secretsError("access_controller_unavailable", "HWLAB access controller is not available", 503);
const auth = await controller.authenticate(request, { required: true });
if (!auth.ok) return auth;
if (auth.actor?.role !== "admin") return secretsError("admin_required", "Only admin users can read admin secrets status", 403);
return auth;
}
function readSecretPlaneConfig(env) {
const configuredPath = text(env.HWLAB_ADMIN_SECRETS_CONFIG_PATH);
const candidates = [
configuredPath,
path.join(repoRoot, DEFAULT_CONFIG_RELATIVE_PATH),
path.join(process.cwd(), DEFAULT_CONFIG_RELATIVE_PATH),
path.join("/workspace/hwlab", DEFAULT_CONFIG_RELATIVE_PATH)
].filter(Boolean);
const sourcePath = candidates.find((candidate) => existsSync(candidate)) ?? candidates[0] ?? "";
const sourceRelativePath = sourcePath.includes(DEFAULT_CONFIG_RELATIVE_PATH) ? DEFAULT_CONFIG_RELATIVE_PATH : sourcePath;
if (!sourcePath || !existsSync(sourcePath)) return { config: null, sourcePath, sourceRelativePath, sourceError: `missing ${sourceRelativePath}` };
try {
return { config: parseAdminSecretsYaml(readFileSync(sourcePath, "utf8")), sourcePath, sourceRelativePath, sourceError: null };
} catch (error) {
return { config: null, sourcePath, sourceRelativePath, sourceError: `invalid yaml: ${error.message ?? String(error)}` };
}
}
function parseAdminSecretsYaml(raw) {
const root = {};
const stack = [{ indent: -1, value: root, pending: false, parent: null, key: "" }];
for (const [lineIndex, rawLine] of String(raw ?? "").split(/\r?\n/u).entries()) {
if (!rawLine.trim() || rawLine.trimStart().startsWith("#")) continue;
const indent = rawLine.match(/^ */u)?.[0].length ?? 0;
const content = rawLine.trim();
while (stack.length > 1 && stack[stack.length - 1].indent >= indent) stack.pop();
let current = stack[stack.length - 1];
if (content.startsWith("- ")) {
current = ensureYamlArrayContainer(current);
const itemText = content.slice(2).trim();
if (!itemText) {
const item = {};
current.value.push(item);
stack.push({ indent, value: item, pending: false, parent: current.value, key: String(current.value.length - 1) });
continue;
}
const keyValue = splitYamlKeyValue(itemText);
if (keyValue) {
const item = {};
current.value.push(item);
assignYamlKeyValue(item, keyValue, lineIndex);
stack.push({ indent, value: item, pending: false, parent: current.value, key: String(current.value.length - 1) });
} else {
current.value.push(parseYamlScalar(itemText));
}
continue;
}
const parent = current.value;
if (!record(parent)) throw new Error(`unsupported yaml mapping at line ${lineIndex + 1}`);
const keyValue = splitYamlKeyValue(content);
if (!keyValue) throw new Error(`unsupported yaml line ${lineIndex + 1}`);
const assigned = assignYamlKeyValue(parent, keyValue, lineIndex);
if (assigned.pending) stack.push({ indent, value: assigned.value, pending: true, parent, key: keyValue.key });
}
return root;
}
function ensureYamlArrayContainer(entry) {
if (Array.isArray(entry.value)) return entry;
if (entry.pending && entry.parent && entry.key) {
const items = [];
entry.parent[entry.key] = items;
entry.value = items;
entry.pending = false;
return entry;
}
throw new Error("unsupported yaml sequence placement");
}
function splitYamlKeyValue(value) {
const match = String(value ?? "").match(/^([^:]+):(.*)$/u);
if (!match) return null;
return { key: match[1].trim(), value: match[2].trim() };
}
function assignYamlKeyValue(target, keyValue, lineIndex) {
if (!keyValue.key) throw new Error(`empty yaml key at line ${lineIndex + 1}`);
if (!keyValue.value) {
const child = {};
target[keyValue.key] = child;
return { value: child, pending: true };
}
target[keyValue.key] = parseYamlScalar(keyValue.value);
return { value: target[keyValue.key], pending: false };
}
function parseYamlScalar(value) {
const textValue = String(value ?? "").trim();
if (!textValue) return "";
if (textValue === "true") return true;
if (textValue === "false") return false;
if (textValue === "null" || textValue === "~") return null;
if (/^-?\d+(?:\.\d+)?$/u.test(textValue)) return Number(textValue);
if ((textValue.startsWith("\"") && textValue.endsWith("\"")) || (textValue.startsWith("'") && textValue.endsWith("'"))) return textValue.slice(1, -1);
return textValue;
}
function normalizeTarget(value, env, sourceRelativePath) {
const input = record(value);
const node = text(env.HWLAB_ADMIN_SECRETS_ACTIVE_NODE) || text(input?.node) || DEFAULT_TARGET.node;
const lane = text(env.HWLAB_ADMIN_SECRETS_ACTIVE_LANE) || text(input?.lane) || DEFAULT_TARGET.lane;
return {
node: node.toUpperCase(),
lane,
runtimeNamespace: text(input?.runtimeNamespace) || DEFAULT_TARGET.runtimeNamespace,
infraNamespace: text(input?.infraNamespace) || DEFAULT_TARGET.infraNamespace,
publicUrl: text(input?.publicUrl) || DEFAULT_TARGET.publicUrl,
source: text(input?.source) || DEFAULT_TARGET.source,
sourceRef: text(input?.sourceRef) || `${sourceRelativePath}#secretPlane.readModel.target`,
upstreamSourceRef: text(input?.upstreamSourceRef) || "config/platform-infra/secret-plane.yaml#activeTarget"
};
}
function normalizeEvidence(value) {
const input = record(value);
const components = record(input?.components);
return {
source: text(input?.source) || "pikasTech/unidesk#1282",
issue: text(input?.issue) || "pikasTech/HWLAB#2277",
command: text(input?.command) || "platform-infra secret-plane status --target JD01",
validatedAt: text(input?.validatedAt) || "2026-06-30T03:21:49Z",
fingerprint: text(input?.fingerprint),
status: normalizeStatus(input?.status, "Ready"),
components: {
esoController: normalizeStatus(components?.esoController, "Ready"),
esoWebhook: normalizeStatus(components?.esoWebhook, "Ready"),
esoCertController: normalizeStatus(components?.esoCertController, "Ready"),
secretStore: normalizeStatus(components?.secretStore, "Ready"),
clusterSecretStore: normalizeStatus(components?.clusterSecretStore, "Ready"),
sync: normalizeStatus(components?.sync, "Ready"),
consumer: normalizeStatus(components?.consumer, "Ready")
}
};
}
function normalizeStore(value) {
const input = record(value);
return {
kind: text(input?.kind) || "ClusterSecretStore",
name: text(input?.name) || "hwlab-secret-plane-vault-cluster",
namespace: text(input?.namespace) || "platform-infra",
scope: text(input?.scope) || (text(input?.kind) === "SecretStore" ? "namespace" : "cluster"),
status: "Ready",
reason: "JD01 secret-plane status evidence reports Store Ready",
valuesPrinted: false
};
}
function normalizeExternalSecrets(secretPlane, target, store, evidence, sourceRelativePath) {
const secrets = Array.isArray(secretPlane?.secrets) ? secretPlane.secrets : [];
return secrets.map((secret, index) => normalizeExternalSecret(secret, index, secretPlane, target, store, evidence, sourceRelativePath));
}
function normalizeExternalSecret(secret, index, secretPlane, target, store, evidence, sourceRelativePath) {
const item = record(secret) ?? {};
const namespace = text(item.targetNamespace) || target.runtimeNamespace;
const data = Array.isArray(item.data) ? item.data.map((entry, dataIndex) => normalizeSecretData(entry, dataIndex)) : [];
const consumers = Array.isArray(item.consumers) ? item.consumers.map((consumer, consumerIndex) => normalizeConsumer(consumer, consumerIndex, evidence)) : [];
const fingerprint = text(record(item.statusEvidence)?.fingerprint) || evidence.fingerprint;
const status = normalizeStatus(record(item.statusEvidence)?.status, evidence.status);
const name = text(item.externalSecretName) || `external-secret-${index + 1}`;
const targetSecretName = text(item.targetSecretName) || name;
return {
id: text(item.id) || name,
name,
namespace,
target,
store: { kind: store.kind, name: store.name, namespace: store.namespace, scope: store.scope, status: store.status },
status,
ready: status === "Ready",
reason: text(record(item.statusEvidence)?.reason) || `${evidence.source} validated JD01 read-only status`,
message: "脱敏 read model only; secret values are never read or returned.",
refreshInterval: text(item.refreshInterval) || text(secretPlane?.refreshInterval) || "1m",
createdAt: null,
lastRefreshTime: evidence.validatedAt,
targetSecret: {
name: targetSecretName,
namespace,
keys: data.map((entry) => ({ key: entry.targetKey, presence: fingerprint ? "present" : "unknown", fingerprint, valuesPrinted: false })),
resourceVersion: text(record(item.statusEvidence)?.resourceVersion) || null,
fingerprint,
valuesPrinted: false
},
data,
consumers,
provenance: {
configSource: `${sourceRelativePath}#secretPlane.secrets[${index}]`,
statusSource: evidence.source,
issue: evidence.issue,
command: evidence.command,
validatedAt: evidence.validatedAt
},
valuesPrinted: false,
secretMaterialStored: false
};
}
function normalizeSecretData(entry, index) {
const item = record(entry) ?? {};
const remoteRef = text(item.remoteRef);
return {
targetKey: text(item.targetKey) || `key-${index + 1}`,
remoteRef: {
path: redactRemoteRef(remoteRef),
property: text(item.property) || null,
version: text(item.version) || null,
fingerprint: remoteRef ? sha256Text(remoteRef).slice(0, 16) : null,
redacted: true
}
};
}
function normalizeConsumer(value, index, evidence) {
const input = record(value) ?? {};
const serviceId = text(input.serviceId) || `consumer-${index + 1}`;
return {
serviceId,
kind: text(input.kind) || "Deployment",
injectAs: text(input.injectAs) || "env",
envName: text(input.envName) || null,
status: evidence.components.consumer,
rollout: evidence.components.consumer === "Ready" ? "Ready" : "RolloutPending",
revision: text(input.revision) || null,
valuesPrinted: false
};
}
function buildEvents({ target, store, externalSecrets, evidence, sourceError }) {
const events = [];
if (sourceError) {
events.push(event("Unknown", "ConfigUnavailable", target.runtimeNamespace, "secret-plane", sourceError, target, evidence));
}
for (const secret of externalSecrets) {
events.push(event(secret.status, `${secret.status}ExternalSecret`, secret.namespace, secret.name, secret.reason, target, evidence, {
resourceKind: "ExternalSecret",
resourceName: secret.name
}));
events.push(event(store.status, `${store.status}Store`, store.namespace, store.name, "SecretStore / ClusterSecretStore readiness is exposed as redacted status.", target, evidence, {
resourceKind: store.kind,
resourceName: store.name
}));
}
return events.sort((left, right) => String(right.observedAt).localeCompare(String(left.observedAt)));
}
function event(status, reason, namespace, name, message, target, evidence, extra = {}) {
return {
status,
reason,
namespace,
resourceKind: extra.resourceKind || "SecretPlane",
resourceName: extra.resourceName || name,
message: summarizeMessage(message),
observedAt: evidence.validatedAt || new Date().toISOString(),
target,
traceId: null,
jobId: null,
source: evidence.source,
valuesPrinted: false
};
}
function componentStatuses(evidence) {
return Object.values(evidence.components);
}
function consumerRolloutSummary(externalSecrets) {
const consumers = externalSecrets.flatMap((item) => item.consumers);
return {
total: consumers.length,
ready: consumers.filter((item) => item.rollout === "Ready").length,
pending: consumers.filter((item) => item.rollout !== "Ready").length,
revision: consumers.find((item) => item.revision)?.revision ?? null
};
}
function aggregateStatus(statuses) {
for (const status of STATUS_PRIORITY) if (statuses.includes(status)) return status;
return "Unknown";
}
function normalizeStatus(value, fallback = "Unknown") {
const textValue = text(value);
if (STATUS_PRIORITY.includes(textValue)) return textValue;
if (/^(ready|ok|healthy|synced)$/iu.test(textValue)) return "Ready";
if (/^(notready|unavailable)$/iu.test(textValue)) return "StoreUnavailable";
if (/^(pending|progressing)$/iu.test(textValue)) return "RolloutPending";
if (/^(failed|error)$/iu.test(textValue)) return "SyncError";
return fallback;
}
function hasLeakRisk(value) {
const stack = [value];
while (stack.length > 0) {
const current = stack.pop();
if (Array.isArray(current)) {
stack.push(...current);
continue;
}
if (!current || typeof current !== "object") continue;
for (const [key, item] of Object.entries(current)) {
if (/^(value|raw|secretValue|decoded|base64|authorization|cookie|token|apiKey|password)$/u.test(key) && typeof item === "string" && item.trim()) return true;
if (item && typeof item === "object") stack.push(item);
}
}
return false;
}
function redactRemoteRef(value) {
const textValue = text(value);
if (!textValue) return null;
const parts = textValue.split("/").filter(Boolean);
if (parts.length <= 1) return `${textValue.slice(0, 4)}...`;
return `${parts[0]}/.../${parts[parts.length - 1]}`;
}
function summarizeMessage(value) {
const message = text(value);
return message.length > 220 ? `${message.slice(0, 217)}...` : message;
}
function secretsError(code, message, status = 500, details = {}) {
return {
ok: false,
status: "failed",
contractVersion: CONTRACT_VERSION,
valuesPrinted: false,
secretMaterialStored: false,
error: {
code,
message,
layer: "admin-secrets",
retryable: false,
valuesPrinted: false,
...details
},
httpStatus: status
};
}
function numericStatus(value) {
const number = Number(value);
return Number.isInteger(number) && number >= 100 && number <= 599 ? number : null;
}
function positiveInteger(value, fallback) {
const number = Number.parseInt(String(value ?? ""), 10);
return Number.isInteger(number) && number > 0 ? number : fallback;
}
function record(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : null;
}
function text(value) {
if (value === null || value === undefined) return "";
return String(value).trim();
}
function sha256Text(value) {
return createHash("sha256").update(text(value)).digest("hex");
}
+7
View File
@@ -77,6 +77,7 @@ import { handleSkillsHttp } from "./server-skills-http.ts";
import { emitHttpServerRequestSpan, httpRequestOtelTraceContext, newOtelSpanId } from "./otel-trace.ts";
import { decorateErrorPayloadForHttp, logErrorEnvelope } from "./error-envelope.ts";
import { handleProviderProfileCatalogHttp, handleProviderProfilesHttp } from "./provider-profile-management.ts";
import { handleAdminSecretsHttp } from "./admin-secrets-readmodel.ts";
import {
configureSkillRuntime,
ensureCodexSkillsAggregationSync
@@ -752,6 +753,11 @@ async function handleRestAdapter(request, response, url, options) {
return;
}
if (url.pathname === "/v1/admin/secrets" || url.pathname.startsWith("/v1/admin/secrets/")) {
await handleAdminSecretsHttp(request, response, url, options);
return;
}
if (url.pathname === "/v1/provider-profiles" && request.method === "GET") {
await handleProviderProfileCatalogHttp(request, response, options);
return;
@@ -961,6 +967,7 @@ function navIdForRestPath(pathname, method = "GET") {
if (pathname === "/v1/workbench/events" || pathname === "/v1/workbench/launches" || pathname === "/v1/workbench/sessions" || pathname.startsWith("/v1/workbench/sessions/") || pathname.startsWith("/v1/workbench/turns/") || pathname.startsWith("/v1/workbench/traces/")) return "workbench.code";
if (pathname === "/v1/agent/chat" || pathname === "/v1/agent/sessions" || pathname.startsWith("/v1/agent/sessions/") || pathname === "/v1/agent/chat/inspect" || pathname.startsWith("/v1/agent/chat/result/") || pathname.startsWith("/v1/agent/turns/") || pathname.startsWith("/v1/agent/traces/") || pathname === "/v1/agent/chat/cancel" || pathname === "/v1/agent/chat/steer") return "workbench.code";
if (pathname === "/v1/admin/provider-profiles" || pathname.startsWith("/v1/admin/provider-profiles/")) return "admin.providerProfiles";
if (pathname === "/v1/admin/secrets" || pathname.startsWith("/v1/admin/secrets/")) return "admin.secrets";
if (pathname === "/v1/provider-profiles" && verb === "GET") return "workbench.code";
if (pathname.startsWith("/v1/admin/access")) return "admin.access";
if (pathname.startsWith("/v1/admin/users")) return "admin.users";
+13 -13
View File
@@ -582,7 +582,7 @@ test("v03 render keeps node identity as data instead of generated structure", as
"scripts/run-bun.mjs",
"scripts/gitops-render.mjs",
"--lane", "v03",
"--node", "D518",
"--node", "JD01",
"--catalog-path", staleCatalogPath,
"--image-tag-mode", "full",
"--source-branch", "v0.3",
@@ -617,8 +617,8 @@ test("v03 render keeps node identity as data instead of generated structure", as
const smokeExternalSecret = (externalSecretsJson.items ?? []).find((item) => item.kind === "ExternalSecret" && item.metadata?.name === "hwlab-secret-plane-smoke");
assert.ok(smokeExternalSecret, "expected v03 secret-plane smoke ExternalSecret");
assert.equal(smokeExternalSecret.metadata?.namespace, "hwlab-v03");
assert.equal(smokeExternalSecret.metadata?.labels?.["hwlab.pikastech.local/secret-plane"], "issue-2234");
assert.equal(smokeExternalSecret.metadata?.annotations?.["hwlab.pikastech.local/secret-plane-issue"], "pikasTech/HWLAB#2234");
assert.equal(smokeExternalSecret.metadata?.labels?.["hwlab.pikastech.local/secret-plane"], "issue-2277");
assert.equal(smokeExternalSecret.metadata?.annotations?.["hwlab.pikastech.local/secret-plane-issue"], "pikasTech/HWLAB#2277");
assert.equal(smokeExternalSecret.spec?.secretStoreRef?.kind, "ClusterSecretStore");
assert.equal(smokeExternalSecret.spec?.secretStoreRef?.name, "hwlab-secret-plane-vault-cluster");
assert.equal(smokeExternalSecret.spec?.target?.name, "hwlab-secret-plane-smoke");
@@ -665,7 +665,7 @@ test("v03 render keeps node identity as data instead of generated structure", as
.flatMap((container) => container.env ?? [])
.filter((entry) => entry.name === "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID");
assert.ok(agentRunNodeEnv.length > 0, "expected AgentRun provider id env to carry configured node id");
assert.deepEqual([...new Set(agentRunNodeEnv.map((entry) => entry.value))], ["D518"]);
assert.deepEqual([...new Set(agentRunNodeEnv.map((entry) => entry.value))], ["JD01"]);
} finally {
await rm(outDir, { recursive: true, force: true });
@@ -673,9 +673,9 @@ test("v03 render keeps node identity as data instead of generated structure", as
}
});
test("v03 render includes D518 secret-plane smoke on D518 gitops root", async () => {
const outDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-v03-d518-render-test-"));
const scratchDir = path.join(".tmp", `gitops-render-v03-d518-${process.pid}-${Date.now()}`);
test("v03 render includes JD01 secret-plane smoke on JD01 gitops root", { timeout: 20000 }, async () => {
const outDir = await mkdtemp(path.join(os.tmpdir(), "hwlab-v03-jd01-render-test-"));
const scratchDir = path.join(".tmp", `gitops-render-v03-jd01-${process.pid}-${Date.now()}`);
const staleCatalogPath = path.join(scratchDir, "artifact-catalog.v03.json");
try {
const staleRevision = "0".repeat(40);
@@ -714,7 +714,7 @@ test("v03 render includes D518 secret-plane smoke on D518 gitops root", async ()
"--image-tag-mode", "full",
"--source-branch", "v0.3",
"--gitops-branch", "v0.3-gitops",
"--gitops-root", "deploy/gitops/node/d518",
"--gitops-root", "deploy/gitops/node/jd01",
"--out", outDir,
"--source-revision", "HEAD"
], { cwd: process.cwd(), encoding: "utf8" });
@@ -730,10 +730,10 @@ test("v03 render includes D518 secret-plane smoke on D518 gitops root", async ()
assert.match(kustomization, /external-secrets\.yaml/u);
const externalSecretsJson = JSON.parse(await readFile(path.join(outDir, "runtime-v03", "external-secrets.yaml"), "utf8"));
const smokeExternalSecret = (externalSecretsJson.items ?? []).find((item) => item.kind === "ExternalSecret" && item.metadata?.name === "hwlab-secret-plane-smoke");
assert.ok(smokeExternalSecret, "expected D518 v03 secret-plane smoke ExternalSecret");
assert.ok(smokeExternalSecret, "expected JD01 v03 secret-plane smoke ExternalSecret");
assert.equal(smokeExternalSecret.metadata?.namespace, "hwlab-v03");
assert.equal(smokeExternalSecret.metadata?.labels?.["hwlab.pikastech.local/secret-plane"], "issue-2234");
assert.equal(smokeExternalSecret.metadata?.annotations?.["hwlab.pikastech.local/secret-plane-issue"], "pikasTech/HWLAB#2234");
assert.equal(smokeExternalSecret.metadata?.labels?.["hwlab.pikastech.local/secret-plane"], "issue-2277");
assert.equal(smokeExternalSecret.metadata?.annotations?.["hwlab.pikastech.local/secret-plane-issue"], "pikasTech/HWLAB#2277");
assert.equal(smokeExternalSecret.spec?.secretStoreRef?.kind, "ClusterSecretStore");
assert.equal(smokeExternalSecret.spec?.secretStoreRef?.name, "hwlab-secret-plane-vault-cluster");
assert.equal(smokeExternalSecret.spec?.target?.name, "hwlab-secret-plane-smoke");
@@ -748,8 +748,8 @@ test("v03 render includes D518 secret-plane smoke on D518 gitops root", async ()
.flatMap((item) => collectContainersFromItem(item))
.flatMap((container) => container.env ?? [])
.filter((entry) => entry.name === "HWLAB_CODE_AGENT_AGENTRUN_PROVIDER_ID");
assert.ok(agentRunNodeEnv.length > 0, "expected D518 node id to be inferred from gitops root");
assert.deepEqual([...new Set(agentRunNodeEnv.map((entry) => entry.value))], ["D518"]);
assert.ok(agentRunNodeEnv.length > 0, "expected JD01 node id to be inferred from gitops root");
assert.deepEqual([...new Set(agentRunNodeEnv.map((entry) => entry.value))], ["JD01"]);
const opencode = JSON.parse(await readFile(path.join(outDir, "runtime-v03", "opencode.yaml"), "utf8"));
const opencodeDeployment = (opencode.items ?? []).find((item) => item.kind === "Deployment" && item.metadata?.name === "opencode-server");
@@ -0,0 +1,19 @@
import { fetchJson } from "./client";
import type { AdminSecretsDetailResponse, AdminSecretsEventsResponse, AdminSecretsListResponse, AdminSecretsSummaryResponse, ApiResult } from "@/types";
export const adminSecretsAPI = {
summary: (): Promise<ApiResult<AdminSecretsSummaryResponse>> => fetchJson("/v1/admin/secrets/summary", { timeoutMs: 12000, timeoutName: "admin secrets summary" }),
externalSecrets: (params: { node?: string; lane?: string; namespace?: string } = {}): Promise<ApiResult<AdminSecretsListResponse>> => fetchJson(`/v1/admin/secrets/external-secrets${query(params)}`, { timeoutMs: 12000, timeoutName: "admin secrets list" }),
detail: (namespace: string, name: string): Promise<ApiResult<AdminSecretsDetailResponse>> => fetchJson(`/v1/admin/secrets/external-secrets/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`, { timeoutMs: 12000, timeoutName: "admin secrets detail" }),
events: (params: { namespace?: string; limit?: number } = {}): Promise<ApiResult<AdminSecretsEventsResponse>> => fetchJson(`/v1/admin/secrets/events${query(params)}`, { timeoutMs: 12000, timeoutName: "admin secrets events" })
};
function query(params: Record<string, unknown>): string {
const search = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
const text = String(value ?? "").trim();
if (text) search.set(key, text);
}
const body = search.toString();
return body ? `?${body}` : "";
}
+3
View File
@@ -9,6 +9,7 @@ export { agentAPI } from "./agent";
export { hwpodAPI } from "./hwpod";
export { caserunAPI } from "./caserun";
export { accessAPI } from "./access";
export { adminSecretsAPI } from "./adminSecrets";
export { providerProfilesAPI } from "./providerProfiles";
export { apiKeysAPI } from "./apiKeys";
export { usageAPI } from "./usage";
@@ -18,6 +19,7 @@ export { projectManagementAPI, type MdtodoExecutionContext, type MdtodoFileRecor
import { fetchJson } from "./client";
import { accessAPI } from "./access";
import { adminSecretsAPI } from "./adminSecrets";
import { agentAPI } from "./agent";
import { apiKeysAPI } from "./apiKeys";
import { authAPI } from "./auth";
@@ -38,6 +40,7 @@ export const api = {
hwpod: hwpodAPI,
caserun: caserunAPI,
access: accessAPI,
adminSecrets: adminSecretsAPI,
providerProfiles: providerProfilesAPI,
apiKeys: apiKeysAPI,
usage: usageAPI,
@@ -35,7 +35,7 @@ const navSections = [
{ title: "工作台", items: [{ name: "CodeWorkbench", label: "Code", path: "/workbench", icon: "C", navId: "workbench.code" }, { name: "OpenCode", label: "OpenCode", path: "/opencode", icon: "O", navId: "opencode.root" }, { name: "Dashboard", label: "概览", path: "/dashboard", icon: "D", navId: "user.dashboard" }] },
{ title: "项目", items: [{ name: "Projects", label: "项目", path: "/projects", icon: "P", navId: "project.overview" }, { name: "ProjectMdtodo", label: "MDTODO", path: "/projects/mdtodo", icon: "M", navId: "project.mdtodo" }] },
{ title: "用户", items: [{ name: "ApiKeys", label: "API Keys", path: "/api-keys", icon: "K", navId: "user.apiKeys" }, { name: "Usage", label: "用量", path: "/usage", icon: "U", navId: "user.usage" }, { name: "Billing", label: "充值", path: "/billing", icon: "$", navId: "user.billing" }] },
{ title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A", navId: "admin.access" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P", navId: "admin.users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B", navId: "admin.billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H", navId: "admin.hwpodGroups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R", navId: "admin.providerProfiles" }] },
{ title: "管理", items: [{ name: "Access", label: "授权", path: "/admin/access", icon: "A", navId: "admin.access" }, { name: "AdminSecrets", label: "密钥", path: "/admin/secrets", icon: "K", navId: "admin.secrets" }, { name: "Users", label: "用户", path: "/admin/users", icon: "P", navId: "admin.users" }, { name: "AdminBilling", label: "账务", path: "/admin/billing", icon: "B", navId: "admin.billing" }, { name: "HwpodGroups", label: "HWPOD", path: "/admin/hwpod-groups", icon: "H", navId: "admin.hwpodGroups" }, { name: "ProviderProfiles", label: "Profiles", path: "/admin/provider-profiles", icon: "R", navId: "admin.providerProfiles" }] },
{ title: "系统", items: [{ name: "Gate", label: "Gate", path: "/gate", icon: "G", navId: "system.gate" }, { name: "Performance", label: "性能", path: "/performance", icon: "M", navId: "system.performance" }, { name: "Skills", label: "Skills", path: "/skills", icon: "S", navId: "system.skills" }, { name: "Settings", label: "设置", path: "/settings", icon: "T", navId: "system.settings" }, { name: "Help", label: "帮助", path: "/help", icon: "?", navId: "system.help" }] }
] satisfies NavSection[];
+1
View File
@@ -27,6 +27,7 @@ const routes: RouteRecordRaw[] = [
{ path: "/billing", name: "Billing", component: () => import("@/views/user/BillingView.vue"), meta: { requiresAuth: true, navId: "user.billing", title: "充值与订阅", section: "user" } },
{ path: "/performance", name: "Performance", component: () => import("@/views/PerformanceView.vue"), meta: { requiresAuth: true, navId: "system.performance", title: "性能监控", section: "system" } },
{ path: "/admin/access", alias: ["/access"], name: "Access", component: () => import("@/views/admin/AccessView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.access", title: "授权管理", section: "admin" } },
{ path: "/admin/secrets", name: "AdminSecrets", component: () => import("@/views/admin/SecretsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.secrets", title: "密钥管理", section: "admin" } },
{ path: "/admin/users", name: "Users", component: () => import("@/views/admin/UsersView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.users", title: "用户管理", section: "admin" } },
{ path: "/admin/billing", name: "AdminBilling", component: () => import("@/views/admin/BillingView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.billing", title: "兑换与订阅", section: "admin" } },
{ path: "/admin/hwpod-groups", name: "HwpodGroups", component: () => import("@/views/admin/HwpodGroupsView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.hwpodGroups", title: "HWPOD 分组", section: "admin" } },
@@ -0,0 +1,257 @@
// SPEC: HWLAB-ESO-UI-20260630 ESO UI draft-2026-06-30-p1-readonly-ui.
// Responsibility: normalize admin secrets read model for the Cloud Web view.
import type { AdminSecretsEvent, AdminSecretsExternalSecret, AdminSecretsStore, AdminSecretsSummaryResponse, AdminSecretsTarget } from "@/types";
import { asRecord, nonEmptyString } from "@/utils";
export interface AdminSecretsTargetView {
node: string;
lane: string;
runtimeNamespace: string;
infraNamespace: string;
publicUrl: string;
source: string;
sourceRef: string;
upstreamSourceRef: string;
}
export interface AdminSecretsSummaryView {
contractVersion: string;
status: string;
generatedAt: string;
valuesPrinted: boolean;
secretMaterialStored: boolean;
target: AdminSecretsTargetView;
sourcePath: string;
sourcePresent: boolean;
counts: { externalSecrets: number; ready: number; pending: number; errors: number; events: number };
eso: { controller: string; webhook: string; certController: string };
stores: AdminSecretsStoreView[];
consumerRollout: { total: number; ready: number; pending: number; revision: string };
evidence: { source: string; issue: string; command: string; validatedAt: string; fingerprint: string; valuesPrinted: boolean };
}
export interface AdminSecretsStoreView {
kind: string;
name: string;
namespace: string;
scope: string;
status: string;
reason: string;
}
export interface AdminSecretsRow {
id: string;
name: string;
namespace: string;
status: string;
ready: boolean;
reason: string;
refreshInterval: string;
lastRefreshTime: string;
targetNode: string;
targetLane: string;
targetSecretName: string;
storeText: string;
keySummary: string;
fingerprint: string;
consumerSummary: string;
valuesPrinted: boolean;
secretMaterialStored: boolean;
raw: AdminSecretsExternalSecret;
}
export interface AdminSecretsDetailView extends AdminSecretsRow {
data: Array<{ targetKey: string; remotePath: string; property: string; version: string; fingerprint: string }>;
targetKeys: Array<{ key: string; presence: string; fingerprint: string }>;
consumers: Array<{ serviceId: string; kind: string; injectAs: string; envName: string; status: string; rollout: string; revision: string }>;
provenance: Array<{ label: string; value: string }>;
}
export interface AdminSecretsEventView {
id: string;
status: string;
reason: string;
namespace: string;
resource: string;
message: string;
observedAt: string;
source: string;
traceId: string;
jobId: string;
valuesPrinted: boolean;
}
export function normalizeAdminSecretsSummary(payload: AdminSecretsSummaryResponse | null | undefined): AdminSecretsSummaryView {
const summary = asRecord(payload?.summary);
const counts = asRecord(summary?.counts);
const eso = asRecord(summary?.eso);
const consumerRollout = asRecord(summary?.consumerRollout);
const evidence = asRecord(summary?.latestEvidence);
const source = asRecord(payload?.source);
return {
contractVersion: nonEmptyString(payload?.contractVersion) ?? "hwlab-admin-secrets-v1",
status: nonEmptyString(summary?.status) ?? nonEmptyString(payload?.status) ?? "Unknown",
generatedAt: nonEmptyString(payload?.generatedAt) ?? "",
valuesPrinted: payload?.valuesPrinted === false ? false : true,
secretMaterialStored: payload?.secretMaterialStored === false ? false : true,
target: normalizeTarget(payload?.target),
sourcePath: nonEmptyString(source?.path) ?? "-",
sourcePresent: source?.present === true,
counts: {
externalSecrets: numberValue(counts?.externalSecrets),
ready: numberValue(counts?.ready),
pending: numberValue(counts?.pending),
errors: numberValue(counts?.errors),
events: numberValue(counts?.events)
},
eso: {
controller: nonEmptyString(eso?.controller) ?? "Unknown",
webhook: nonEmptyString(eso?.webhook) ?? "Unknown",
certController: nonEmptyString(eso?.certController) ?? "Unknown"
},
stores: Array.isArray(summary?.stores) ? summary.stores.map((item) => normalizeStore(item)).filter(Boolean) : [],
consumerRollout: {
total: numberValue(consumerRollout?.total),
ready: numberValue(consumerRollout?.ready),
pending: numberValue(consumerRollout?.pending),
revision: nonEmptyString(consumerRollout?.revision) ?? "-"
},
evidence: {
source: nonEmptyString(evidence?.source) ?? "-",
issue: nonEmptyString(evidence?.issue) ?? "-",
command: nonEmptyString(evidence?.command) ?? "-",
validatedAt: nonEmptyString(evidence?.validatedAt) ?? "",
fingerprint: nonEmptyString(evidence?.fingerprint) ?? "-",
valuesPrinted: evidence?.valuesPrinted === false ? false : true
}
};
}
export function normalizeAdminSecretsRows(items: AdminSecretsExternalSecret[] | null | undefined): AdminSecretsRow[] {
return Array.isArray(items) ? items.map((item) => normalizeRow(item)).sort((left, right) => `${left.namespace}/${left.name}`.localeCompare(`${right.namespace}/${right.name}`)) : [];
}
export function normalizeAdminSecretsDetail(item: AdminSecretsExternalSecret | null | undefined): AdminSecretsDetailView | null {
if (!item) return null;
const row = normalizeRow(item);
const targetSecret = asRecord(item.targetSecret);
const provenance = asRecord(item.provenance);
return {
...row,
data: Array.isArray(item.data) ? item.data.map((entry) => {
const remoteRef = asRecord(entry.remoteRef);
return {
targetKey: nonEmptyString(entry.targetKey) ?? "-",
remotePath: nonEmptyString(remoteRef?.path) ?? "-",
property: nonEmptyString(remoteRef?.property) ?? "-",
version: nonEmptyString(remoteRef?.version) ?? "-",
fingerprint: nonEmptyString(remoteRef?.fingerprint) ?? "-"
};
}) : [],
targetKeys: Array.isArray(targetSecret?.keys) ? targetSecret.keys.map((entry) => ({
key: nonEmptyString(entry.key) ?? "-",
presence: nonEmptyString(entry.presence) ?? "unknown",
fingerprint: nonEmptyString(entry.fingerprint) ?? "-"
})) : [],
consumers: Array.isArray(item.consumers) ? item.consumers.map((entry) => ({
serviceId: nonEmptyString(entry.serviceId) ?? "-",
kind: nonEmptyString(entry.kind) ?? "-",
injectAs: nonEmptyString(entry.injectAs) ?? "-",
envName: nonEmptyString(entry.envName) ?? "-",
status: nonEmptyString(entry.status) ?? "Unknown",
rollout: nonEmptyString(entry.rollout) ?? "Unknown",
revision: nonEmptyString(entry.revision) ?? "-"
})) : [],
provenance: [
fact("configSource", provenance?.configSource),
fact("statusSource", provenance?.statusSource),
fact("issue", provenance?.issue),
fact("command", provenance?.command),
fact("validatedAt", provenance?.validatedAt)
].filter((item): item is { label: string; value: string } => Boolean(item))
};
}
export function normalizeAdminSecretsEvents(items: AdminSecretsEvent[] | null | undefined): AdminSecretsEventView[] {
if (!Array.isArray(items)) return [];
return items.map((item, index) => {
const resourceKind = nonEmptyString(item.resourceKind) ?? "SecretPlane";
const resourceName = nonEmptyString(item.resourceName) ?? "-";
return {
id: `${nonEmptyString(item.observedAt) ?? "event"}:${resourceKind}:${resourceName}:${index}`,
status: nonEmptyString(item.status) ?? "Unknown",
reason: nonEmptyString(item.reason) ?? "-",
namespace: nonEmptyString(item.namespace) ?? "-",
resource: `${resourceKind}/${resourceName}`,
message: nonEmptyString(item.message) ?? "-",
observedAt: nonEmptyString(item.observedAt) ?? "",
source: nonEmptyString(item.source) ?? "-",
traceId: nonEmptyString(item.traceId) ?? "-",
jobId: nonEmptyString(item.jobId) ?? "-",
valuesPrinted: item.valuesPrinted === false ? false : true
};
});
}
function normalizeRow(item: AdminSecretsExternalSecret): AdminSecretsRow {
const target = normalizeTarget(item.target);
const store = normalizeStore(item.store);
const targetSecret = asRecord(item.targetSecret);
const keys = Array.isArray(targetSecret?.keys) ? targetSecret.keys : [];
const consumers = Array.isArray(item.consumers) ? item.consumers : [];
return {
id: nonEmptyString(item.id) ?? `${nonEmptyString(item.namespace) ?? "-"}:${nonEmptyString(item.name) ?? "-"}`,
name: nonEmptyString(item.name) ?? "-",
namespace: nonEmptyString(item.namespace) ?? target.runtimeNamespace,
status: nonEmptyString(item.status) ?? "Unknown",
ready: item.ready === true,
reason: nonEmptyString(item.reason) ?? "-",
refreshInterval: nonEmptyString(item.refreshInterval) ?? "-",
lastRefreshTime: nonEmptyString(item.lastRefreshTime) ?? "",
targetNode: target.node,
targetLane: target.lane,
targetSecretName: nonEmptyString(targetSecret?.name) ?? "-",
storeText: `${store.kind}/${store.name}`,
keySummary: keys.map((entry) => `${nonEmptyString(entry.key) ?? "-"}:${nonEmptyString(entry.presence) ?? "unknown"}`).join(", ") || "-",
fingerprint: nonEmptyString(targetSecret?.fingerprint) ?? "-",
consumerSummary: consumers.map((entry) => `${nonEmptyString(entry.serviceId) ?? "-"}:${nonEmptyString(entry.rollout) ?? "Unknown"}`).join(", ") || "-",
valuesPrinted: item.valuesPrinted === false ? false : true,
secretMaterialStored: item.secretMaterialStored === false ? false : true,
raw: item
};
}
function normalizeTarget(value: AdminSecretsTarget | undefined): AdminSecretsTargetView {
return {
node: nonEmptyString(value?.node) ?? "-",
lane: nonEmptyString(value?.lane) ?? "-",
runtimeNamespace: nonEmptyString(value?.runtimeNamespace) ?? "-",
infraNamespace: nonEmptyString(value?.infraNamespace) ?? "-",
publicUrl: nonEmptyString(value?.publicUrl) ?? "-",
source: nonEmptyString(value?.source) ?? "-",
sourceRef: nonEmptyString(value?.sourceRef) ?? "-",
upstreamSourceRef: nonEmptyString(value?.upstreamSourceRef) ?? "-"
};
}
function normalizeStore(value: AdminSecretsStore | unknown): AdminSecretsStoreView {
const item = asRecord(value);
return {
kind: nonEmptyString(item?.kind) ?? "-",
name: nonEmptyString(item?.name) ?? "-",
namespace: nonEmptyString(item?.namespace) ?? "-",
scope: nonEmptyString(item?.scope) ?? "-",
status: nonEmptyString(item?.status) ?? "Unknown",
reason: nonEmptyString(item?.reason) ?? "-"
};
}
function fact(label: string, value: unknown): { label: string; value: string } | null {
const text = nonEmptyString(value);
return text ? { label, value: text } : null;
}
function numberValue(value: unknown): number {
const number = Number(value ?? 0);
return Number.isFinite(number) ? number : 0;
}
@@ -2609,6 +2609,150 @@
font-size: 12px;
}
.secrets-admin-page code {
overflow-wrap: anywhere;
color: #0f766e;
font-size: 12px;
}
.secrets-summary-grid,
.secrets-readiness-grid,
.secrets-detail-grid {
display: grid;
min-width: 0;
gap: 12px;
}
.secrets-summary-grid,
.secrets-readiness-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.secrets-detail-grid {
grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.75fr);
}
.secrets-target-panel,
.secrets-contract-panel {
display: grid;
min-width: 0;
gap: 12px;
padding: 14px;
}
.secrets-fact-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
margin: 0;
}
.secrets-fact-grid div {
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
padding: 10px;
}
.secrets-fact-grid dt,
.secrets-fact-grid dd {
min-width: 0;
margin: 0;
}
.secrets-fact-grid dt {
color: #64748b;
font-size: 12px;
font-weight: 700;
}
.secrets-fact-grid dd {
overflow-wrap: anywhere;
color: #0f172a;
font-size: 13px;
font-weight: 750;
}
.secrets-subsection {
display: grid;
min-width: 0;
gap: 8px;
}
.secrets-subsection h3 {
margin: 0;
color: #334155;
font-size: 13px;
}
.secrets-chip-list {
display: grid;
min-width: 0;
gap: 8px;
}
.secrets-chip {
display: grid;
min-width: 0;
gap: 3px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
padding: 9px 10px;
}
.secrets-chip strong,
.secrets-chip small {
min-width: 0;
overflow-wrap: anywhere;
}
.secrets-chip small {
color: #64748b;
font-size: 12px;
}
.secrets-event-list {
display: grid;
max-height: 560px;
min-width: 0;
gap: 8px;
margin: 0;
overflow: auto;
padding: 0;
list-style: none;
}
.secrets-event-list li {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 10px;
align-items: start;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
padding: 10px;
}
.secrets-event-list strong,
.secrets-event-list p,
.secrets-event-list small {
min-width: 0;
overflow-wrap: anywhere;
}
.secrets-event-list p {
margin: 3px 0;
color: #334155;
font-size: 13px;
}
.secrets-event-list small {
color: #64748b;
font-size: 12px;
}
.toast-host {
position: fixed;
right: 16px;
+33
View File
@@ -386,6 +386,39 @@ export interface CaseRunEventsResponse { ok?: boolean; contractVersion?: string;
export interface CaseRunAggregateResponse { ok?: boolean; contractVersion?: string; runId?: string; caseId?: string; status?: string; sha256?: string; summary?: Record<string, unknown> | null; evidence?: Record<string, unknown> | null; run?: Record<string, unknown> | null; [key: string]: unknown }
export interface ProviderProfilesResponse { ok?: boolean; status?: string; profiles?: Array<Record<string, unknown>>; [key: string]: unknown }
export interface ProviderProfileValidation { validationId?: string; status?: string; [key: string]: unknown }
export type AdminSecretStatus = "SecretLeakBlocked" | "StoreUnavailable" | "SyncError" | "TargetMissing" | "RolloutPending" | "ValidationFailed" | "Ready" | "Unknown" | string;
export interface AdminSecretsTarget { node?: string; lane?: string; runtimeNamespace?: string; infraNamespace?: string; publicUrl?: string; source?: string; sourceRef?: string; upstreamSourceRef?: string; [key: string]: unknown }
export interface AdminSecretsStore { kind?: string; name?: string; namespace?: string; scope?: string; status?: AdminSecretStatus; reason?: string; valuesPrinted?: boolean; [key: string]: unknown }
export interface AdminSecretsTargetKey { key?: string; presence?: string; fingerprint?: string; valuesPrinted?: boolean; [key: string]: unknown }
export interface AdminSecretsRemoteRef { path?: string | null; property?: string | null; version?: string | null; fingerprint?: string | null; redacted?: boolean; [key: string]: unknown }
export interface AdminSecretsDataMapping { targetKey?: string; remoteRef?: AdminSecretsRemoteRef; [key: string]: unknown }
export interface AdminSecretsConsumer { serviceId?: string; kind?: string; injectAs?: string; envName?: string | null; status?: AdminSecretStatus; rollout?: string; revision?: string | null; valuesPrinted?: boolean; [key: string]: unknown }
export interface AdminSecretsExternalSecret {
id?: string;
name?: string;
namespace?: string;
target?: AdminSecretsTarget;
store?: AdminSecretsStore;
status?: AdminSecretStatus;
ready?: boolean;
reason?: string;
message?: string;
refreshInterval?: string;
createdAt?: string | null;
lastRefreshTime?: string | null;
targetSecret?: { name?: string; namespace?: string; keys?: AdminSecretsTargetKey[]; resourceVersion?: string | null; fingerprint?: string | null; valuesPrinted?: boolean; [key: string]: unknown };
data?: AdminSecretsDataMapping[];
consumers?: AdminSecretsConsumer[];
provenance?: Record<string, unknown>;
valuesPrinted?: boolean;
secretMaterialStored?: boolean;
[key: string]: unknown;
}
export interface AdminSecretsEvent { status?: AdminSecretStatus; reason?: string; namespace?: string; resourceKind?: string; resourceName?: string; message?: string; observedAt?: string; traceId?: string | null; jobId?: string | null; source?: string; valuesPrinted?: boolean; [key: string]: unknown }
export interface AdminSecretsSummaryResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; source?: Record<string, unknown>; summary?: Record<string, unknown>; externalSecrets?: AdminSecretsExternalSecret[]; events?: AdminSecretsEvent[]; valuesPrinted?: boolean; secretMaterialStored?: boolean; generatedAt?: string; [key: string]: unknown }
export interface AdminSecretsListResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; items?: AdminSecretsExternalSecret[]; count?: number; valuesPrinted?: boolean; secretMaterialStored?: boolean; generatedAt?: string; [key: string]: unknown }
export interface AdminSecretsDetailResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; item?: AdminSecretsExternalSecret; valuesPrinted?: boolean; secretMaterialStored?: boolean; generatedAt?: string; [key: string]: unknown }
export interface AdminSecretsEventsResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; events?: AdminSecretsEvent[]; count?: number; valuesPrinted?: boolean; secretMaterialStored?: boolean; generatedAt?: string; [key: string]: unknown }
export interface GateDiagnosticRow extends Record<string, unknown> {
category?: string;
check?: string;
@@ -0,0 +1,247 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { adminSecretsAPI } from "@/api";
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
import DataTable from "@/components/common/DataTable.vue";
import type { DataTableColumn } from "@/components/common/DataTable.vue";
import EmptyState from "@/components/common/EmptyState.vue";
import LoadingState from "@/components/common/LoadingState.vue";
import PageHeader from "@/components/common/PageHeader.vue";
import StatusBadge from "@/components/common/StatusBadge.vue";
import TablePageLayout from "@/components/layout/TablePageLayout.vue";
import { normalizeAdminSecretsDetail, normalizeAdminSecretsEvents, normalizeAdminSecretsRows, normalizeAdminSecretsSummary, type AdminSecretsDetailView, type AdminSecretsEventView, type AdminSecretsRow, type AdminSecretsSummaryView } from "@/stores/admin-secrets-view";
import type { ApiError, ErrorDiagnostic } from "@/types";
const columns: DataTableColumn[] = [
{ key: "secret", label: "ExternalSecret" },
{ key: "target", label: "Target" },
{ key: "store", label: "Store" },
{ key: "status", label: "Status" },
{ key: "fingerprint", label: "Fingerprint" },
{ key: "consumer", label: "Consumer" },
{ key: "actions", label: "操作", align: "right" }
];
const loading = ref(true);
const refreshing = ref(false);
const error = ref<string | null>(null);
const apiError = ref<ApiError | null>(null);
const diagnostic = ref<ErrorDiagnostic | null>(null);
const summary = ref<AdminSecretsSummaryView | null>(null);
const rows = ref<AdminSecretsRow[]>([]);
const detail = ref<AdminSecretsDetailView | null>(null);
const events = ref<AdminSecretsEventView[]>([]);
const selectedKey = ref("");
const selectedRow = computed(() => rows.value.find((row) => rowKey(row) === selectedKey.value) ?? rows.value[0] ?? null);
const targetFacts = computed(() => {
const target = summary.value?.target;
if (!target) return [];
return [
["node", target.node],
["lane", target.lane],
["runtime", target.runtimeNamespace],
["infra", target.infraNamespace],
["source", target.source],
["public", target.publicUrl]
];
});
onMounted(() => void reload());
async function reload(): Promise<void> {
refreshing.value = true;
if (!summary.value) loading.value = true;
error.value = null;
apiError.value = null;
diagnostic.value = null;
const [summaryResult, listResult, eventsResult] = await Promise.all([
adminSecretsAPI.summary(),
adminSecretsAPI.externalSecrets(),
adminSecretsAPI.events({ limit: 80 })
]);
if (!summaryResult.ok) {
error.value = summaryResult.error ?? `HTTP ${summaryResult.status}`;
apiError.value = summaryResult.apiError ?? null;
diagnostic.value = summaryResult.diagnostic ?? null;
rows.value = [];
detail.value = null;
events.value = [];
} else {
summary.value = normalizeAdminSecretsSummary(summaryResult.data);
const listItems = listResult.ok ? listResult.data?.items : summaryResult.data?.externalSecrets;
rows.value = normalizeAdminSecretsRows(listItems);
events.value = normalizeAdminSecretsEvents(eventsResult.ok ? eventsResult.data?.events : summaryResult.data?.events);
if (!selectedKey.value && rows.value[0]) selectedKey.value = rowKey(rows.value[0]);
await loadDetail(selectedRow.value);
}
loading.value = false;
refreshing.value = false;
}
async function select(row: AdminSecretsRow): Promise<void> {
selectedKey.value = rowKey(row);
await loadDetail(row);
}
async function loadDetail(row: AdminSecretsRow | null): Promise<void> {
if (!row) {
detail.value = null;
return;
}
const response = await adminSecretsAPI.detail(row.namespace, row.name);
detail.value = response.ok ? normalizeAdminSecretsDetail(response.data?.item) : normalizeAdminSecretsDetail(row.raw);
}
function rowKey(row: AdminSecretsRow): string {
return `${row.namespace}/${row.name}`;
}
function displayDate(value: string): string {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return date.toLocaleString("zh-CN", { month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit" });
}
</script>
<template>
<section class="route-stack secrets-admin-page">
<PageHeader eyebrow="Admin" title="密钥" description="Cloud Web 同源只读 API 返回 ESO/ExternalSecret 脱敏状态;页面不读取、不复制、不展示 secret value。" />
<LoadingState v-if="loading" />
<section v-else-if="error" class="data-panel admin-crud-panel">
<EmptyState title="密钥状态加载失败" :description="error" :api-error="apiError" :diagnostic="diagnostic" action-label="重试" @action="reload" />
<ApiErrorDiagnostic :error="error" :api-error="apiError" :diagnostic="diagnostic" compact />
</section>
<template v-else-if="summary">
<section class="secrets-summary-grid">
<section class="data-panel secrets-target-panel">
<header class="panel-header">
<h2>{{ summary.target.node }} / {{ summary.target.lane }}</h2>
<StatusBadge :status="summary.status" :label="summary.status" />
</header>
<dl class="secrets-fact-grid">
<div v-for="[label, value] in targetFacts" :key="label"><dt>{{ label }}</dt><dd>{{ value }}</dd></div>
</dl>
</section>
<section class="data-panel secrets-contract-panel">
<header class="panel-header">
<h2>{{ summary.contractVersion }}</h2>
<button class="table-action" type="button" :disabled="refreshing" @click="reload">{{ refreshing ? "刷新中" : "刷新" }}</button>
</header>
<dl class="secrets-fact-grid">
<div><dt>valuesPrinted</dt><dd>{{ String(summary.valuesPrinted) }}</dd></div>
<div><dt>secretMaterialStored</dt><dd>{{ String(summary.secretMaterialStored) }}</dd></div>
<div><dt>source</dt><dd>{{ summary.sourcePath }}</dd></div>
<div><dt>generated</dt><dd>{{ displayDate(summary.generatedAt) }}</dd></div>
</dl>
</section>
</section>
<section class="secrets-readiness-grid">
<section class="data-panel admin-crud-panel">
<header class="panel-header"><h2>ESO</h2><small>{{ summary.evidence.issue }}</small></header>
<dl class="field-grid">
<div><dt>controller</dt><dd>{{ summary.eso.controller }}</dd></div>
<div><dt>webhook</dt><dd>{{ summary.eso.webhook }}</dd></div>
<div><dt>cert-controller</dt><dd>{{ summary.eso.certController }}</dd></div>
<div><dt>validatedAt</dt><dd>{{ displayDate(summary.evidence.validatedAt) }}</dd></div>
</dl>
</section>
<section class="data-panel admin-crud-panel">
<header class="panel-header"><h2>Rollout</h2><small>{{ summary.evidence.source }}</small></header>
<dl class="field-grid">
<div><dt>ExternalSecrets</dt><dd>{{ summary.counts.externalSecrets }}</dd></div>
<div><dt>Ready</dt><dd>{{ summary.counts.ready }}</dd></div>
<div><dt>Consumers</dt><dd>{{ summary.consumerRollout.ready }} / {{ summary.consumerRollout.total }}</dd></div>
<div><dt>Fingerprint</dt><dd>{{ summary.evidence.fingerprint }}</dd></div>
</dl>
</section>
</section>
<TablePageLayout title="ExternalSecrets" subtitle="ESO read model">
<template #actions>
<span class="status-pill" data-status="ready">{{ rows.length }} rows</span>
</template>
<DataTable :columns="columns" :rows="rows" :row-key="rowKey" empty-text="暂无 ExternalSecret read model">
<template #cell-secret="{ row }">
<strong>{{ (row as AdminSecretsRow).name }}</strong>
<small>{{ (row as AdminSecretsRow).namespace }} / refresh {{ (row as AdminSecretsRow).refreshInterval }}</small>
</template>
<template #cell-target="{ row }">
{{ (row as AdminSecretsRow).targetNode }} / {{ (row as AdminSecretsRow).targetLane }}
<small>{{ (row as AdminSecretsRow).targetSecretName }}</small>
</template>
<template #cell-store="{ row }">
<code>{{ (row as AdminSecretsRow).storeText }}</code>
<small>{{ (row as AdminSecretsRow).keySummary }}</small>
</template>
<template #cell-status="{ row }">
<StatusBadge :status="(row as AdminSecretsRow).status" :label="(row as AdminSecretsRow).status" />
<small>{{ displayDate((row as AdminSecretsRow).lastRefreshTime) }}</small>
</template>
<template #cell-fingerprint="{ row }">
<code>{{ (row as AdminSecretsRow).fingerprint }}</code>
<small>valuesPrinted={{ String((row as AdminSecretsRow).valuesPrinted) }}</small>
</template>
<template #cell-consumer="{ row }">
{{ (row as AdminSecretsRow).consumerSummary }}
<small>secretMaterialStored={{ String((row as AdminSecretsRow).secretMaterialStored) }}</small>
</template>
<template #cell-actions="{ row }">
<button class="table-action" type="button" :data-selected="rowKey(row as AdminSecretsRow) === selectedKey" @click="select(row as AdminSecretsRow)">详情</button>
</template>
</DataTable>
</TablePageLayout>
<section class="secrets-detail-grid">
<section class="data-panel admin-crud-panel">
<header class="panel-header">
<h2>{{ detail?.name || "ExternalSecret" }}</h2>
<StatusBadge :status="detail?.status" :label="detail?.status || 'Unknown'" />
</header>
<p class="muted-box">{{ detail?.reason || "-" }}</p>
<dl v-if="detail" class="field-grid">
<div><dt>namespace</dt><dd>{{ detail.namespace }}</dd></div>
<div><dt>target Secret</dt><dd>{{ detail.targetSecretName }}</dd></div>
<div><dt>Store</dt><dd>{{ detail.storeText }}</dd></div>
<div><dt>fingerprint</dt><dd>{{ detail.fingerprint }}</dd></div>
</dl>
<section v-if="detail" class="secrets-subsection">
<h3>RemoteRef</h3>
<div class="secrets-chip-list">
<span v-for="item in detail.data" :key="`${item.targetKey}:${item.fingerprint}`" class="secrets-chip">
<strong>{{ item.targetKey }}</strong>
<small>{{ item.remotePath }} / {{ item.property }} / {{ item.fingerprint }}</small>
</span>
</div>
</section>
<section v-if="detail" class="secrets-subsection">
<h3>Consumer</h3>
<div class="secrets-chip-list">
<span v-for="item in detail.consumers" :key="`${item.serviceId}:${item.envName}`" class="secrets-chip">
<strong>{{ item.serviceId }}</strong>
<small>{{ item.kind }} {{ item.injectAs }} {{ item.envName }} / {{ item.rollout }}</small>
</span>
</div>
</section>
</section>
<section class="data-panel admin-crud-panel">
<header class="panel-header"><h2>Events</h2><small>{{ events.length }} rows</small></header>
<ol class="secrets-event-list">
<li v-for="event in events" :key="event.id">
<span class="status-pill" :data-status="event.status.toLowerCase()">{{ event.status }}</span>
<div>
<strong>{{ event.reason }}</strong>
<p>{{ event.message }}</p>
<small>{{ displayDate(event.observedAt) }} / {{ event.namespace }} / {{ event.resource }} / {{ event.source }}</small>
</div>
</li>
</ol>
</section>
</section>
</template>
</section>
</template>