From c07fefbea2a7fcd71f5ab3c5a8b8473eb14fef7f Mon Sep 17 00:00:00 2001 From: Your Name Date: Thu, 25 Jun 2026 14:55:41 +0800 Subject: [PATCH] feat(web): add product command map to tenants --- apps/web/messages/en.json | 46 +++ apps/web/messages/zh-TW.json | 46 +++ .../src/app/[locale]/awooop/tenants/page.tsx | 272 +++++++++++++++++- 3 files changed, 360 insertions(+), 4 deletions(-) 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 }) => ( +
+
+
+

{label}

+

{value}

+
+ + +
+

{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 */} -
-
+
+