fix(web): condense tenants asset cockpit
Some checks failed
Code Review / ai-code-review (push) Successful in 15s
CD Pipeline / tests (push) Successful in 1m42s
CD Pipeline / build-and-deploy (push) Successful in 5m47s
CD Pipeline / post-deploy-checks (push) Has been cancelled

This commit is contained in:
Your Name
2026-06-26 20:25:49 +08:00
parent 89169d24b6
commit 3351b07aa4
3 changed files with 149 additions and 63 deletions

View File

@@ -10749,6 +10749,9 @@
"loading": "讀取中",
"heatmapTitle": "產品納管熱力圖",
"heatmapDetail": "每個產品以入口、來源與 owner 狀態呈現納管成熟度。",
"matrixTitle": "決策支援矩陣",
"matrixDetail": "先判斷全域資產是否納入、哪些還缺證據、下一步該往哪個工作區下鑽。",
"matrixBadge": "首屏判讀",
"remainingProducts": "+{count} 個產品",
"drilldownTitle": "關聯工作區",
"boundaryTitle": "目前不可誤讀",
@@ -10778,6 +10781,23 @@
"sources": "來源",
"owner": "owner"
},
"matrix": {
"products": "產品 / 專案",
"productsStatus": "已進同一張資產地圖",
"productsDetail": "所有前後台產品、公開網站與平台工具先收斂到單一納管視圖。",
"routes": "網站入口",
"routesStatus": "待驗證 {pending}",
"routesDetail": "有入口就必須能追到可觀測性、smoke 與 owner 證據。",
"sources": "來源範圍",
"sourcesStatus": "缺口 {blockers}",
"sourcesDetail": "原始碼範圍只顯示脫敏代號,未完成證據前不得切主來源。",
"owner": "Owner gate",
"ownerStatus": "待回覆 {waiting}",
"ownerDetail": "沒有負責人接受紀錄,就不把候選範圍當正式核准。",
"runtime": "Runtime gate",
"runtimeStatus": "操作入口 {actions}",
"runtimeDetail": "這頁只作納管判讀,不提供掃描、部署、修復或主機操作入口。"
},
"drilldowns": {
"observability": "可觀測性",
"observabilityDetail": "主機、服務、網站入口、告警與接收證據。",
@@ -10855,6 +10875,11 @@
"runtimeGate": "執行閘門",
"actionButtons": "操作入口"
},
"details": {
"productsSummary": "{count} 個產品 / 專案完整明細,預設收合避免干擾首屏判讀。",
"routesSummary": "{count} 個網站 / 服務入口;待驗證 {pending} 個。",
"reposSummary": "{count} 個脫敏來源範圍;待補證據 {blockers} 個。"
},
"columns": {
"domain": "網站 / 服務入口",
"product": "產品 / 專案",

View File

@@ -10749,6 +10749,9 @@
"loading": "讀取中",
"heatmapTitle": "產品納管熱力圖",
"heatmapDetail": "每個產品以入口、來源與 owner 狀態呈現納管成熟度。",
"matrixTitle": "決策支援矩陣",
"matrixDetail": "先判斷全域資產是否納入、哪些還缺證據、下一步該往哪個工作區下鑽。",
"matrixBadge": "首屏判讀",
"remainingProducts": "+{count} 個產品",
"drilldownTitle": "關聯工作區",
"boundaryTitle": "目前不可誤讀",
@@ -10778,6 +10781,23 @@
"sources": "來源",
"owner": "owner"
},
"matrix": {
"products": "產品 / 專案",
"productsStatus": "已進同一張資產地圖",
"productsDetail": "所有前後台產品、公開網站與平台工具先收斂到單一納管視圖。",
"routes": "網站入口",
"routesStatus": "待驗證 {pending}",
"routesDetail": "有入口就必須能追到可觀測性、smoke 與 owner 證據。",
"sources": "來源範圍",
"sourcesStatus": "缺口 {blockers}",
"sourcesDetail": "原始碼範圍只顯示脫敏代號,未完成證據前不得切主來源。",
"owner": "Owner gate",
"ownerStatus": "待回覆 {waiting}",
"ownerDetail": "沒有負責人接受紀錄,就不把候選範圍當正式核准。",
"runtime": "Runtime gate",
"runtimeStatus": "操作入口 {actions}",
"runtimeDetail": "這頁只作納管判讀,不提供掃描、部署、修復或主機操作入口。"
},
"drilldowns": {
"observability": "可觀測性",
"observabilityDetail": "主機、服務、網站入口、告警與接收證據。",
@@ -10855,6 +10875,11 @@
"runtimeGate": "執行閘門",
"actionButtons": "操作入口"
},
"details": {
"productsSummary": "{count} 個產品 / 專案完整明細,預設收合避免干擾首屏判讀。",
"routesSummary": "{count} 個網站 / 服務入口;待驗證 {pending} 個。",
"reposSummary": "{count} 個脫敏來源範圍;待補證據 {blockers} 個。"
},
"columns": {
"domain": "網站 / 服務入口",
"product": "產品 / 專案",

View File

@@ -16,6 +16,7 @@ import {
AlertCircle,
DollarSign,
Ban,
ChevronDown,
CheckCircle2,
GitBranch,
Globe2,
@@ -588,7 +589,6 @@ function ProductCommandMap({
loading: boolean;
}) {
const t = useTranslations("awooop.tenants.productCommandMap");
const assetT = useTranslations("awooop.tenants.globalAssets");
const products = inventory?.products ?? [];
const publicRoutes = inventory?.public_routes ?? [];
const sourceRepos = inventory?.source_repos ?? [];
@@ -604,6 +604,9 @@ function ProductCommandMap({
const acceptedOwnerCount = summary?.owner_response_accepted_count ?? 0;
const runtimeGateCount = summary?.runtime_gate_count ?? 0;
const actionButtonCount = summary?.action_button_count ?? 0;
const routeReadyCount = publicRoutes.filter((item) => item.route_smoke_accepted).length;
const sourcePrimaryReadyCount = summary?.source_primary_ready_count ?? 0;
const sourceCandidateCount = summary?.source_candidate_repo_count ?? sourceRepos.length;
const topologyRows = [
{
key: "products",
@@ -668,8 +671,43 @@ function ProductCommandMap({
tone: runtimeGateCount === 0 ? "locked" as const : "warn" as const,
},
];
const productPreview = products.slice(0, 12);
const remainingProducts = Math.max(products.length - productPreview.length, 0);
const decisionRows = [
{
key: "products",
value: `${products.length}`,
status: t("matrix.productsStatus"),
detail: t("matrix.productsDetail"),
tone: "steady" as const,
},
{
key: "routes",
value: `${routeReadyCount}/${publicRoutes.length}`,
status: t("matrix.routesStatus", { pending: routePendingCount }),
detail: t("matrix.routesDetail"),
tone: routePendingCount > 0 ? "warn" as const : "steady" as const,
},
{
key: "sources",
value: `${sourcePrimaryReadyCount}/${sourceCandidateCount}`,
status: t("matrix.sourcesStatus", { blockers: sourceBlockerCount }),
detail: t("matrix.sourcesDetail"),
tone: sourceBlockerCount > 0 ? "warn" as const : "steady" as const,
},
{
key: "owner",
value: `${acceptedOwnerCount}/${products.length}`,
status: t("matrix.ownerStatus", { waiting: ownerWaitingCount }),
detail: t("matrix.ownerDetail"),
tone: "warn" as const,
},
{
key: "runtime",
value: `${runtimeGateCount}`,
status: t("matrix.runtimeStatus", { actions: actionButtonCount }),
detail: t("matrix.runtimeDetail"),
tone: "locked" as const,
},
];
const drilldowns = [
{
href: "/observability",
@@ -754,52 +792,32 @@ function ProductCommandMap({
<div className="min-w-0 bg-[#faf9f3] p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-xs font-semibold text-[#77736a]">{t("heatmapTitle")}</p>
<p className="mt-1 text-sm font-semibold text-[#141413]">{t("heatmapDetail")}</p>
<p className="text-xs font-semibold text-[#77736a]">{t("matrixTitle")}</p>
<p className="mt-1 text-sm font-semibold text-[#141413]">{t("matrixDetail")}</p>
</div>
{remainingProducts > 0 && (
<span className="border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#5f5b52]">
{t("remainingProducts", { count: remainingProducts })}
</span>
)}
<span className="border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#5f5b52]">
{t("matrixBadge")}
</span>
</div>
<div className="mt-4 grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
{productPreview.map((item, index) => {
const statusTone = ASSET_STATUS_TONES[item.coverage_status] ?? "locked";
return (
<article
key={`${item.product_id}:${index}`}
className="min-w-0 border border-[#e0ddd4] bg-white px-3 py-3"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<p className="break-words text-sm font-semibold text-[#141413]">
{assetPublicProductName(item, index)}
</p>
<p className="mt-1 font-mono text-[11px] text-[#77736a]">
{assetPublicCode(index)} · {assetCategoryLabel(item.category, assetT)}
</p>
</div>
<span className={cn("h-3 w-3 shrink-0 border", assetToneClass(statusTone))} aria-hidden="true" />
</div>
<div className="mt-3 grid grid-cols-3 gap-1.5 text-[11px]">
<div className="border border-[#eee9dd] bg-[#faf9f3] px-2 py-1">
<p className="text-[#77736a]">{t("mini.routes")}</p>
<p className="font-mono font-semibold text-[#141413]">{item.public_route_count}</p>
</div>
<div className="border border-[#eee9dd] bg-[#faf9f3] px-2 py-1">
<p className="text-[#77736a]">{t("mini.sources")}</p>
<p className="font-mono font-semibold text-[#141413]">{item.source_repo_count}</p>
</div>
<div className="border border-[#eee9dd] bg-[#faf9f3] px-2 py-1">
<p className="text-[#77736a]">{t("mini.owner")}</p>
<p className="font-mono font-semibold text-[#141413]">{item.owner_response_accepted_count}</p>
</div>
</div>
</article>
);
})}
<div className="mt-4 grid gap-2">
{decisionRows.map(({ key, value, status, detail, tone }) => (
<div
key={key}
className="grid gap-3 border border-[#e0ddd4] bg-white px-3 py-3 sm:grid-cols-[112px_minmax(0,1fr)_minmax(0,1.2fr)] sm:items-center"
>
<div className="min-w-0">
<p className="text-[11px] font-semibold text-[#77736a]">
{t(`matrix.${key}` as never)}
</p>
<p className="mt-1 font-mono text-2xl font-semibold text-[#141413]">{value}</p>
</div>
<div className={cn("min-w-0 border px-2 py-1 text-xs font-semibold", assetToneClass(tone))}>
{status}
</div>
<p className="min-w-0 text-xs leading-5 text-[#5f5b52]">{detail}</p>
</div>
))}
</div>
</div>
@@ -1438,13 +1456,19 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
</div>
<div className="grid gap-4 p-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
<div className="min-w-0 border border-[#e0ddd4] bg-white">
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<p className="text-xs font-semibold text-[#141413]">{t("productsTitle")}</p>
<span className="font-mono text-xs text-[#77736a]">
{products.length} {t("itemsUnit")}
<details className="group min-w-0 overflow-hidden border border-[#e0ddd4] bg-white">
<summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-2 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3 [&::-webkit-details-marker]:hidden">
<span className="min-w-0">
<span className="block text-xs font-semibold text-[#141413]">{t("productsTitle")}</span>
<span className="mt-1 block text-[11px] text-[#77736a]">
{t("details.productsSummary", { count: products.length })}
</span>
</span>
</div>
<span className="inline-flex items-center gap-2 font-mono text-xs font-semibold text-[#5f5b52]">
{products.length} {t("itemsUnit")}
<ChevronDown className="h-4 w-4 transition-transform group-open:rotate-180" aria-hidden="true" />
</span>
</summary>
<div className="grid gap-px bg-[#eee9dd] md:grid-cols-2">
{products.map((item, index) => (
<article key={item.product_id} className="min-w-0 bg-white px-4 py-3">
@@ -1479,7 +1503,7 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
</article>
))}
</div>
</div>
</details>
<div className="min-w-0 border border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t("boundaryTitle")}</p>
@@ -1501,10 +1525,16 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
</div>
<div className="grid gap-4 border-t border-[#e0ddd4] bg-[#faf9f3] p-4 xl:grid-cols-2">
<div className="min-w-0 overflow-hidden border border-[#e0ddd4] bg-white">
<div className="border-b border-[#e0ddd4] px-4 py-3 text-xs font-semibold text-[#141413]">
{t("routesTitle")}
</div>
<details className="group min-w-0 overflow-hidden border border-[#e0ddd4] bg-white">
<summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-2 border-b border-[#e0ddd4] px-4 py-3 [&::-webkit-details-marker]:hidden">
<span className="min-w-0">
<span className="block text-xs font-semibold text-[#141413]">{t("routesTitle")}</span>
<span className="mt-1 block text-[11px] text-[#77736a]">
{t("details.routesSummary", { count: publicRoutes.length, pending: pendingRouteSmokeCount })}
</span>
</span>
<ChevronDown className="h-4 w-4 text-[#5f5b52] transition-transform group-open:rotate-180" aria-hidden="true" />
</summary>
<div data-testid="awooop-route-card-grid" className="grid gap-2 p-3 xl:hidden">
{publicRoutes.map((route, index) => (
<article key={route.domain} className="border border-[#eee9dd] bg-[#faf9f3] px-3 py-3">
@@ -1568,12 +1598,18 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
</tbody>
</table>
</div>
</div>
</details>
<div className="min-w-0 overflow-hidden border border-[#e0ddd4] bg-white">
<div className="border-b border-[#e0ddd4] px-4 py-3 text-xs font-semibold text-[#141413]">
{t("reposTitle")}
</div>
<details className="group min-w-0 overflow-hidden border border-[#e0ddd4] bg-white">
<summary className="flex cursor-pointer list-none flex-wrap items-center justify-between gap-2 border-b border-[#e0ddd4] px-4 py-3 [&::-webkit-details-marker]:hidden">
<span className="min-w-0">
<span className="block text-xs font-semibold text-[#141413]">{t("reposTitle")}</span>
<span className="mt-1 block text-[11px] text-[#77736a]">
{t("details.reposSummary", { count: sourceRepos.length, blockers: sourceBlockerCount })}
</span>
</span>
<ChevronDown className="h-4 w-4 text-[#5f5b52] transition-transform group-open:rotate-180" aria-hidden="true" />
</summary>
<div data-testid="awooop-source-card-grid" className="grid gap-2 p-3 xl:hidden">
{sourceRepos.map((repo, index) => (
<article
@@ -1660,7 +1696,7 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
</tbody>
</table>
</div>
</div>
</details>
</div>
</section>
);