feat: compact admin secrets external secret ui
This commit is contained in:
@@ -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。
|
||||
- 目标 Secret:key presence、fingerprint、resourceVersion、valuesPrinted=false。
|
||||
- Conditions:type、status、reason、lastTransitionTime。
|
||||
- Consumer:Deployment/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`。
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" })
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user