Merge pull request #2303 from pikasTech/feat/2298-eso-plan-wizard

feat: 实现 ESO ExternalSecret plan 向导
This commit is contained in:
Lyon
2026-06-30 20:22:34 +08:00
committed by GitHub
7 changed files with 917 additions and 9 deletions
+418 -3
View File
@@ -10,6 +10,8 @@ import { fileURLToPath } from "node:url";
import { sendJson } from "./server-http-utils.ts";
const CONTRACT_VERSION = "hwlab-admin-secrets-v1";
const PLAN_PERMISSION = "admin.secrets.plan";
const MAX_PLAN_BODY_BYTES = 32 * 1024;
const DEFAULT_CONFIG_RELATIVE_PATH = "config/hwlab-v03/secrets.yaml";
const DEFAULT_TARGET = Object.freeze({
node: "JD01",
@@ -29,12 +31,25 @@ const STATUS_PRIORITY = Object.freeze([
"Ready",
"Unknown"
]);
const PLAN_FAILURE_PRIORITY = Object.freeze([
"rbac_denied",
"leak_blocked",
"store_scope_violation",
"store_unavailable",
"target_collision",
"sync_risk",
"validation_failed"
]);
const KUBERNETES_NAME_RE = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u;
const REF_PATH_RE = /^[A-Za-z0-9._/-]+$/u;
const REF_KEY_RE = /^[A-Za-z0-9._-]+$/u;
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);
const planRoute = request.method === "POST" && url.pathname === "/v1/admin/secrets/external-secrets/plan";
const auth = await authenticateAdmin(request, options, { requireAdmin: !planRoute });
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 });
@@ -46,6 +61,13 @@ export async function handleAdminSecretsHttp(request, response, url, options = {
if (request.method === "GET" && url.pathname === "/v1/admin/secrets/external-secrets") {
return sendJson(response, 200, externalSecretsListPayload(readModel.payload, url));
}
if (planRoute) {
const permission = adminSecretsPlanPermission(auth.actor);
if (!permission.allowed) return sendJson(response, 403, planPermissionDeniedPayload(readModel.payload, permission));
const body = await readJsonRequestBody(request, MAX_PLAN_BODY_BYTES);
const plan = buildAdminSecretsPlan(readModel.payload, body, auth);
return sendJson(response, plan.httpStatus ?? 200, plan);
}
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]));
@@ -83,6 +105,13 @@ export function buildAdminSecretsReadModel({ env = process.env } = {}) {
secretMaterialStored: false,
generatedAt,
target,
permissions: {
[PLAN_PERMISSION]: true,
plan: true,
apply: false,
valuesPrinted: false,
secretMaterialStored: false
},
source: {
kind: "yaml",
path: sourceRelativePath,
@@ -142,6 +171,392 @@ export function buildAdminSecretsReadModel({ env = process.env } = {}) {
};
}
export function buildAdminSecretsPlan(payload, requestBody = {}, auth = {}) {
const generatedAt = new Date().toISOString();
const permission = adminSecretsPlanPermission(auth.actor);
if (!permission.allowed) return planPermissionDeniedPayload(payload, permission, generatedAt);
const leak = findPlanSecretMaterial(requestBody);
if (leak) {
return {
...basePlanPayload(payload, generatedAt, permission),
ok: false,
status: "failed",
failureKind: "leak_blocked",
checks: [planCheck("leak-block", "Secret material blocked", "failed", "leak_blocked", "Plan request included a field that can carry secret material.")],
risks: [planRisk("leak_blocked", "blocked", "Plan request was rejected before planning because it contained a disallowed secret material field.")],
error: {
code: "SecretLeakBlocked",
message: "ExternalSecret plan requests must not include secret values.",
layer: "admin-secrets-plan",
retryable: false,
field: leak.path,
valuesPrinted: false
},
httpStatus: 422
};
}
const input = record(requestBody) ?? {};
const activeTarget = payload.target ?? DEFAULT_TARGET;
const targetInput = record(input.target) ?? {};
const target = {
node: (text(targetInput.node) || text(activeTarget.node) || DEFAULT_TARGET.node).toUpperCase(),
lane: text(targetInput.lane) || text(activeTarget.lane) || DEFAULT_TARGET.lane,
namespace: text(targetInput.namespace) || text(targetInput.runtimeNamespace) || text(activeTarget.runtimeNamespace) || DEFAULT_TARGET.runtimeNamespace
};
const mode = normalizePlanMode(input.mode);
const externalSecretName = text(input.externalSecretName) || text(input.name);
const targetSecretName = text(input.targetSecretName) || externalSecretName;
const refreshInterval = text(input.refreshInterval) || "1m";
const store = normalizePlanStore(input.store, payload, target);
const data = normalizePlanData(input.data);
const consumerRollout = normalizePlanConsumerRollout(input.consumerRollout);
const existingExternalSecret = payload.externalSecrets.find((item) => item.namespace === target.namespace && item.name === externalSecretName) ?? null;
const targetSecretCollision = payload.externalSecrets.find((item) => item.namespace === target.namespace && text(record(item.targetSecret)?.name) === targetSecretName) ?? null;
const affectedConsumers = affectedPlanConsumers(existingExternalSecret ?? targetSecretCollision);
const checks = [];
const risks = [];
const failures = new Set();
if (!externalSecretName || !KUBERNETES_NAME_RE.test(externalSecretName) || externalSecretName.length > 63) {
checks.push(planCheck("external-secret-name", "ExternalSecret name", "failed", "validation_failed", "Name must be a Kubernetes DNS label with at most 63 characters."));
failures.add("validation_failed");
} else {
checks.push(planCheck("external-secret-name", "ExternalSecret name", "passed", "ok", externalSecretName));
}
if (!targetSecretName || !KUBERNETES_NAME_RE.test(targetSecretName) || targetSecretName.length > 63) {
checks.push(planCheck("target-secret-name", "Target Secret name", "failed", "validation_failed", "Target Secret name must be a Kubernetes DNS label with at most 63 characters."));
failures.add("validation_failed");
} else {
checks.push(planCheck("target-secret-name", "Target Secret name", "passed", "ok", targetSecretName));
}
if (target.node !== text(activeTarget.node).toUpperCase() || target.lane !== text(activeTarget.lane) || target.namespace !== text(activeTarget.runtimeNamespace)) {
checks.push(planCheck("active-target", "Active target", "failed", "validation_failed", "Plan target must match the API active target."));
failures.add("validation_failed");
} else {
checks.push(planCheck("active-target", "Active target", "passed", "ok", `${target.node}/${target.lane}/${target.namespace}`));
}
const availableStore = findPlanStore(payload, store);
if (store.kind === "SecretStore" && store.namespace !== target.namespace) {
checks.push(planCheck("store-scope", "Store namespace scope", "failed", "store_scope_violation", "Namespace-scoped SecretStore cannot plan for a different namespace."));
risks.push(planRisk("store_scope_violation", "blocked", "Namespace-scoped Store is limited to the target namespace."));
failures.add("store_scope_violation");
} else {
checks.push(planCheck("store-scope", "Store namespace scope", "passed", "ok", store.kind === "ClusterSecretStore" ? `cluster store targets ${target.namespace}` : store.namespace));
}
if (!availableStore) {
checks.push(planCheck("store-available", "Store availability", "failed", "store_unavailable", `${store.kind}/${store.name} was not found in the active read model.`));
risks.push(planRisk("store_unavailable", "blocked", "Plan cannot confirm the requested Store from the active target."));
failures.add("store_unavailable");
} else {
checks.push(planCheck("store-available", "Store availability", "passed", "ok", `${store.kind}/${store.name}`));
}
if (!/^\d+[smhd]$/u.test(refreshInterval)) {
checks.push(planCheck("refresh-interval", "Refresh interval", "failed", "validation_failed", "Refresh interval must use s/m/h/d suffix."));
failures.add("validation_failed");
} else {
checks.push(planCheck("refresh-interval", "Refresh interval", "passed", "ok", refreshInterval));
}
for (const failure of validatePlanData(data, checks, risks)) failures.add(failure);
if (mode === "create" && (existingExternalSecret || targetSecretCollision)) {
const collisionName = existingExternalSecret?.name ?? text(record(targetSecretCollision?.targetSecret)?.name) ?? targetSecretName;
checks.push(planCheck("target-collision", "Target collision", "failed", "target_collision", `${target.namespace}/${collisionName} already exists in the read model.`));
risks.push(planRisk("target_collision", "blocked", "Create plan would collide with an existing ExternalSecret or target Secret."));
failures.add("target_collision");
} else {
checks.push(planCheck("target-collision", "Target collision", "passed", "ok", mode === "edit" ? "edit mode permits matching object" : "no collision detected"));
}
checks.push(planCheck("dry-run", "Dry-run boundary", "passed", "ok", "No Kubernetes, Vault, ESO, or GitOps writes are performed."));
checks.push(planCheck("secret-values", "Secret value boundary", "passed", "ok", "Plan request and response do not include secret values."));
risks.push(planRisk("dry_run_only", "info", "This plan is read-only and cannot apply, rotate, or roll back a Secret."));
if (consumerRollout.enabled && affectedConsumers.length === 0) risks.push(planRisk("consumer_rollout_marker", "info", "Consumer rollout marker is recorded as intent only; no consumer was resolved from the read model."));
const failureKind = firstFailureKind(failures);
const ok = !failureKind;
return {
...basePlanPayload(payload, generatedAt, permission),
ok,
status: ok ? "planned" : "failed",
planId: `esp_${sha256Text(stableJson({ target, mode, externalSecretName, targetSecretName, store, data, refreshInterval, consumerRollout })).slice(0, 16)}`,
failureKind: failureKind || null,
checks,
risks,
plan: {
operation: mode === "edit" ? "edit-external-secret-dry-run" : "create-external-secret-dry-run",
mode,
dryRun: true,
writes: false,
target,
externalSecret: {
name: externalSecretName,
namespace: target.namespace,
refreshInterval
},
targetSecret: {
name: targetSecretName,
namespace: target.namespace,
presence: targetSecretCollision ? "present" : "not-present",
valuesPrinted: false
},
store,
data: data.map((item) => ({
targetKey: item.targetKey,
sourceRef: item.sourceRef,
presence: "not-read-dry-run",
referenceFingerprint: item.referenceFingerprint,
valuesPrinted: false
})),
affectedConsumers,
consumerRollout,
valuesPrinted: false,
secretMaterialStored: false
}
};
}
function basePlanPayload(payload, generatedAt, permission) {
return {
contractVersion: CONTRACT_VERSION,
generatedAt,
target: payload.target,
permission,
valuesPrinted: false,
secretMaterialStored: false
};
}
function planPermissionDeniedPayload(payload, permission, generatedAt = new Date().toISOString()) {
return {
...basePlanPayload(payload, generatedAt, permission),
ok: false,
status: "failed",
failureKind: "rbac_denied",
checks: [planCheck("permission", PLAN_PERMISSION, "failed", "rbac_denied", `${PLAN_PERMISSION} is required.`)],
risks: [planRisk("rbac_denied", "blocked", "Current actor is not allowed to plan ExternalSecret changes.")],
error: {
code: "admin_secrets_plan_required",
message: `${PLAN_PERMISSION} permission is required.`,
layer: "admin-secrets-plan",
retryable: false,
valuesPrinted: false
},
httpStatus: 403
};
}
function adminSecretsPlanPermission(actor) {
const role = text(actor?.role);
const permissions = permissionSetFromActor(actor);
if (role === "admin") return { id: PLAN_PERMISSION, allowed: true, source: "admin-role", valuesPrinted: false };
if (permissions.has(PLAN_PERMISSION)) return { id: PLAN_PERMISSION, allowed: true, source: "actor-permission", valuesPrinted: false };
return { id: PLAN_PERMISSION, allowed: false, source: role || "anonymous", valuesPrinted: false };
}
function permissionSetFromActor(actor) {
const output = new Set();
for (const key of ["permissions", "capabilities"]) {
const value = actor?.[key];
if (Array.isArray(value)) for (const item of value) if (text(item)) output.add(text(item));
if (record(value)) for (const [name, status] of Object.entries(value)) if (status === true || status === "available" || status === "allowed") output.add(name);
}
return output;
}
async function readJsonRequestBody(request, limitBytes) {
let raw = "";
for await (const chunk of request) {
raw += Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk ?? "");
if (Buffer.byteLength(raw, "utf8") > limitBytes) throw Object.assign(new Error("Plan request body is too large"), { httpStatus: 413, code: "plan_payload_too_large" });
}
if (!raw.trim()) return {};
try {
return JSON.parse(raw);
} catch (error) {
throw Object.assign(new Error("Plan request body must be valid JSON"), { httpStatus: 400, code: "plan_payload_parse_error", details: { reason: error.message } });
}
}
function normalizePlanMode(value) {
const mode = text(value).toLowerCase();
return mode === "edit" ? "edit" : "create";
}
function normalizePlanStore(value, payload, target) {
const input = record(value) ?? {};
const firstStore = record(payload.summary?.stores?.[0]) ?? {};
const kind = text(input.kind) === "SecretStore" ? "SecretStore" : "ClusterSecretStore";
const namespace = kind === "SecretStore" ? text(input.namespace) || target.namespace : text(input.namespace) || text(firstStore.namespace) || text(payload.target?.infraNamespace) || "platform-infra";
return {
kind,
name: text(input.name) || text(firstStore.name) || "hwlab-secret-plane-vault-cluster",
namespace,
scope: kind === "SecretStore" ? "namespace" : "cluster",
valuesPrinted: false
};
}
function findPlanStore(payload, store) {
const stores = Array.isArray(payload.summary?.stores) ? payload.summary.stores : [];
return stores.find((item) => {
if (text(item.kind) !== store.kind) return false;
if (text(item.name) !== store.name) return false;
if (store.kind === "SecretStore" && text(item.namespace) !== store.namespace) return false;
return true;
}) ?? null;
}
function normalizePlanData(value) {
const items = Array.isArray(value) ? value : [value].filter(Boolean);
return items.map((entry, index) => {
const item = record(entry) ?? {};
const sourceRef = record(item.sourceRef) ?? record(item.remoteRef) ?? {};
const pathValue = text(sourceRef.path) || text(item.path) || text(item.remotePath);
const property = text(sourceRef.property) || text(item.property);
const version = text(sourceRef.version) || text(item.version) || null;
const targetKey = text(item.targetKey);
return {
targetKey,
sourceRef: {
path: pathValue,
property,
version
},
referenceFingerprint: pathValue || property ? `sha256:${sha256Text(`${pathValue}:${property}:${version ?? ""}`).slice(0, 16)}` : null,
index
};
});
}
function normalizePlanConsumerRollout(value) {
const input = record(value) ?? {};
const enabled = input.enabled === true || text(input.intent) !== "";
const intent = text(input.intent) || (enabled ? "rollout-marker" : "none");
return {
enabled,
intent,
marker: enabled ? "plan-only" : "none",
valuesPrinted: false
};
}
function validatePlanData(data, checks, risks) {
const failures = new Set();
if (data.length === 0) {
checks.push(planCheck("source-ref", "Source reference", "failed", "sync_risk", "At least one sourceRef mapping is required."));
risks.push(planRisk("sync_risk", "blocked", "ESO sync cannot be planned without sourceRef path/property and target key."));
failures.add("sync_risk");
return failures;
}
for (const item of data) {
const label = `data[${item.index}]`;
if (!item.targetKey || !REF_KEY_RE.test(item.targetKey)) {
checks.push(planCheck(`${label}.targetKey`, "Target key", "failed", "validation_failed", "Target key must be non-empty and contain only letters, digits, dot, dash, or underscore."));
failures.add("validation_failed");
}
if (!item.sourceRef.path || !REF_PATH_RE.test(item.sourceRef.path)) {
checks.push(planCheck(`${label}.sourceRef.path`, "Source path", "failed", "sync_risk", "sourceRef.path is required and must be a safe remote reference path."));
risks.push(planRisk("sync_risk", "blocked", "Remote source path is missing or malformed."));
failures.add("sync_risk");
}
if (!item.sourceRef.property || !REF_KEY_RE.test(item.sourceRef.property)) {
checks.push(planCheck(`${label}.sourceRef.property`, "Source property", "failed", "sync_risk", "sourceRef.property is required and must be a safe property name."));
risks.push(planRisk("sync_risk", "blocked", "Remote source property is missing or malformed."));
failures.add("sync_risk");
}
if (item.sourceRef.version && !REF_KEY_RE.test(item.sourceRef.version)) {
checks.push(planCheck(`${label}.sourceRef.version`, "Source version", "failed", "validation_failed", "sourceRef.version contains unsupported characters."));
failures.add("validation_failed");
}
}
if (failures.size === 0) checks.push(planCheck("source-ref", "Source reference", "passed", "ok", `${data.length} mapping(s)`));
return failures;
}
function affectedPlanConsumers(secret) {
if (!secret || !Array.isArray(secret.consumers)) return [];
return secret.consumers.map((item) => ({
serviceId: text(item.serviceId) || "-",
kind: text(item.kind) || "-",
injectAs: text(item.injectAs) || "-",
envName: text(item.envName) || null,
rollout: text(item.rollout) || "Unknown",
revision: text(item.revision) || null,
valuesPrinted: false
}));
}
function firstFailureKind(failures) {
for (const kind of PLAN_FAILURE_PRIORITY) if (failures.has(kind)) return kind;
return "";
}
function planCheck(id, label, status, code, detail) {
return { id, label, status, code, detail, valuesPrinted: false };
}
function planRisk(code, severity, message) {
return { code, severity, message, valuesPrinted: false };
}
function findPlanSecretMaterial(value, pathParts = []) {
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
const found = findPlanSecretMaterial(value[index], [...pathParts, String(index)]);
if (found) return found;
}
return null;
}
if (!value || typeof value !== "object") {
if (typeof value === "string" && looksLikeSecretMaterial(value) && !isPlanDescriptorPath(pathParts)) return { path: pathParts.join(".") || "$" };
return null;
}
for (const [key, item] of Object.entries(value)) {
const nextPath = [...pathParts, key];
if (isSecretValueKey(key) && typeof item === "string" && item.trim() && !isPlanDescriptorPath(nextPath)) return { path: nextPath.join(".") };
const found = findPlanSecretMaterial(item, nextPath);
if (found) return found;
}
return null;
}
function isSecretValueKey(key) {
return /^(value|raw|secretValue|decoded|base64|authorization|cookie|token|apiKey|password)$/u.test(key);
}
function isPlanDescriptorPath(pathParts) {
const key = pathParts[pathParts.length - 1];
if (["targetKey", "property", "version", "path", "remotePath", "externalSecretName", "targetSecretName", "name", "namespace", "kind", "intent", "mode", "node", "lane", "refreshInterval"].includes(key)) return true;
const joined = pathParts.join(".");
return /\.(sourceRef|remoteRef)\.(path|property|version)$/u.test(joined);
}
function looksLikeSecretMaterial(value) {
const item = text(value);
if (!item) return false;
if (/-----BEGIN [A-Z ]*PRIVATE KEY-----/u.test(item)) return true;
if (/^(hsk|ghp|github_pat|sk|xox[baprs])-[-A-Za-z0-9_]{16,}$/u.test(item)) return true;
if (/^[A-Za-z0-9+/]{48,}={0,2}$/u.test(item) && !item.includes("/")) return true;
return false;
}
function stableJson(value) {
return JSON.stringify(sortJson(value));
}
function sortJson(value) {
if (Array.isArray(value)) return value.map(sortJson);
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])]));
return value;
}
function externalSecretsListPayload(payload, url) {
const node = text(url.searchParams.get("node")).toUpperCase();
const lane = text(url.searchParams.get("lane")).toLowerCase();
@@ -199,12 +614,12 @@ function eventsPayload(payload, url) {
};
}
async function authenticateAdmin(request, options) {
async function authenticateAdmin(request, options, { requireAdmin = true } = {}) {
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);
if (requireAdmin && auth.actor?.role !== "admin") return secretsError("admin_required", "Only admin users can read admin secrets status", 403);
return auth;
}
+3 -2
View File
@@ -1,11 +1,12 @@
import { fetchJson } from "./client";
import type { AdminSecretsDetailResponse, AdminSecretsEventsResponse, AdminSecretsListResponse, AdminSecretsSummaryResponse, ApiResult } from "@/types";
import type { AdminSecretsDetailResponse, AdminSecretsEventsResponse, AdminSecretsListResponse, AdminSecretsPlanRequest, AdminSecretsPlanResponse, 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" })
events: (params: { namespace?: string; limit?: number } = {}): Promise<ApiResult<AdminSecretsEventsResponse>> => fetchJson(`/v1/admin/secrets/events${query(params)}`, { timeoutMs: 12000, timeoutName: "admin secrets events" }),
plan: (payload: AdminSecretsPlanRequest): Promise<ApiResult<AdminSecretsPlanResponse>> => fetchJson("/v1/admin/secrets/external-secrets/plan", { method: "POST", body: JSON.stringify(payload), timeoutMs: 12000, timeoutName: "admin secrets plan" })
};
function query(params: Record<string, unknown>): string {
@@ -20,6 +20,7 @@ export interface AdminSecretsSummaryView {
generatedAt: string;
valuesPrinted: boolean;
secretMaterialStored: boolean;
permissions: { plan: boolean; apply: boolean; valuesPrinted: boolean };
target: AdminSecretsTargetView;
sourcePath: string;
sourcePresent: boolean;
@@ -88,12 +89,18 @@ export function normalizeAdminSecretsSummary(payload: AdminSecretsSummaryRespons
const consumerRollout = asRecord(summary?.consumerRollout);
const evidence = asRecord(summary?.latestEvidence);
const source = asRecord(payload?.source);
const permissions = asRecord(payload?.permissions);
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,
permissions: {
plan: permissions?.plan === true || permissions?.["admin.secrets.plan"] === true,
apply: permissions?.apply === true,
valuesPrinted: permissions?.valuesPrinted === true
},
target: normalizeTarget(payload?.target),
sourcePath: nonEmptyString(source?.path) ?? "-",
sourcePresent: source?.present === true,
+138 -2
View File
@@ -2477,6 +2477,14 @@
color: #991b1b;
}
.status-pill[data-status="dry-run"],
.status-pill[data-status="planned"],
.status-pill[data-status="pending"] {
border-color: #bae6fd;
background: #e0f2fe;
color: #075985;
}
.field-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
@@ -2532,6 +2540,7 @@
.form-grid select,
.admin-form input,
.admin-form select,
.admin-form textarea {
min-width: 0;
border: 1px solid #cbd5e1;
@@ -2713,6 +2722,126 @@
font-size: 12px;
}
.secrets-plan-panel {
border-color: #bae6fd;
background: #f8fcff;
}
.secrets-plan-steps {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.secrets-plan-steps li {
display: grid;
min-width: 0;
grid-template-columns: auto minmax(0, 1fr);
gap: 7px;
align-items: center;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: white;
padding: 8px 10px;
color: #334155;
font-size: 12px;
font-weight: 750;
}
.secrets-plan-steps strong {
display: inline-grid;
width: 22px;
height: 22px;
place-items: center;
border-radius: 999px;
background: #0f766e;
color: white;
font-size: 11px;
}
.secrets-plan-form {
gap: 12px;
}
.secrets-plan-fieldset {
display: grid;
min-width: 0;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: white;
margin: 0;
padding: 12px;
}
.secrets-plan-fieldset legend {
padding: 0 4px;
color: #0f766e;
font-size: 12px;
font-weight: 800;
}
.secrets-plan-fieldset input[readonly] {
background: #f8fafc;
color: #475569;
}
.secrets-plan-note {
margin: 0;
color: #64748b;
font-size: 12px;
}
.secrets-plan-toggle {
grid-template-columns: auto minmax(0, 1fr);
align-items: center;
align-self: end;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: #f8fafc;
padding: 9px 10px;
}
.secrets-plan-toggle input {
width: 16px;
height: 16px;
margin: 0;
}
.secrets-plan-result {
display: grid;
min-width: 0;
gap: 12px;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: white;
padding: 12px;
}
.secrets-plan-result h3 {
margin: 0;
color: #0f172a;
font-size: 14px;
}
.secrets-plan-result-grid {
display: grid;
min-width: 0;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.secrets-plan-checks {
display: flex;
min-width: 0;
flex-wrap: wrap;
gap: 8px;
}
.secrets-event-list {
display: grid;
max-height: 560px;
@@ -3496,7 +3625,12 @@
.performance-contract-strip,
.performance-collection-strip,
.performance-chart-grid,
.performance-analytics-grid {
.performance-analytics-grid,
.secrets-summary-grid,
.secrets-readiness-grid,
.secrets-detail-grid,
.secrets-plan-fieldset,
.secrets-plan-result-grid {
grid-template-columns: 1fr;
}
@@ -3520,7 +3654,9 @@
.field-grid,
.form-grid.two-col,
.compact-form-grid {
.compact-form-grid,
.secrets-fact-grid,
.secrets-plan-steps {
grid-template-columns: 1fr;
}
}
+47 -1
View File
@@ -393,6 +393,52 @@ export interface AdminSecretsTargetKey { key?: string; presence?: string; finger
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 AdminSecretsPlanPermission { id?: string; allowed?: boolean; source?: string; valuesPrinted?: boolean; [key: string]: unknown }
export interface AdminSecretsPlanCheck { id?: string; label?: string; status?: string; code?: string; detail?: string; valuesPrinted?: boolean; [key: string]: unknown }
export interface AdminSecretsPlanRisk { code?: string; severity?: string; message?: string; valuesPrinted?: boolean; [key: string]: unknown }
export interface AdminSecretsPlanRequest {
mode?: "create" | "edit" | string;
target?: { node?: string; lane?: string; namespace?: string; runtimeNamespace?: string; [key: string]: unknown };
externalSecretName?: string;
targetSecretName?: string;
store?: { kind?: "SecretStore" | "ClusterSecretStore" | string; name?: string; namespace?: string; [key: string]: unknown };
data?: Array<{ targetKey?: string; sourceRef?: AdminSecretsRemoteRef; remoteRef?: AdminSecretsRemoteRef; [key: string]: unknown }>;
refreshInterval?: string;
consumerRollout?: { enabled?: boolean; intent?: string; [key: string]: unknown };
[key: string]: unknown;
}
export interface AdminSecretsPlanResponse {
ok?: boolean;
status?: string;
contractVersion?: string;
generatedAt?: string;
target?: AdminSecretsTarget;
permission?: AdminSecretsPlanPermission;
planId?: string | null;
failureKind?: string | null;
checks?: AdminSecretsPlanCheck[];
risks?: AdminSecretsPlanRisk[];
plan?: {
operation?: string;
mode?: string;
dryRun?: boolean;
writes?: boolean;
target?: { node?: string; lane?: string; namespace?: string; [key: string]: unknown };
externalSecret?: { name?: string; namespace?: string; refreshInterval?: string; [key: string]: unknown };
targetSecret?: { name?: string; namespace?: string; presence?: string; valuesPrinted?: boolean; [key: string]: unknown };
store?: AdminSecretsStore;
data?: Array<{ targetKey?: string; sourceRef?: AdminSecretsRemoteRef; presence?: string; referenceFingerprint?: string | null; valuesPrinted?: boolean; [key: string]: unknown }>;
affectedConsumers?: AdminSecretsConsumer[];
consumerRollout?: { enabled?: boolean; intent?: string; marker?: string; valuesPrinted?: boolean; [key: string]: unknown };
valuesPrinted?: boolean;
secretMaterialStored?: boolean;
[key: string]: unknown;
};
error?: Record<string, unknown>;
valuesPrinted?: boolean;
secretMaterialStored?: boolean;
[key: string]: unknown;
}
export interface AdminSecretsExternalSecret {
id?: string;
name?: string;
@@ -415,7 +461,7 @@ export interface AdminSecretsExternalSecret {
[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 AdminSecretsSummaryResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; permissions?: Record<string, unknown>; 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 }
@@ -0,0 +1,295 @@
<script setup lang="ts">
import { computed, reactive, ref } from "vue";
import { adminSecretsAPI } from "@/api";
import type { AdminSecretsPlanRequest, AdminSecretsPlanResponse } from "@/types";
import type { AdminSecretsStoreView, AdminSecretsSummaryView } from "@/stores/admin-secrets-view";
const props = defineProps<{
summary: AdminSecretsSummaryView;
}>();
interface PlanForm {
mode: "create" | "edit";
externalSecretName: string;
targetSecretName: string;
storeKind: "ClusterSecretStore" | "SecretStore";
storeName: string;
storeNamespace: string;
remotePath: string;
property: string;
version: string;
targetKey: string;
refreshInterval: string;
consumerRolloutEnabled: boolean;
consumerRolloutIntent: string;
}
const defaultStore = computed(() => props.summary.stores[0] ?? { kind: "ClusterSecretStore", name: "hwlab-secret-plane-vault-cluster", namespace: props.summary.target.infraNamespace, scope: "cluster", status: "Unknown", reason: "" });
const form = reactive<PlanForm>({
mode: "create",
externalSecretName: "",
targetSecretName: "",
storeKind: defaultStore.value.kind === "SecretStore" ? "SecretStore" : "ClusterSecretStore",
storeName: defaultStore.value.name,
storeNamespace: defaultStore.value.kind === "SecretStore" ? props.summary.target.runtimeNamespace : defaultStore.value.namespace,
remotePath: "hwlab-secret-plane/example",
property: "password",
version: "",
targetKey: "password",
refreshInterval: "1m",
consumerRolloutEnabled: true,
consumerRolloutIntent: "rollout-marker"
});
const attempted = ref(false);
const planning = ref(false);
const planResult = ref<AdminSecretsPlanResponse | null>(null);
const planError = ref<string | null>(null);
const storeOptions = computed<AdminSecretsStoreView[]>(() => props.summary.stores);
const fieldErrors = computed(() => {
const errors: Record<string, string> = {};
if (!safeName(form.externalSecretName)) errors.externalSecretName = "需要 DNS label 格式的 ExternalSecret 名称。";
const targetSecret = form.targetSecretName.trim() || form.externalSecretName.trim();
if (!safeName(targetSecret)) errors.targetSecretName = "需要 DNS label 格式的 target Secret 名称。";
if (!form.storeName.trim()) errors.storeName = "需要选择或填写 Store 名称。";
if (form.storeKind === "SecretStore" && form.storeNamespace.trim() !== props.summary.target.runtimeNamespace) errors.storeNamespace = "SecretStore 只能使用目标 namespace。";
if (!/^[A-Za-z0-9._/-]+$/u.test(form.remotePath.trim())) errors.remotePath = "需要 sourceRef path。";
if (!/^[A-Za-z0-9._-]+$/u.test(form.property.trim())) errors.property = "需要 sourceRef property。";
if (!/^[A-Za-z0-9._-]+$/u.test(form.targetKey.trim())) errors.targetKey = "需要 target key。";
if (!/^\d+[smhd]$/u.test(form.refreshInterval.trim())) errors.refreshInterval = "refresh interval 需要使用 s/m/h/d 后缀。";
return errors;
});
const validationMessages = computed(() => Object.values(fieldErrors.value));
const resultPlan = computed(() => planResult.value?.plan ?? null);
const resultData = computed(() => Array.isArray(resultPlan.value?.data) ? resultPlan.value.data : []);
const resultConsumers = computed(() => Array.isArray(resultPlan.value?.affectedConsumers) ? resultPlan.value.affectedConsumers : []);
const resultChecks = computed(() => Array.isArray(planResult.value?.checks) ? planResult.value.checks : []);
const resultRisks = computed(() => Array.isArray(planResult.value?.risks) ? planResult.value.risks : []);
const resultTone = computed(() => planResult.value?.ok === false ? "failed" : planResult.value?.status === "planned" ? "ready" : "pending");
const statusLabel = computed(() => {
if (!planResult.value) return "not submitted";
if (planResult.value.ok === false) return `failed: ${planResult.value.failureKind ?? "unknown"}`;
return `${planResult.value.status ?? "planned"} / dry-run`;
});
function safeName(value: string): boolean {
const name = value.trim();
return /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(name) && name.length <= 63;
}
function syncStoreDefaults(): void {
const match = storeOptions.value.find((store) => store.kind === form.storeKind) ?? null;
form.storeName = match?.name || form.storeName;
form.storeNamespace = form.storeKind === "SecretStore" ? props.summary.target.runtimeNamespace : match?.namespace || props.summary.target.infraNamespace;
}
async function submitPlan(): Promise<void> {
attempted.value = true;
planError.value = null;
if (validationMessages.value.length > 0) return;
planning.value = true;
const response = await adminSecretsAPI.plan(buildPayload());
planResult.value = response.data;
if (!response.ok) planError.value = response.error ?? `HTTP ${response.status}`;
planning.value = false;
}
function buildPayload(): AdminSecretsPlanRequest {
return {
mode: form.mode,
target: {
node: props.summary.target.node,
lane: props.summary.target.lane,
namespace: props.summary.target.runtimeNamespace
},
externalSecretName: form.externalSecretName.trim(),
targetSecretName: form.targetSecretName.trim() || form.externalSecretName.trim(),
store: {
kind: form.storeKind,
name: form.storeName.trim(),
namespace: form.storeNamespace.trim()
},
data: [{
targetKey: form.targetKey.trim(),
sourceRef: {
path: form.remotePath.trim(),
property: form.property.trim(),
version: form.version.trim() || null
}
}],
refreshInterval: form.refreshInterval.trim(),
consumerRollout: {
enabled: form.consumerRolloutEnabled,
intent: form.consumerRolloutEnabled ? form.consumerRolloutIntent.trim() || "rollout-marker" : "none"
}
};
}
</script>
<template>
<section class="data-panel admin-crud-panel secrets-plan-panel" data-testid="admin-secrets-plan-panel">
<header class="panel-header">
<div>
<h2>ExternalSecret Plan</h2>
<small>{{ props.summary.target.node }} / {{ props.summary.target.lane }} / {{ props.summary.target.runtimeNamespace }}</small>
</div>
<span class="status-pill" data-status="dry-run">dry-run only</span>
</header>
<ol class="secrets-plan-steps" aria-label="ExternalSecret plan steps">
<li><strong>1</strong><span>Target</span></li>
<li><strong>2</strong><span>Names</span></li>
<li><strong>3</strong><span>Store</span></li>
<li><strong>4</strong><span>SourceRef</span></li>
<li><strong>5</strong><span>Result</span></li>
</ol>
<form class="admin-form secrets-plan-form" @submit.prevent="submitPlan">
<fieldset class="secrets-plan-fieldset">
<legend>Target</legend>
<label>node<input :value="props.summary.target.node" readonly data-testid="plan-node" /></label>
<label>lane<input :value="props.summary.target.lane" readonly data-testid="plan-lane" /></label>
<label>namespace<input :value="props.summary.target.runtimeNamespace" readonly data-testid="plan-namespace" /></label>
<label>mode
<select v-model="form.mode" data-testid="plan-mode">
<option value="create">create</option>
<option value="edit">edit</option>
</select>
</label>
</fieldset>
<fieldset class="secrets-plan-fieldset">
<legend>Names</legend>
<label>ExternalSecret
<input v-model.trim="form.externalSecretName" placeholder="example-secret" autocomplete="off" data-testid="plan-external-secret-name" />
</label>
<label>target Secret
<input v-model.trim="form.targetSecretName" placeholder="defaults to ExternalSecret" autocomplete="off" data-testid="plan-target-secret-name" />
</label>
</fieldset>
<fieldset class="secrets-plan-fieldset">
<legend>Store</legend>
<label>kind
<select v-model="form.storeKind" data-testid="plan-store-kind" @change="syncStoreDefaults">
<option value="ClusterSecretStore">ClusterSecretStore</option>
<option value="SecretStore">SecretStore</option>
</select>
</label>
<label>name
<input v-model.trim="form.storeName" list="admin-secrets-store-options" autocomplete="off" data-testid="plan-store-name" />
<datalist id="admin-secrets-store-options">
<option v-for="store in storeOptions" :key="`${store.kind}:${store.name}`" :value="store.name">{{ store.kind }}</option>
</datalist>
</label>
<label>store namespace
<input v-model.trim="form.storeNamespace" :readonly="form.storeKind === 'ClusterSecretStore'" autocomplete="off" data-testid="plan-store-namespace" />
</label>
<p class="secrets-plan-note span-2">SecretStore must stay in target namespace; ClusterSecretStore plans against the selected target namespace.</p>
</fieldset>
<fieldset class="secrets-plan-fieldset">
<legend>SourceRef</legend>
<label>path
<input v-model.trim="form.remotePath" autocomplete="off" data-testid="plan-remote-path" />
</label>
<label>property
<input v-model.trim="form.property" autocomplete="off" data-testid="plan-remote-property" />
</label>
<label>version
<input v-model.trim="form.version" placeholder="optional" autocomplete="off" data-testid="plan-remote-version" />
</label>
<label>target key
<input v-model.trim="form.targetKey" autocomplete="off" data-testid="plan-target-key" />
</label>
<label>refresh interval
<input v-model.trim="form.refreshInterval" autocomplete="off" data-testid="plan-refresh-interval" />
</label>
<label class="secrets-plan-toggle">
<input v-model="form.consumerRolloutEnabled" type="checkbox" data-testid="plan-consumer-rollout" />
<span>consumer rollout marker</span>
</label>
<label class="span-2">rollout intent
<input v-model.trim="form.consumerRolloutIntent" :disabled="!form.consumerRolloutEnabled" autocomplete="off" data-testid="plan-consumer-rollout-intent" />
</label>
</fieldset>
<section v-if="attempted && validationMessages.length > 0" class="form-error" data-testid="admin-secrets-plan-validation">
<strong>字段校验未通过</strong>
<ul>
<li v-for="message in validationMessages" :key="message">{{ message }}</li>
</ul>
</section>
<div class="table-actions-inline">
<button class="table-action" type="submit" :disabled="planning" data-testid="admin-secrets-plan-submit">{{ planning ? "Planning" : "Plan" }}</button>
<span class="status-pill" data-status="dry-run">valuesPrinted=false</span>
<span class="status-pill" data-status="dry-run">secretMaterialStored=false</span>
</div>
</form>
<section v-if="planError" class="form-error" data-testid="admin-secrets-plan-error">{{ planError }}</section>
<section v-if="planResult" class="secrets-plan-result" data-testid="admin-secrets-plan-result">
<header class="panel-header">
<div>
<h3>Plan Result</h3>
<small>{{ planResult.planId || "no plan id" }}</small>
</div>
<span class="status-pill" :data-status="resultTone" data-testid="admin-secrets-plan-status">{{ statusLabel }}</span>
</header>
<dl class="secrets-fact-grid">
<div><dt>valuesPrinted</dt><dd>{{ String(planResult.valuesPrinted) }}</dd></div>
<div><dt>secretMaterialStored</dt><dd>{{ String(planResult.secretMaterialStored) }}</dd></div>
<div><dt>permission</dt><dd>{{ planResult.permission?.id }} / {{ String(planResult.permission?.allowed) }}</dd></div>
<div><dt>operation</dt><dd>{{ resultPlan?.operation || "-" }}</dd></div>
<div><dt>target</dt><dd>{{ resultPlan?.target?.node }} / {{ resultPlan?.target?.lane }} / {{ resultPlan?.target?.namespace }}</dd></div>
<div><dt>store</dt><dd>{{ resultPlan?.store?.kind }} / {{ resultPlan?.store?.name }}</dd></div>
</dl>
<section class="secrets-subsection">
<h3>Mapping</h3>
<div class="secrets-chip-list">
<span v-for="item in resultData" :key="`${item.targetKey}:${item.referenceFingerprint}`" class="secrets-chip">
<strong>{{ item.targetKey }} / {{ item.presence }}</strong>
<small>{{ item.sourceRef?.path }} / {{ item.sourceRef?.property }} / {{ item.sourceRef?.version || "latest" }} / {{ item.referenceFingerprint }}</small>
</span>
</div>
</section>
<section class="secrets-plan-result-grid">
<section class="secrets-subsection">
<h3>Consumers</h3>
<div class="secrets-chip-list">
<span v-if="resultConsumers.length === 0" class="secrets-chip"><strong>none</strong><small>{{ resultPlan?.consumerRollout?.marker || "none" }}</small></span>
<span v-for="consumer in resultConsumers" :key="`${consumer.serviceId}:${consumer.envName}`" class="secrets-chip">
<strong>{{ consumer.serviceId }}</strong>
<small>{{ consumer.kind }} / {{ consumer.injectAs }} / {{ consumer.rollout }}</small>
</span>
</div>
</section>
<section class="secrets-subsection">
<h3>Risks</h3>
<div class="secrets-chip-list">
<span v-for="risk in resultRisks" :key="`${risk.code}:${risk.severity}`" class="secrets-chip">
<strong>{{ risk.code }} / {{ risk.severity }}</strong>
<small>{{ risk.message }}</small>
</span>
</div>
</section>
</section>
<section class="secrets-subsection">
<h3>Checks</h3>
<div class="secrets-plan-checks">
<span v-for="check in resultChecks" :key="check.id" class="status-pill" :data-status="check.status === 'failed' ? 'failed' : 'ready'" :title="check.detail">
{{ check.label }}: {{ check.status }}
</span>
</div>
</section>
</section>
</section>
</template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, onMounted, ref } from "vue";
import { adminSecretsAPI } from "@/api";
import AdminSecretsPlanPanel from "./AdminSecretsPlanPanel.vue";
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.vue";
import DataTable from "@/components/common/DataTable.vue";
import type { DataTableColumn } from "@/components/common/DataTable.vue";
@@ -32,8 +33,10 @@ const rows = ref<AdminSecretsRow[]>([]);
const detail = ref<AdminSecretsDetailView | null>(null);
const events = ref<AdminSecretsEventView[]>([]);
const selectedKey = ref("");
const planPanelOpen = ref(false);
const selectedRow = computed(() => rows.value.find((row) => rowKey(row) === selectedKey.value) ?? rows.value[0] ?? null);
const canPlan = computed(() => summary.value?.permissions.plan === true);
const targetFacts = computed(() => {
const target = summary.value?.target;
if (!target) return [];
@@ -128,7 +131,10 @@ function displayDate(value: string): string {
<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>
<div class="table-actions-inline">
<button v-if="canPlan" class="table-action" type="button" data-testid="admin-secrets-open-plan" @click="planPanelOpen = !planPanelOpen">{{ planPanelOpen ? "收起 Plan" : "Plan" }}</button>
<button class="table-action" type="button" :disabled="refreshing" @click="reload">{{ refreshing ? "刷新中" : "刷新" }}</button>
</div>
</header>
<dl class="secrets-fact-grid">
<div><dt>valuesPrinted</dt><dd>{{ String(summary.valuesPrinted) }}</dd></div>
@@ -160,6 +166,8 @@ function displayDate(value: string): string {
</section>
</section>
<AdminSecretsPlanPanel v-if="canPlan && planPanelOpen" :summary="summary" />
<TablePageLayout title="ExternalSecrets" subtitle="ESO read model">
<template #actions>
<span class="status-pill" data-status="ready">{{ rows.length }} rows</span>