feat: compact admin secrets external secret ui

This commit is contained in:
UniDesk Codex
2026-07-01 15:24:16 +08:00
parent 4bff8c7bbc
commit 4bbcd7ff54
9 changed files with 596 additions and 207 deletions
+24 -24
View File
@@ -1,7 +1,7 @@
# HWLAB v0.3 ESO 密钥管理 UI 规格
SPEC: HWLAB-ESO-UI-20260630 ESO UI draft-2026-06-30-p0.
Issue: [#2292](https://github.com/pikasTech/HWLAB/issues/2292).
SPEC: HWLAB-ESO-UI-20260630 ESO UI draft-2026-07-01-p2-compact-detail.
Issue: [#2292](https://github.com/pikasTech/HWLAB/issues/2292), [#2324](https://github.com/pikasTech/HWLAB/issues/2324).
上级: [#2233](https://github.com/pikasTech/HWLAB/issues/2233), [#2277](https://github.com/pikasTech/HWLAB/issues/2277).
参考: Weave GitOps 0.22.0 Manage Secrets UI, HWLAB `config/hwlab-v03/secrets.yaml`.
@@ -32,44 +32,43 @@ Issue: [#2292](https://github.com/pikasTech/HWLAB/issues/2292).
## 页面结构
### 概览
### `/admin/secrets` 首页
屏顶部展示当前 target 摘要,不把 target 写死在前端:
页首屏直接聚焦 `ExternalSecrets` 紧凑列表和必要操作区,不再展示上一轮同一行的 `JD01 / v03``hwlab-admin-secrets-v1``ESO` 三个顶部摘要模块,也不使用同等体量的新卡片替代它们。
- node、lane、public URL、runtime namespace、infra namespace
- ESO controller / webhook / cert-controller readiness。
- `SecretStore` / `ClusterSecretStore` readiness 和 store scope。
- 最近一次 sync 时间、最近一次 validate 时间、当前 fingerprint 对齐摘要。
- consumer rollout 摘要:关联 Deployment/Job 数量、ready 数量、pending 数量、最近一次 fingerprint 触发的 rollout revision。
- `valuesPrinted=false` 固定可见。
概览区域只显示状态、引用和下一步 drill-down;不显示教程式长文案,也不提供复制 secret value 的入口。
允许保留低噪声刷新、Plan 入口、行数、更新时间和脱敏状态提示;target、contract、ESO readiness、fingerprint、consumer rollout 等诊断信息进入详情页、受控 API 或专门 drill-down,不长期占据首页首屏
### ExternalSecrets 列表
列表列字段:
首页列表固定列顺序:`NAME``STATUS``NAMESPACE``CLUSTER``K8S SECRET``SECRET STORE``AGE`
- `ExternalSecret` 名称、namespace、target node/lane、目标 Secret 名称
- `SecretStore` / `ClusterSecretStore` 名称、store scope
- 状态:`Ready``NotReady``Pending``SyncError``Unknown`
- 最近刷新时间、创建时间、refresh interval
- 目标 Secret key presence、fingerprint 摘要、resourceVersion
- 关联 consumer 与 rollout 状态
- 操作:详情、事件、复制脱敏诊断摘要、打开受控 validate 结果
- `NAME` 可点击,进入 `/admin/secrets/external-secrets/:namespace/:name`
- `STATUS` 覆盖 `Ready``NotReady``Pending``SyncError``Unknown` 和安全阻断状态
- `CLUSTER` 来自同源 read model 的 target/cluster 字段,不由前端硬编码 JD01/D518/D601/G14
- `K8S SECRET` 显示 target Kubernetes Secret 名称和必要 resourceVersion/presence 摘要
- `SECRET STORE` 显示 SecretStore 或 ClusterSecretStore 的脱敏名称与 scope
- `AGE` 来自后端 `createdAt``ageSource``lastUpdated` 或等价 timestamp;前端只能格式化显示,不硬编码时间
- 桌面端保持高密度可扫描行高;窄屏允许表格横向滚动,但不得改变列顺序或造成文本重叠
列表不得提供复制、下载、解码或 reveal secret value 的操作。
### ExternalSecret 详情
详情页分区:
详情页路由为 `/admin/secrets/external-secrets/:namespace/:name`,刷新浏览器后必须能恢复当前资源。页面左上角提供返回按钮和刷新按钮;返回进入 `/admin/secrets` 列表,刷新只刷新当前 ExternalSecret detail 与当前资源 events。
详情页顶部状态区固定两行:第一行 `status`,第二行 `last updated`。顶部状态区下方提供同一行 `DETAILS` / `EVENTS` 切换按钮;切换只影响按钮下方内容区,不重排顶部状态区。
`DETAILS` 分区展示:
- 状态摘要:Ready/NotReady、LastRefreshTime、LastTransitionTime、reason、message 摘要。
- 基本信息:ExternalSecret、target Secret、node/lane、namespace、store kind/name/scope。
- 数据映射:remoteRef path、property、version、target key、fingerprint;路径可脱敏或 hash,不能显示 value。
- 目标 Secretkey presence、fingerprint、resourceVersion、valuesPrinted=false。
- Conditionstype、status、reason、lastTransitionTime。
- ConsumerDeployment/Job、image、pod readiness、rollout revision、template fingerprint annotation、最后 validate 结果。
- Provenance:配置来源文件、Git source commit、GitOps revision、Argo revision 或 status job id。
`EVENTS` 分区只展示当前 ExternalSecret 相关 events,包括 reason、message 摘要、resource、时间、trace/job id;不得展示全局无关 events。
### 创建/编辑向导
本 issue 只设计向导,不启用生产 apply。向导提交后的默认动作是 dry-run/plan。
@@ -119,7 +118,7 @@ UI 可以把 `Pending` 作为 `Unknown` 与明确错误之间的中间态,但
GET /v1/admin/secrets/summary
GET /v1/admin/secrets/external-secrets?node=<id>&lane=<lane>&namespace=<ns>
GET /v1/admin/secrets/external-secrets/:namespace/:name
GET /v1/admin/secrets/events?node=<id>&lane=<lane>&limit=<n>
GET /v1/admin/secrets/events?node=<id>&lane=<lane>&namespace=<ns>&name=<externalSecret>&limit=<n>
POST /v1/admin/secrets/external-secrets/plan
POST /v1/admin/secrets/external-secrets/validate
```
@@ -183,7 +182,8 @@ POST /v1/admin/secrets/rotate
## 验收标准
- 侧边栏设计明确 `密钥` 位于 `授权``用户` 之间。
- 页面覆盖概览、ExternalSecrets 列表、详情、创建/编辑 dry-run 向导、事件与 rollout/validate 可视化
- `/admin/secrets` 首页覆盖紧凑 ExternalSecrets 列表、刷新和 dry-run Plan 入口;不再展示顶部三模块、内联详情或全局 Events
- ExternalSecret 详情深链覆盖状态、last updated、脱敏 details、当前资源 events、返回和刷新。
- 所有 target 默认值由 API/YAML active target 解析,前端不硬编码 JD01、D518、D601 或 G14。
- UI 只覆盖 ESO;没有 SOPS、age/GPG、Git 加密文件、PR 工作流或 secret value 输入框。
- API 草案只通过同源受控 API 暴露脱敏状态,不要求用户执行裸 `kubectl`
+46 -10
View File
@@ -583,6 +583,7 @@ function externalSecretsListPayload(payload, url) {
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 });
const events = filterSecretEvents(payload.events, { namespace, name }).slice(0, 200);
return {
ok: true,
status: "ok",
@@ -591,15 +592,17 @@ function externalSecretDetailPayload(payload, namespace, name) {
secretMaterialStored: false,
generatedAt: payload.generatedAt,
target: payload.target,
item
item,
events,
eventCount: events.length
};
}
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)
const name = text(url.searchParams.get("name"));
const items = filterSecretEvents(payload.events, { namespace, name })
.slice(0, Math.min(limit, 200));
return {
ok: true,
@@ -614,6 +617,14 @@ function eventsPayload(payload, url) {
};
}
function filterSecretEvents(events, { namespace = "", name = "" } = {}) {
return events.filter((event) => {
if (namespace && event.namespace !== namespace) return false;
if (name && event.resourceName !== name) return false;
return true;
});
}
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);
@@ -779,31 +790,40 @@ function normalizeExternalSecrets(secretPlane, target, store, evidence, sourceRe
function normalizeExternalSecret(secret, index, secretPlane, target, store, evidence, sourceRelativePath) {
const item = record(secret) ?? {};
const statusEvidence = record(item.statusEvidence) ?? {};
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 fingerprint = text(statusEvidence.fingerprint) || evidence.fingerprint;
const status = normalizeStatus(statusEvidence.status, evidence.status);
const name = text(item.externalSecretName) || `external-secret-${index + 1}`;
const targetSecretName = text(item.targetSecretName) || name;
const lastUpdated = text(statusEvidence.lastUpdated) || text(statusEvidence.validatedAt) || evidence.validatedAt;
const createdAt = text(item.createdAt) || text(statusEvidence.createdAt) || null;
const cluster = text(item.cluster) || `${target.node}/${target.lane}`;
const reason = text(statusEvidence.reason) || `${evidence.source} validated ${cluster} read-only status`;
return {
id: text(item.id) || name,
name,
namespace,
cluster,
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.",
reason,
message: "脱敏 read model only; sensitive material is never read or returned.",
refreshInterval: text(item.refreshInterval) || text(secretPlane?.refreshInterval) || "1m",
createdAt: null,
lastRefreshTime: evidence.validatedAt,
createdAt,
lastUpdated,
ageSource: createdAt || lastUpdated,
lastRefreshTime: lastUpdated,
conditions: normalizeConditions(item.conditions, { status, reason, lastUpdated }),
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,
resourceVersion: text(statusEvidence.resourceVersion) || null,
fingerprint,
valuesPrinted: false
},
@@ -821,6 +841,22 @@ function normalizeExternalSecret(secret, index, secretPlane, target, store, evid
};
}
function normalizeConditions(value, fallback) {
const source = Array.isArray(value) ? value : [];
const conditions = source.map((entry) => {
const item = record(entry) ?? {};
return {
type: text(item.type) || "Ready",
status: text(item.status) || fallback.status,
reason: text(item.reason) || fallback.reason,
lastTransitionTime: text(item.lastTransitionTime) || fallback.lastUpdated,
valuesPrinted: false
};
});
if (conditions.length > 0) return conditions;
return [{ type: "Ready", status: fallback.status, reason: fallback.reason, lastTransitionTime: fallback.lastUpdated, valuesPrinted: false }];
}
function normalizeSecretData(entry, index) {
const item = record(entry) ?? {};
const remoteRef = text(item.remoteRef);
+1 -1
View File
@@ -5,7 +5,7 @@ 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; name?: 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" })
};
+1
View File
@@ -28,6 +28,7 @@ const routes: RouteRecordRaw[] = [
{ 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/secrets/external-secrets/:namespace/:name", name: "AdminExternalSecretDetail", component: () => import("@/views/admin/ExternalSecretDetailView.vue"), meta: { requiresAuth: true, requiresAdmin: true, navId: "admin.secrets", title: "ExternalSecret 详情", 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" } },
@@ -1,7 +1,7 @@
// 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";
import { asRecord, firstNonEmptyString, formatRelativeTime, nonEmptyString } from "@/utils";
export interface AdminSecretsTargetView {
node: string;
@@ -44,14 +44,23 @@ export interface AdminSecretsRow {
id: string;
name: string;
namespace: string;
cluster: string;
status: string;
ready: boolean;
reason: string;
refreshInterval: string;
createdAt: string;
lastUpdated: string;
ageText: string;
lastRefreshTime: string;
targetNode: string;
targetLane: string;
targetSecretName: string;
targetSecretNamespace: string;
targetSecretResourceVersion: string;
storeKind: string;
storeName: string;
storeScope: string;
storeText: string;
keySummary: string;
fingerprint: string;
@@ -62,8 +71,10 @@ export interface AdminSecretsRow {
}
export interface AdminSecretsDetailView extends AdminSecretsRow {
metadata: Array<{ label: string; value: string }>;
data: Array<{ targetKey: string; remotePath: string; property: string; version: string; fingerprint: string }>;
targetKeys: Array<{ key: string; presence: string; fingerprint: string }>;
conditions: Array<{ type: string; status: string; reason: string; lastTransitionTime: string }>;
consumers: Array<{ serviceId: string; kind: string; injectAs: string; envName: string; status: string; rollout: string; revision: string }>;
provenance: Array<{ label: string; value: string }>;
}
@@ -145,6 +156,19 @@ export function normalizeAdminSecretsDetail(item: AdminSecretsExternalSecret | n
const provenance = asRecord(item.provenance);
return {
...row,
metadata: [
fact("name", row.name),
fact("namespace", row.namespace),
fact("cluster", row.cluster),
fact("refreshInterval", row.refreshInterval),
fact("createdAt", row.createdAt),
fact("lastUpdated", row.lastUpdated),
fact("reason", row.reason),
fact("targetSecretNamespace", row.targetSecretNamespace),
fact("resourceVersion", row.targetSecretResourceVersion),
fact("valuesPrinted", String(row.valuesPrinted)),
fact("secretMaterialStored", String(row.secretMaterialStored))
].filter((item): item is { label: string; value: string } => Boolean(item)),
data: Array.isArray(item.data) ? item.data.map((entry) => {
const remoteRef = asRecord(entry.remoteRef);
return {
@@ -160,6 +184,12 @@ export function normalizeAdminSecretsDetail(item: AdminSecretsExternalSecret | n
presence: nonEmptyString(entry.presence) ?? "unknown",
fingerprint: nonEmptyString(entry.fingerprint) ?? "-"
})) : [],
conditions: Array.isArray(item.conditions) ? item.conditions.map((entry) => ({
type: nonEmptyString(entry.type) ?? "Ready",
status: nonEmptyString(entry.status) ?? row.status,
reason: nonEmptyString(entry.reason) ?? row.reason,
lastTransitionTime: nonEmptyString(entry.lastTransitionTime) ?? row.lastUpdated
})) : [{ type: "Ready", status: row.status, reason: row.reason, lastTransitionTime: row.lastUpdated }],
consumers: Array.isArray(item.consumers) ? item.consumers.map((entry) => ({
serviceId: nonEmptyString(entry.serviceId) ?? "-",
kind: nonEmptyString(entry.kind) ?? "-",
@@ -206,18 +236,31 @@ function normalizeRow(item: AdminSecretsExternalSecret): AdminSecretsRow {
const targetSecret = asRecord(item.targetSecret);
const keys = Array.isArray(targetSecret?.keys) ? targetSecret.keys : [];
const consumers = Array.isArray(item.consumers) ? item.consumers : [];
const cluster = firstNonEmptyString(item.cluster, `${target.node}/${target.lane}`) ?? "-";
const createdAt = nonEmptyString(item.createdAt) ?? "";
const lastUpdated = firstNonEmptyString(item.lastUpdated, item.lastRefreshTime, item.ageSource, item.createdAt) ?? "";
const ageSource = firstNonEmptyString(item.ageSource, item.createdAt, lastUpdated) ?? "";
return {
id: nonEmptyString(item.id) ?? `${nonEmptyString(item.namespace) ?? "-"}:${nonEmptyString(item.name) ?? "-"}`,
name: nonEmptyString(item.name) ?? "-",
namespace: nonEmptyString(item.namespace) ?? target.runtimeNamespace,
cluster,
status: nonEmptyString(item.status) ?? "Unknown",
ready: item.ready === true,
reason: nonEmptyString(item.reason) ?? "-",
refreshInterval: nonEmptyString(item.refreshInterval) ?? "-",
lastRefreshTime: nonEmptyString(item.lastRefreshTime) ?? "",
createdAt,
lastUpdated,
ageText: ageSource ? formatRelativeTime(ageSource) : "-",
lastRefreshTime: nonEmptyString(item.lastRefreshTime) ?? lastUpdated,
targetNode: target.node,
targetLane: target.lane,
targetSecretName: nonEmptyString(targetSecret?.name) ?? "-",
targetSecretNamespace: nonEmptyString(targetSecret?.namespace) ?? nonEmptyString(item.namespace) ?? target.runtimeNamespace,
targetSecretResourceVersion: nonEmptyString(targetSecret?.resourceVersion) ?? "-",
storeKind: store.kind,
storeName: store.name,
storeScope: store.scope,
storeText: `${store.kind}/${store.name}`,
keySummary: keys.map((entry) => `${nonEmptyString(entry.key) ?? "-"}:${nonEmptyString(entry.presence) ?? "unknown"}`).join(", ") || "-",
fingerprint: nonEmptyString(targetSecret?.fingerprint) ?? "-",
@@ -2624,6 +2624,232 @@
font-size: 12px;
}
.secrets-compact-toolbar,
.secrets-detail-commandbar {
display: flex;
min-width: 0;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 10px;
border: 1px solid #d8e1eb;
border-radius: 8px;
background: #ffffff;
padding: 10px 12px;
}
.secrets-compact-toolbar > div:first-child {
display: grid;
min-width: 0;
gap: 2px;
}
.secrets-compact-toolbar strong {
color: #0f172a;
font-size: 15px;
}
.secrets-compact-toolbar small {
color: #64748b;
font-size: 12px;
}
.secrets-external-table .data-table {
min-width: 980px;
}
.secrets-external-table .data-table th,
.secrets-external-table .data-table td {
padding: 6px 8px;
font-size: 12px;
line-height: 1.25;
vertical-align: middle;
}
.secrets-external-table .data-table th {
color: #475569;
font-size: 11px;
letter-spacing: 0;
}
.secrets-external-table .crud-table small {
margin-top: 2px;
max-width: 280px;
line-height: 1.2;
}
.secrets-name-link {
display: grid;
min-width: 0;
gap: 2px;
color: #0f766e;
text-decoration: none;
}
.secrets-name-link:hover strong {
text-decoration: underline;
}
.secrets-detail-status-panel {
padding: 12px;
}
.secrets-status-lines {
display: grid;
min-height: 78px;
min-width: 0;
gap: 8px;
margin: 0;
}
.secrets-status-lines div {
display: grid;
grid-template-columns: 120px minmax(0, 1fr);
align-items: center;
min-width: 0;
gap: 10px;
}
.secrets-status-lines dt,
.secrets-status-lines dd {
margin: 0;
min-width: 0;
}
.secrets-status-lines dt {
color: #64748b;
font-size: 12px;
font-weight: 750;
text-transform: uppercase;
}
.secrets-status-lines dd {
overflow-wrap: anywhere;
color: #0f172a;
font-size: 14px;
font-weight: 750;
}
.secrets-segmented-tabs {
display: inline-flex;
align-items: center;
width: fit-content;
max-width: 100%;
border: 1px solid #cbd5e1;
border-radius: 8px;
overflow: hidden;
background: #f8fafc;
}
.secrets-segmented-tabs button {
min-width: 116px;
border: 0;
border-right: 1px solid #cbd5e1;
background: transparent;
color: #334155;
cursor: pointer;
font-size: 12px;
font-weight: 800;
letter-spacing: 0;
padding: 9px 14px;
}
.secrets-segmented-tabs button:last-child {
border-right: 0;
}
.secrets-segmented-tabs button[data-active="true"] {
background: #0f766e;
color: white;
}
.secrets-detail-content {
display: grid;
min-width: 0;
gap: 12px;
}
.secrets-detail-section {
display: grid;
min-width: 0;
gap: 12px;
padding: 14px;
}
.secrets-detail-fact-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
margin: 0;
}
.secrets-detail-fact-grid div {
min-width: 0;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
padding: 8px 9px;
}
.secrets-detail-fact-grid dt,
.secrets-detail-fact-grid dd {
min-width: 0;
margin: 0;
}
.secrets-detail-fact-grid dt {
color: #64748b;
font-size: 11px;
font-weight: 750;
}
.secrets-detail-fact-grid dd {
overflow-wrap: anywhere;
color: #0f172a;
font-size: 12px;
font-weight: 750;
}
.secrets-ref-table {
display: grid;
max-width: 100%;
min-width: 0;
overflow-x: auto;
border: 1px solid #e2e8f0;
border-radius: 8px;
}
.secrets-ref-table [role="row"] {
display: grid;
min-width: 840px;
grid-template-columns: 140px minmax(220px, 1.4fr) 140px 120px minmax(180px, 1fr);
border-bottom: 1px solid #e2e8f0;
}
.secrets-ref-table [role="row"]:last-child {
border-bottom: 0;
}
.secrets-ref-table span {
min-width: 0;
overflow-wrap: anywhere;
padding: 8px 9px;
color: #334155;
font-size: 12px;
}
.secrets-ref-head span {
background: #f8fafc;
color: #64748b;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.secrets-resource-event-list {
max-height: none;
}
.secrets-summary-grid,
.secrets-readiness-grid,
.secrets-detail-grid {
@@ -3660,10 +3886,26 @@
.field-grid,
.form-grid.two-col,
.compact-form-grid,
.secrets-detail-fact-grid,
.secrets-fact-grid,
.secrets-plan-steps {
grid-template-columns: 1fr;
}
.secrets-status-lines div {
grid-template-columns: 1fr;
gap: 4px;
}
.secrets-segmented-tabs {
display: flex;
width: 100%;
}
.secrets-segmented-tabs button {
flex: 1;
min-width: 0;
}
}
@media (max-width: 720px) {
+5 -1
View File
@@ -443,6 +443,7 @@ export interface AdminSecretsExternalSecret {
id?: string;
name?: string;
namespace?: string;
cluster?: string;
target?: AdminSecretsTarget;
store?: AdminSecretsStore;
status?: AdminSecretStatus;
@@ -451,10 +452,13 @@ export interface AdminSecretsExternalSecret {
message?: string;
refreshInterval?: string;
createdAt?: string | null;
lastUpdated?: string | null;
ageSource?: 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[];
conditions?: Array<{ type?: string; status?: string; reason?: string; lastTransitionTime?: string | null; valuesPrinted?: boolean; [key: string]: unknown }>;
provenance?: Record<string, unknown>;
valuesPrinted?: boolean;
secretMaterialStored?: boolean;
@@ -463,7 +467,7 @@ export interface AdminSecretsExternalSecret {
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; 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 AdminSecretsDetailResponse { ok?: boolean; status?: string; contractVersion?: string; target?: AdminSecretsTarget; item?: AdminSecretsExternalSecret; events?: AdminSecretsEvent[]; eventCount?: number; 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;
@@ -0,0 +1,182 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from "vue";
import { useRoute, useRouter } from "vue-router";
import { adminSecretsAPI } from "@/api";
import ApiErrorDiagnostic from "@/components/common/ApiErrorDiagnostic.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 { normalizeAdminSecretsDetail, normalizeAdminSecretsEvents, type AdminSecretsDetailView, type AdminSecretsEventView } from "@/stores/admin-secrets-view";
import type { ApiError, ErrorDiagnostic } from "@/types";
const route = useRoute();
const router = useRouter();
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 detail = ref<AdminSecretsDetailView | null>(null);
const events = ref<AdminSecretsEventView[]>([]);
const activeTab = ref<"details" | "events">("details");
const namespaceParam = computed(() => String(route.params.namespace ?? ""));
const nameParam = computed(() => String(route.params.name ?? ""));
const lastUpdatedText = computed(() => displayDate(detail.value?.lastUpdated ?? ""));
onMounted(() => void reload());
watch([namespaceParam, nameParam], () => void reload());
async function reload(): Promise<void> {
const namespace = namespaceParam.value;
const name = nameParam.value;
if (!namespace || !name) {
error.value = "缺少 ExternalSecret namespace/name。";
loading.value = false;
return;
}
refreshing.value = true;
if (!detail.value) loading.value = true;
error.value = null;
apiError.value = null;
diagnostic.value = null;
const [detailResult, eventsResult] = await Promise.all([
adminSecretsAPI.detail(namespace, name),
adminSecretsAPI.events({ namespace, name, limit: 80 })
]);
if (!detailResult.ok) {
error.value = detailResult.error ?? `HTTP ${detailResult.status}`;
apiError.value = detailResult.apiError ?? null;
diagnostic.value = detailResult.diagnostic ?? null;
detail.value = null;
events.value = [];
} else {
detail.value = normalizeAdminSecretsDetail(detailResult.data?.item);
events.value = normalizeAdminSecretsEvents(eventsResult.ok ? eventsResult.data?.events : detailResult.data?.events);
}
loading.value = false;
refreshing.value = false;
}
function backToList(): void {
void router.push({ name: "AdminSecrets" });
}
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 external-secret-detail-page">
<PageHeader eyebrow="Admin / 密钥" :title="detail?.name || nameParam" description="当前 ExternalSecret 的脱敏详情与资源级事件。" />
<LoadingState v-if="loading" />
<section v-else-if="error" class="data-panel admin-crud-panel">
<EmptyState title="ExternalSecret 加载失败" :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="detail">
<section class="secrets-detail-commandbar" aria-label="ExternalSecret detail actions">
<button class="table-action" type="button" @click="backToList">返回</button>
<button class="table-action" type="button" :disabled="refreshing" @click="reload">{{ refreshing ? "刷新中" : "刷新" }}</button>
</section>
<section class="data-panel secrets-detail-status-panel">
<dl class="secrets-status-lines">
<div>
<dt>status</dt>
<dd><StatusBadge :status="detail.status" :label="detail.status" /></dd>
</div>
<div>
<dt>last updated</dt>
<dd>{{ lastUpdatedText }}</dd>
</div>
</dl>
</section>
<nav class="secrets-segmented-tabs" aria-label="ExternalSecret detail tabs">
<button type="button" :data-active="activeTab === 'details'" @click="activeTab = 'details'">DETAILS</button>
<button type="button" :data-active="activeTab === 'events'" @click="activeTab = 'events'">EVENTS</button>
</nav>
<section v-if="activeTab === 'details'" class="secrets-detail-content">
<section class="data-panel admin-crud-panel secrets-detail-section">
<header class="panel-header"><h2>Metadata</h2><small>{{ detail.namespace }} / {{ detail.cluster }}</small></header>
<dl class="secrets-detail-fact-grid">
<div v-for="item in detail.metadata" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div>
</dl>
</section>
<section class="data-panel admin-crud-panel secrets-detail-section">
<header class="panel-header"><h2>Target Secret</h2><small>{{ detail.targetSecretName }}</small></header>
<div class="secrets-chip-list">
<span v-for="item in detail.targetKeys" :key="`${item.key}:${item.fingerprint}`" class="secrets-chip">
<strong>{{ item.key }}</strong>
<small>{{ item.presence }} / {{ item.fingerprint }}</small>
</span>
</div>
</section>
<section class="data-panel admin-crud-panel secrets-detail-section">
<header class="panel-header"><h2>Store / RemoteRef</h2><small>{{ detail.storeText }}</small></header>
<div class="secrets-ref-table" role="table" aria-label="ExternalSecret remote references">
<div role="row" class="secrets-ref-head"><span>target key</span><span>path</span><span>property</span><span>version</span><span>fingerprint</span></div>
<div v-for="item in detail.data" :key="`${item.targetKey}:${item.fingerprint}`" role="row">
<span>{{ item.targetKey }}</span><span>{{ item.remotePath }}</span><span>{{ item.property }}</span><span>{{ item.version }}</span><span>{{ item.fingerprint }}</span>
</div>
</div>
</section>
<section class="data-panel admin-crud-panel secrets-detail-section">
<header class="panel-header"><h2>Conditions</h2><small>{{ detail.reason }}</small></header>
<div class="secrets-chip-list">
<span v-for="item in detail.conditions" :key="`${item.type}:${item.lastTransitionTime}`" class="secrets-chip">
<strong>{{ item.type }} / {{ item.status }}</strong>
<small>{{ item.reason }} / {{ displayDate(item.lastTransitionTime) }}</small>
</span>
</div>
</section>
<section class="data-panel admin-crud-panel secrets-detail-section">
<header class="panel-header"><h2>Consumers</h2><small>{{ detail.consumers.length }} rows</small></header>
<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 }} / {{ item.revision }}</small>
</span>
<span v-if="detail.consumers.length === 0" class="muted-box">暂无 consumer rollout 记录</span>
</div>
</section>
<section class="data-panel admin-crud-panel secrets-detail-section">
<header class="panel-header"><h2>Provenance</h2><small>valuesPrinted=false</small></header>
<dl class="secrets-detail-fact-grid">
<div v-for="item in detail.provenance" :key="item.label"><dt>{{ item.label }}</dt><dd>{{ item.value }}</dd></div>
</dl>
</section>
</section>
<section v-else class="data-panel admin-crud-panel secrets-detail-section">
<header class="panel-header"><h2>Events</h2><small>{{ events.length }} rows</small></header>
<ol class="secrets-event-list secrets-resource-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 }} / trace {{ event.traceId }} / job {{ event.jobId }}</small>
</div>
</li>
</ol>
<p v-if="events.length === 0" class="muted-box">当前 ExternalSecret 暂无资源级事件</p>
</section>
</template>
</section>
</template>
@@ -10,17 +10,17 @@ 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 { normalizeAdminSecretsRows, normalizeAdminSecretsSummary, 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" }
{ key: "name", label: "NAME" },
{ key: "status", label: "STATUS" },
{ key: "namespace", label: "NAMESPACE" },
{ key: "cluster", label: "CLUSTER" },
{ key: "targetSecret", label: "K8S SECRET" },
{ key: "store", label: "SECRET STORE" },
{ key: "age", label: "AGE" }
];
const loading = ref(true);
@@ -30,25 +30,10 @@ 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 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 [];
return [
["node", target.node],
["lane", target.lane],
["runtime", target.runtimeNamespace],
["infra", target.infraNamespace],
["source", target.source],
["public", target.publicUrl]
];
});
const updatedText = computed(() => displayDate(summary.value?.generatedAt ?? ""));
onMounted(() => void reload());
@@ -58,48 +43,32 @@ async function reload(): Promise<void> {
error.value = null;
apiError.value = null;
diagnostic.value = null;
const [summaryResult, listResult, eventsResult] = await Promise.all([
const [summaryResult, listResult] = await Promise.all([
adminSecretsAPI.summary(),
adminSecretsAPI.externalSecrets(),
adminSecretsAPI.events({ limit: 80 })
adminSecretsAPI.externalSecrets()
]);
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 detailLocation(row: AdminSecretsRow): { name: string; params: { namespace: string; name: string } } {
return { name: "AdminExternalSecretDetail", params: { namespace: row.namespace, name: row.name } };
}
function displayDate(value: string): string {
if (!value) return "-";
const date = new Date(value);
@@ -110,151 +79,63 @@ function displayDate(value: string): string {
<template>
<section class="route-stack secrets-admin-page">
<PageHeader eyebrow="Admin" title="密钥" description="Cloud Web 同源只读 API 返回 ESO/ExternalSecret 脱敏状态;页面不读取、不复制、不展示 secret value。" />
<PageHeader eyebrow="Admin" title="密钥" description="ExternalSecrets 只读状态来自同源脱敏 read model;页面只展示对象名、状态、presence 与 fingerprint。" />
<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>
<div class="table-actions-inline">
<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>
<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 class="data-panel admin-crud-panel secrets-eso-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>
<section class="secrets-readiness-grid">
<section class="data-panel admin-crud-panel secrets-rollout-panel">
<header class="panel-header">
<div>
<h2>Rollout</h2>
<small>{{ summary.evidence.source }}</small>
</div>
<button v-if="canPlan" class="table-action" type="button" data-testid="admin-secrets-open-plan" @click="planPanelOpen = !planPanelOpen">{{ planPanelOpen ? "收起 Plan" : "Plan" }}</button>
</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>
<template v-else-if="summary">
<section class="secrets-compact-toolbar" aria-label="ExternalSecrets actions">
<div>
<strong>ExternalSecrets</strong>
<small>{{ rows.length }} rows / updated {{ updatedText }} / valuesPrinted={{ String(summary.valuesPrinted) }}</small>
</div>
<div class="table-actions-inline">
<button class="table-action" type="button" :disabled="refreshing" @click="reload">{{ refreshing ? "刷新中" : "刷新" }}</button>
<button v-if="canPlan" class="table-action" type="button" data-testid="admin-secrets-open-plan" @click="planPanelOpen = !planPanelOpen">{{ planPanelOpen ? "收起 Plan" : "Plan" }}</button>
</div>
</section>
<AdminSecretsPlanPanel v-if="canPlan && planPanelOpen" :summary="summary" />
<TablePageLayout title="ExternalSecrets" subtitle="ESO read model">
<TablePageLayout class="secrets-table-page" title="ExternalSecrets" subtitle="NAME / STATUS / NAMESPACE / CLUSTER / K8S SECRET / SECRET STORE / AGE">
<template #actions>
<span class="status-pill" data-status="ready">{{ rows.length }} rows</span>
<StatusBadge :status="summary.status" :label="summary.status" />
</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>
<DataTable class="secrets-external-table" :columns="columns" :rows="rows" :row-key="rowKey" empty-text="暂无 ExternalSecret read model">
<template #cell-name="{ row }">
<RouterLink class="secrets-name-link" :to="detailLocation(row as AdminSecretsRow)">
<strong>{{ (row as AdminSecretsRow).name }}</strong>
<small>{{ (row as AdminSecretsRow).refreshInterval }}</small>
</RouterLink>
</template>
<template #cell-status="{ row }">
<StatusBadge :status="(row as AdminSecretsRow).status" :label="(row as AdminSecretsRow).status" />
<small>{{ displayDate((row as AdminSecretsRow).lastRefreshTime) }}</small>
<small>{{ (row as AdminSecretsRow).reason }}</small>
</template>
<template #cell-fingerprint="{ row }">
<code>{{ (row as AdminSecretsRow).fingerprint }}</code>
<small>valuesPrinted={{ String((row as AdminSecretsRow).valuesPrinted) }}</small>
<template #cell-namespace="{ row }">
<code>{{ (row as AdminSecretsRow).namespace }}</code>
</template>
<template #cell-consumer="{ row }">
{{ (row as AdminSecretsRow).consumerSummary }}
<small>secretMaterialStored={{ String((row as AdminSecretsRow).secretMaterialStored) }}</small>
<template #cell-cluster="{ row }">
{{ (row as AdminSecretsRow).cluster }}
</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 #cell-targetSecret="{ row }">
<code>{{ (row as AdminSecretsRow).targetSecretName }}</code>
<small>{{ (row as AdminSecretsRow).targetSecretResourceVersion }}</small>
</template>
<template #cell-store="{ row }">
<code>{{ (row as AdminSecretsRow).storeKind }}/{{ (row as AdminSecretsRow).storeName }}</code>
<small>{{ (row as AdminSecretsRow).storeScope }}</small>
</template>
<template #cell-age="{ row }">
{{ (row as AdminSecretsRow).ageText }}
<small>{{ displayDate((row as AdminSecretsRow).lastUpdated) }}</small>
</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>