diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index fd782db9..26c1a31a 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -9784,6 +9784,52 @@
}
},
"tenants": {
+ "productCommandMap": {
+ "eyebrow": "產品納管作戰圖",
+ "title": "所有網站、專案、產品的同一張地圖",
+ "subtitle": "產品範圍、網站入口、來源專案庫與 owner 閘門統一收斂;下游串接可觀測性、知識沉澱與推版審查。",
+ "badge": "全域只讀",
+ "loading": "讀取中",
+ "heatmapTitle": "產品納管熱力圖",
+ "heatmapDetail": "每個產品以入口、來源與 owner 狀態呈現納管成熟度。",
+ "remainingProducts": "+{count} 個產品",
+ "drilldownTitle": "關聯工作區",
+ "boundaryTitle": "目前不可誤讀",
+ "boundaryDetail": "已接受負責人回覆 {owner}、執行閘門 {runtime}、操作入口 {actions};因此這仍是可視化與證據索引,不是部署、掃描、修復或路由變更授權。",
+ "metrics": {
+ "totalAssets": "可視資產",
+ "totalAssetsDetail": "產品、網站入口與來源範圍統一成單一資產地圖。",
+ "ownerWaiting": "待 owner",
+ "ownerWaitingDetail": "需要負責人回覆或接受的產品會先停在候選狀態,不升級成已核准。",
+ "routePending": "待入口驗證",
+ "routePendingDetail": "尚未完成 smoke / owner evidence 的公開入口不能被視為正式綠燈。",
+ "runtimeGate": "執行閘門",
+ "runtimeGateDetail": "維持 0 代表此頁沒有任何執行、掃描、部署或修復按鈕。"
+ },
+ "topology": {
+ "products": "產品 / 專案",
+ "productsDetail": "租戶資料 {tenants} 筆,產品範圍由資產台帳統一整理。",
+ "routes": "網站入口",
+ "routesDetail": "待驗證入口 {pending};對應可觀測性訊號覆蓋。",
+ "sources": "來源範圍",
+ "sourcesDetail": "來源缺口 {blockers};對應推版審查風險。",
+ "gates": "owner gate",
+ "gatesDetail": "執行閘門 {runtime}、操作入口 {actions},未批准前全鎖。"
+ },
+ "mini": {
+ "routes": "入口",
+ "sources": "來源",
+ "owner": "owner"
+ },
+ "drilldowns": {
+ "observability": "可觀測性",
+ "observabilityDetail": "主機、服務、網站入口、告警與接收證據。",
+ "knowledge": "知識與自動化",
+ "knowledgeDetail": "KM、PlayBook、腳本、verifier 與 owner review。",
+ "codeReview": "推版審查",
+ "codeReviewDetail": "產品級防木馬、Aider / ElephantAlpha 與 release gate。"
+ }
+ },
"globalAssets": {
"eyebrow": "全域納管",
"title": "全域產品資產台帳",
diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json
index fd782db9..26c1a31a 100644
--- a/apps/web/messages/zh-TW.json
+++ b/apps/web/messages/zh-TW.json
@@ -9784,6 +9784,52 @@
}
},
"tenants": {
+ "productCommandMap": {
+ "eyebrow": "產品納管作戰圖",
+ "title": "所有網站、專案、產品的同一張地圖",
+ "subtitle": "產品範圍、網站入口、來源專案庫與 owner 閘門統一收斂;下游串接可觀測性、知識沉澱與推版審查。",
+ "badge": "全域只讀",
+ "loading": "讀取中",
+ "heatmapTitle": "產品納管熱力圖",
+ "heatmapDetail": "每個產品以入口、來源與 owner 狀態呈現納管成熟度。",
+ "remainingProducts": "+{count} 個產品",
+ "drilldownTitle": "關聯工作區",
+ "boundaryTitle": "目前不可誤讀",
+ "boundaryDetail": "已接受負責人回覆 {owner}、執行閘門 {runtime}、操作入口 {actions};因此這仍是可視化與證據索引,不是部署、掃描、修復或路由變更授權。",
+ "metrics": {
+ "totalAssets": "可視資產",
+ "totalAssetsDetail": "產品、網站入口與來源範圍統一成單一資產地圖。",
+ "ownerWaiting": "待 owner",
+ "ownerWaitingDetail": "需要負責人回覆或接受的產品會先停在候選狀態,不升級成已核准。",
+ "routePending": "待入口驗證",
+ "routePendingDetail": "尚未完成 smoke / owner evidence 的公開入口不能被視為正式綠燈。",
+ "runtimeGate": "執行閘門",
+ "runtimeGateDetail": "維持 0 代表此頁沒有任何執行、掃描、部署或修復按鈕。"
+ },
+ "topology": {
+ "products": "產品 / 專案",
+ "productsDetail": "租戶資料 {tenants} 筆,產品範圍由資產台帳統一整理。",
+ "routes": "網站入口",
+ "routesDetail": "待驗證入口 {pending};對應可觀測性訊號覆蓋。",
+ "sources": "來源範圍",
+ "sourcesDetail": "來源缺口 {blockers};對應推版審查風險。",
+ "gates": "owner gate",
+ "gatesDetail": "執行閘門 {runtime}、操作入口 {actions},未批准前全鎖。"
+ },
+ "mini": {
+ "routes": "入口",
+ "sources": "來源",
+ "owner": "owner"
+ },
+ "drilldowns": {
+ "observability": "可觀測性",
+ "observabilityDetail": "主機、服務、網站入口、告警與接收證據。",
+ "knowledge": "知識與自動化",
+ "knowledgeDetail": "KM、PlayBook、腳本、verifier 與 owner review。",
+ "codeReview": "推版審查",
+ "codeReviewDetail": "產品級防木馬、Aider / ElephantAlpha 與 release gate。"
+ }
+ },
"globalAssets": {
"eyebrow": "全域納管",
"title": "全域產品資產台帳",
diff --git a/apps/web/src/app/[locale]/awooop/tenants/page.tsx b/apps/web/src/app/[locale]/awooop/tenants/page.tsx
index 03ed50dd..69e2ce2d 100644
--- a/apps/web/src/app/[locale]/awooop/tenants/page.tsx
+++ b/apps/web/src/app/[locale]/awooop/tenants/page.tsx
@@ -21,6 +21,7 @@ import {
Globe2,
ListChecks,
Lock,
+ Network,
ShieldCheck,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -577,6 +578,267 @@ function TenantRow({ tenant, index }: { tenant: Tenant; index: number }) {
);
}
+function ProductCommandMap({
+ inventory,
+ tenants,
+ loading,
+}: {
+ inventory: TenantAssetInventory | null;
+ tenants: Tenant[];
+ 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 ?? [];
+ const summary = inventory?.summary;
+ const totalAssets = products.length + publicRoutes.length + sourceRepos.length;
+ const ownerWaitingCount = products.filter(
+ (item) =>
+ item.owner_response_accepted_count === 0 ||
+ item.coverage_status === "owner_response_required"
+ ).length;
+ const routePendingCount = publicRoutes.filter((item) => !item.route_smoke_accepted).length;
+ const sourceBlockerCount = sourceRepos.reduce((total, item) => total + item.blocker_count, 0);
+ const acceptedOwnerCount = summary?.owner_response_accepted_count ?? 0;
+ const runtimeGateCount = summary?.runtime_gate_count ?? 0;
+ const actionButtonCount = summary?.action_button_count ?? 0;
+ const topologyRows = [
+ {
+ key: "products",
+ label: t("topology.products"),
+ value: products.length || "--",
+ Icon: Boxes,
+ tone: "steady" as const,
+ detail: t("topology.productsDetail", { tenants: tenants.length }),
+ },
+ {
+ key: "routes",
+ label: t("topology.routes"),
+ value: publicRoutes.length || "--",
+ Icon: Globe2,
+ tone: routePendingCount > 0 ? "warn" as const : "steady" as const,
+ detail: t("topology.routesDetail", { pending: routePendingCount }),
+ },
+ {
+ key: "sources",
+ label: t("topology.sources"),
+ value: sourceRepos.length || "--",
+ Icon: GitBranch,
+ tone: sourceBlockerCount > 0 ? "warn" as const : "steady" as const,
+ detail: t("topology.sourcesDetail", { blockers: sourceBlockerCount }),
+ },
+ {
+ key: "gates",
+ label: t("topology.gates"),
+ value: acceptedOwnerCount,
+ Icon: Lock,
+ tone: "locked" as const,
+ detail: t("topology.gatesDetail", { runtime: runtimeGateCount, actions: actionButtonCount }),
+ },
+ ];
+ const commandMetrics = [
+ {
+ key: "totalAssets",
+ value: totalAssets || "--",
+ detail: t("metrics.totalAssetsDetail"),
+ Icon: Network,
+ tone: "steady" as const,
+ },
+ {
+ key: "ownerWaiting",
+ value: ownerWaitingCount,
+ detail: t("metrics.ownerWaitingDetail"),
+ Icon: ShieldCheck,
+ tone: ownerWaitingCount > 0 ? "warn" as const : "steady" as const,
+ },
+ {
+ key: "routePending",
+ value: routePendingCount,
+ detail: t("metrics.routePendingDetail"),
+ Icon: Globe2,
+ tone: routePendingCount > 0 ? "warn" as const : "steady" as const,
+ },
+ {
+ key: "runtimeGate",
+ value: runtimeGateCount,
+ detail: t("metrics.runtimeGateDetail"),
+ Icon: Lock,
+ 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 drilldowns = [
+ {
+ href: "/observability",
+ label: t("drilldowns.observability"),
+ detail: t("drilldowns.observabilityDetail"),
+ },
+ {
+ href: "/knowledge-base",
+ label: t("drilldowns.knowledge"),
+ detail: t("drilldowns.knowledgeDetail"),
+ },
+ {
+ href: "/code-review",
+ label: t("drilldowns.codeReview"),
+ detail: t("drilldowns.codeReviewDetail"),
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+ {t("eyebrow")}
+
+
+ {t("title")}
+
+
+ {t("subtitle")}
+
+
+
+ {loading ? t("loading") : t("badge")}
+
+
+
+
+ {commandMetrics.map(({ key, value, detail, Icon, tone }) => (
+
+
+
+
+ {t(`metrics.${key}` as never)}
+
+
{value}
+
+
+
+
+
+
{detail}
+
+ ))}
+
+
+
+
+
+ {topologyRows.map(({ key, label, value, Icon, tone, detail }) => (
+
+ ))}
+
+
+
+
+
+
+
{t("heatmapTitle")}
+
{t("heatmapDetail")}
+
+ {remainingProducts > 0 && (
+
+ {t("remainingProducts", { count: remainingProducts })}
+
+ )}
+
+
+
+ {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}
+
+
+
+ );
+ })}
+
+
+
+
+
{t("drilldownTitle")}
+
+ {drilldowns.map((item) => (
+
+
+ {item.label}
+ {item.detail}
+
+
+
+ ))}
+
+
+
+
{t("boundaryTitle")}
+
+ {t("boundaryDetail", {
+ owner: acceptedOwnerCount,
+ runtime: runtimeGateCount,
+ actions: actionButtonCount,
+ })}
+
+
+
+
+
+
+
+ );
+}
+
function SecurityTenantScopeCandidatePanel() {
const t = useTranslations("awooop.tenants.securityTenantScopeCandidate");
const metrics = [
@@ -1365,12 +1627,12 @@ export default function TenantsPage() {
return (
{/* Page Header */}
-
-
+
+
-
+
租戶管理
-
+
{loading
? "載入中..."
: `共 ${tenants.length} 個租戶 · ${assetInventory?.summary.product_surface_count ?? 0} 個產品 / 專案 · ${assetInventory?.summary.public_route_count ?? 0} 個網站入口`}
@@ -1388,6 +1650,8 @@ export default function TenantsPage() {
+
+