diff --git a/docs/reference/spec-v03-eso-secret-ui.md b/docs/reference/spec-v03-eso-secret-ui.md index 63a9ca03..70dbaba1 100644 --- a/docs/reference/spec-v03-eso-secret-ui.md +++ b/docs/reference/spec-v03-eso-secret-ui.md @@ -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=&lane=&namespace= GET /v1/admin/secrets/external-secrets/:namespace/:name -GET /v1/admin/secrets/events?node=&lane=&limit= +GET /v1/admin/secrets/events?node=&lane=&namespace=&name=&limit= 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`。 diff --git a/internal/cloud/admin-secrets-readmodel.ts b/internal/cloud/admin-secrets-readmodel.ts index 9f7ca07e..fe1ca178 100644 --- a/internal/cloud/admin-secrets-readmodel.ts +++ b/internal/cloud/admin-secrets-readmodel.ts @@ -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); diff --git a/web/hwlab-cloud-web/src/api/adminSecrets.ts b/web/hwlab-cloud-web/src/api/adminSecrets.ts index 5c5a9283..dbea1570 100644 --- a/web/hwlab-cloud-web/src/api/adminSecrets.ts +++ b/web/hwlab-cloud-web/src/api/adminSecrets.ts @@ -5,7 +5,7 @@ export const adminSecretsAPI = { summary: (): Promise> => fetchJson("/v1/admin/secrets/summary", { timeoutMs: 12000, timeoutName: "admin secrets summary" }), externalSecrets: (params: { node?: string; lane?: string; namespace?: string } = {}): Promise> => fetchJson(`/v1/admin/secrets/external-secrets${query(params)}`, { timeoutMs: 12000, timeoutName: "admin secrets list" }), detail: (namespace: string, name: string): Promise> => fetchJson(`/v1/admin/secrets/external-secrets/${encodeURIComponent(namespace)}/${encodeURIComponent(name)}`, { timeoutMs: 12000, timeoutName: "admin secrets detail" }), - events: (params: { namespace?: string; limit?: number } = {}): Promise> => fetchJson(`/v1/admin/secrets/events${query(params)}`, { timeoutMs: 12000, timeoutName: "admin secrets events" }), + events: (params: { namespace?: string; name?: string; limit?: number } = {}): Promise> => fetchJson(`/v1/admin/secrets/events${query(params)}`, { timeoutMs: 12000, timeoutName: "admin secrets events" }), plan: (payload: AdminSecretsPlanRequest): Promise> => fetchJson("/v1/admin/secrets/external-secrets/plan", { method: "POST", body: JSON.stringify(payload), timeoutMs: 12000, timeoutName: "admin secrets plan" }) }; diff --git a/web/hwlab-cloud-web/src/router/index.ts b/web/hwlab-cloud-web/src/router/index.ts index 91c2608f..cc581574 100644 --- a/web/hwlab-cloud-web/src/router/index.ts +++ b/web/hwlab-cloud-web/src/router/index.ts @@ -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" } }, diff --git a/web/hwlab-cloud-web/src/stores/admin-secrets-view.ts b/web/hwlab-cloud-web/src/stores/admin-secrets-view.ts index 7e3beea5..8f743964 100644 --- a/web/hwlab-cloud-web/src/stores/admin-secrets-view.ts +++ b/web/hwlab-cloud-web/src/stores/admin-secrets-view.ts @@ -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) ?? "-", diff --git a/web/hwlab-cloud-web/src/styles/workbench.css b/web/hwlab-cloud-web/src/styles/workbench.css index 07c0bb38..dcd78e21 100644 --- a/web/hwlab-cloud-web/src/styles/workbench.css +++ b/web/hwlab-cloud-web/src/styles/workbench.css @@ -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) { diff --git a/web/hwlab-cloud-web/src/types/index.ts b/web/hwlab-cloud-web/src/types/index.ts index 86f9f3cd..56538008 100644 --- a/web/hwlab-cloud-web/src/types/index.ts +++ b/web/hwlab-cloud-web/src/types/index.ts @@ -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; 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; source?: Record; summary?: Record; 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 { category?: string; diff --git a/web/hwlab-cloud-web/src/views/admin/ExternalSecretDetailView.vue b/web/hwlab-cloud-web/src/views/admin/ExternalSecretDetailView.vue new file mode 100644 index 00000000..500a2ee1 --- /dev/null +++ b/web/hwlab-cloud-web/src/views/admin/ExternalSecretDetailView.vue @@ -0,0 +1,182 @@ + + + diff --git a/web/hwlab-cloud-web/src/views/admin/SecretsView.vue b/web/hwlab-cloud-web/src/views/admin/SecretsView.vue index 5c7b36dc..cd3588f4 100644 --- a/web/hwlab-cloud-web/src/views/admin/SecretsView.vue +++ b/web/hwlab-cloud-web/src/views/admin/SecretsView.vue @@ -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(null); const diagnostic = ref(null); const summary = ref(null); const rows = ref([]); -const detail = ref(null); -const events = ref([]); -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 { 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 { - selectedKey.value = rowKey(row); - await loadDetail(row); -} - -async function loadDetail(row: AdminSecretsRow | null): Promise { - 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 {