feat: add admin secrets read-only MVP
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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}` : "";
|
||||
}
|
||||
@@ -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[];
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user