diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 38053928..488676fd 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -9306,7 +9306,26 @@ "gates": "閘門", "redactedScope": "已脫敏範圍", "blockers": "待補證據 {count}", - "routeShape": "上游 {upstream};後台 {admin};即時通道 {websocket}" + "routeShape": "上游 {upstream};後台 {admin};即時通道 {websocket}", + "ownerRequired": "需負責人", + "routePending": "待 smoke", + "sourceBlockers": "來源缺口" + }, + "atlas": { + "eyebrow": "全域資產地圖", + "totalAssets": "可視資產已納入", + "totalDetail": "{products} 個產品 / 專案、{routes} 個網站 / 服務入口、{repos} 個來源範圍已進入同一張只讀台帳。", + "productMapTitle": "產品 / 專案分類", + "productMapDetail": "先看資產類型與納管狀態,再下鑽到產品列。", + "routeMapTitle": "網站 / 服務入口", + "routeMapDetail": "公開前台、後台工具、平台工具與模型服務入口合併顯示。", + "moreRoutes": "+{count} 個入口" + }, + "sourceGate": { + "primaryReady": "主要來源就緒", + "ownerAccepted": "已接受回覆", + "runtimeGate": "執行閘門", + "actionButtons": "操作入口" }, "columns": { "domain": "網站 / 服務入口", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 38053928..488676fd 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -9306,7 +9306,26 @@ "gates": "閘門", "redactedScope": "已脫敏範圍", "blockers": "待補證據 {count}", - "routeShape": "上游 {upstream};後台 {admin};即時通道 {websocket}" + "routeShape": "上游 {upstream};後台 {admin};即時通道 {websocket}", + "ownerRequired": "需負責人", + "routePending": "待 smoke", + "sourceBlockers": "來源缺口" + }, + "atlas": { + "eyebrow": "全域資產地圖", + "totalAssets": "可視資產已納入", + "totalDetail": "{products} 個產品 / 專案、{routes} 個網站 / 服務入口、{repos} 個來源範圍已進入同一張只讀台帳。", + "productMapTitle": "產品 / 專案分類", + "productMapDetail": "先看資產類型與納管狀態,再下鑽到產品列。", + "routeMapTitle": "網站 / 服務入口", + "routeMapDetail": "公開前台、後台工具、平台工具與模型服務入口合併顯示。", + "moreRoutes": "+{count} 個入口" + }, + "sourceGate": { + "primaryReady": "主要來源就緒", + "ownerAccepted": "已接受回覆", + "runtimeGate": "執行閘門", + "actionButtons": "操作入口" }, "columns": { "domain": "網站 / 服務入口", diff --git a/apps/web/src/app/[locale]/awooop/tenants/page.tsx b/apps/web/src/app/[locale]/awooop/tenants/page.tsx index bfe2968f..03ed50dd 100644 --- a/apps/web/src/app/[locale]/awooop/tenants/page.tsx +++ b/apps/web/src/app/[locale]/awooop/tenants/page.tsx @@ -308,6 +308,19 @@ function assetCategoryLabel(category: string, t: ReturnType) { + const key = ASSET_STATUS_KEYS[status] ?? "unknown"; + return t(`statuses.${key}` as never); +} + +function countBy(items: T[], selectKey: (item: T) => string) { + return items.reduce>((counts, item) => { + const key = selectKey(item); + counts[key] = (counts[key] ?? 0) + 1; + return counts; + }, {}); +} + function sourceRiskKey(risk: string) { const normalized = risk.toLowerCase(); if (normalized === "high" || normalized === "medium" || normalized === "low") return normalized; @@ -922,6 +935,39 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento const products = inventory?.products ?? []; const publicRoutes = inventory?.public_routes ?? []; const sourceRepos = inventory?.source_repos ?? []; + const visibleAssetCount = products.length + publicRoutes.length + sourceRepos.length; + const categoryCounts = countBy(products, (item) => item.category); + const productStatusCounts = countBy(products, (item) => item.coverage_status); + const routeStatusCounts = countBy(publicRoutes, (item) => item.coverage_status); + const routePreviewLimit = 18; + const routePreview = publicRoutes.slice(0, routePreviewLimit); + const remainingRouteCount = Math.max(publicRoutes.length - routePreview.length, 0); + const ownerRequiredProducts = products.filter( + (item) => item.coverage_status === "owner_response_required" + ).length; + const pendingRouteSmokeCount = publicRoutes.filter((item) => !item.route_smoke_accepted).length; + const sourceBlockerCount = sourceRepos.reduce((total, item) => total + item.blocker_count, 0); + const productStatusEntries = Object.entries(productStatusCounts); + const routeStatusEntries = Object.entries(routeStatusCounts); + const categoryEntries = Object.entries(categoryCounts); + const sourceGateRows = [ + { + label: t("sourceGate.primaryReady"), + value: `${summary?.source_primary_ready_count ?? 0}/${summary?.source_candidate_repo_count ?? sourceRepos.length}`, + }, + { + label: t("sourceGate.ownerAccepted"), + value: `${summary?.owner_response_accepted_count ?? 0}`, + }, + { + label: t("sourceGate.runtimeGate"), + value: `${summary?.runtime_gate_count ?? 0}`, + }, + { + label: t("sourceGate.actionButtons"), + value: `${summary?.action_button_count ?? 0}`, + }, + ]; const metrics = [ { key: "products", @@ -1007,6 +1053,128 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento ))} +
+
+

+ {t("atlas.eyebrow")} +

+

{visibleAssetCount || "--"}

+

{t("atlas.totalAssets")}

+

+ {t("atlas.totalDetail", { + products: products.length, + routes: publicRoutes.length, + repos: sourceRepos.length, + })} +

+
+
+

{t("labels.ownerRequired")}

+

{ownerRequiredProducts}

+
+
+

{t("labels.routePending")}

+

{pendingRouteSmokeCount}

+
+
+

{t("labels.sourceBlockers")}

+

{sourceBlockerCount}

+
+
+
+ +
+
+
+

{t("atlas.productMapTitle")}

+

{t("atlas.productMapDetail")}

+
+ + {products.length} + +
+
+ {categoryEntries.map(([category, count]) => ( +
+ + {assetCategoryLabel(category, t)} + +
+
+
+ {count} +
+ ))} +
+
+ {productStatusEntries.map(([status, count]) => ( + + {assetStatusLabel(status, t)} + {count} + + ))} +
+
+ +
+
+
+

{t("atlas.routeMapTitle")}

+

{t("atlas.routeMapDetail")}

+
+ + {publicRoutes.length} + +
+
+ {routePreview.map((route, index) => ( + + {route.domain} + + ))} + {remainingRouteCount > 0 && ( + + {t("atlas.moreRoutes", { count: remainingRouteCount })} + + )} +
+
+ {sourceGateRows.map((row) => ( +
+ {row.label} + {row.value} +
+ ))} +
+
+ {routeStatusEntries.map(([status, count]) => ( + + {assetStatusLabel(status, t)} + {count} + + ))} +
+
+
+