diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 808189f8..2c24bfae 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -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": "產品 / 專案",
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index 808189f8..2c24bfae 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -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": "產品 / 專案",
diff --git a/apps/web/src/app/[locale]/awooop/tenants/page.tsx b/apps/web/src/app/[locale]/awooop/tenants/page.tsx
index d037d068..d0f60f6e 100644
--- a/apps/web/src/app/[locale]/awooop/tenants/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/tenants/page.tsx
@@ -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({
-
{t("heatmapTitle")}
-
{t("heatmapDetail")}
+
{t("matrixTitle")}
+
{t("matrixDetail")}
- {remainingProducts > 0 && (
-
- {t("remainingProducts", { count: remainingProducts })}
-
- )}
+
+ {t("matrixBadge")}
+
-
- {productPreview.map((item, index) => {
- const statusTone = ASSET_STATUS_TONES[item.coverage_status] ?? "locked";
- return (
-
-
-
-
- {assetPublicProductName(item, index)}
-
-
- {assetPublicCode(index)} · {assetCategoryLabel(item.category, assetT)}
-
-
-
-
-
-
-
{t("mini.routes")}
-
{item.public_route_count}
-
-
-
{t("mini.sources")}
-
{item.source_repo_count}
-
-
-
{t("mini.owner")}
-
{item.owner_response_accepted_count}
-
-
-
- );
- })}
+
+ {decisionRows.map(({ key, value, status, detail, tone }) => (
+
+
+
+ {t(`matrix.${key}` as never)}
+
+
{value}
+
+
+ {status}
+
+
{detail}
+
+ ))}
@@ -1438,13 +1456,19 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
-
-
-
{t("productsTitle")}
-
- {products.length} {t("itemsUnit")}
+
+
+
+ {t("productsTitle")}
+
+ {t("details.productsSummary", { count: products.length })}
+
-
+
+ {products.length} {t("itemsUnit")}
+
+
+
{products.map((item, index) => (
@@ -1479,7 +1503,7 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
))}
-
+
{t("boundaryTitle")}
@@ -1501,10 +1525,16 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
-
-
- {t("routesTitle")}
-
+
+
+
+ {t("routesTitle")}
+
+ {t("details.routesSummary", { count: publicRoutes.length, pending: pendingRouteSmokeCount })}
+
+
+
+
{publicRoutes.map((route, index) => (
@@ -1568,12 +1598,18 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
-
+
-
-
- {t("reposTitle")}
-
+
+
+
+ {t("reposTitle")}
+
+ {t("details.reposSummary", { count: sourceRepos.length, blockers: sourceBlockerCount })}
+
+
+
+
{sourceRepos.map((repo, index) => (
-
+
);