feat(web): add product command map to tenants
All checks were successful
Code Review / ai-code-review (push) Successful in 13s
CD Pipeline / tests (push) Successful in 1m40s
CD Pipeline / build-and-deploy (push) Successful in 5m10s
CD Pipeline / post-deploy-checks (push) Successful in 1m40s

This commit is contained in:
Your Name
2026-06-25 14:55:41 +08:00
parent c5d76eb360
commit c07fefbea2
3 changed files with 360 additions and 4 deletions

View File

@@ -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": "全域產品資產台帳",

View File

@@ -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": "全域產品資產台帳",

View File

@@ -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 (
<section
className="awooop-product-command-map overflow-hidden border border-[#d8d3c7] bg-white shadow-[0_1px_4px_rgba(0,0,0,0.05)]"
data-testid="awooop-product-command-map"
>
<div className="grid gap-px bg-[#e0ddd4] xl:grid-cols-[minmax(0,0.72fr)_minmax(0,1.28fr)]">
<div className="min-w-0 bg-[#262f3a] px-4 py-4 text-white">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-[#8fd3ff]">
{t("eyebrow")}
</p>
<h3 className="mt-2 font-['Syne'] text-2xl font-bold leading-tight">
{t("title")}
</h3>
<p className="mt-3 max-w-xl text-sm leading-6 text-[#d8e4ed]">
{t("subtitle")}
</p>
</div>
<span className="inline-flex shrink-0 border border-[#547089] bg-[#1b2530] px-2 py-1 text-xs font-semibold text-[#d8e4ed]">
{loading ? t("loading") : t("badge")}
</span>
</div>
<div className="mt-5 grid gap-2 sm:grid-cols-2">
{commandMetrics.map(({ key, value, detail, Icon, tone }) => (
<div key={key} className="min-w-0 border border-[#465a6a] bg-[#1d2732] px-3 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-[11px] font-semibold text-[#9fb3c4]">
{t(`metrics.${key}` as never)}
</p>
<p className="mt-2 font-mono text-2xl font-semibold text-white">{value}</p>
</div>
<span className={cn("flex h-8 w-8 shrink-0 items-center justify-center border", assetToneClass(tone))}>
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
</div>
<p className="mt-2 text-xs leading-5 text-[#c6d4df]">{detail}</p>
</div>
))}
</div>
</div>
<div className="min-w-0 bg-white">
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
{topologyRows.map(({ key, label, value, Icon, tone, detail }) => (
<div key={key} className="min-w-0 bg-white px-4 py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
<p className="mt-2 font-mono text-2xl font-semibold text-[#141413]">{value}</p>
</div>
<span className={cn("flex h-8 w-8 shrink-0 items-center justify-center border", assetToneClass(tone))}>
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
</div>
<p className="mt-3 text-xs leading-5 text-[#5f5b52]">{detail}</p>
</div>
))}
</div>
<div className="grid gap-px bg-[#e0ddd4] lg:grid-cols-[minmax(0,1fr)_320px]">
<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>
</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>
)}
</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>
</div>
<div className="min-w-0 bg-white p-4">
<p className="text-xs font-semibold text-[#77736a]">{t("drilldownTitle")}</p>
<div className="mt-3 grid gap-2">
{drilldowns.map((item) => (
<Link
key={item.href}
href={item.href}
className="group grid grid-cols-[minmax(0,1fr)_20px] items-center gap-3 border border-[#e0ddd4] bg-[#faf9f3] px-3 py-2 hover:border-[#d97757] hover:bg-white"
>
<span className="min-w-0">
<span className="block text-sm font-semibold text-[#141413]">{item.label}</span>
<span className="mt-1 block text-xs leading-5 text-[#5f5b52]">{item.detail}</span>
</span>
<ArrowRight className="h-4 w-4 text-[#77736a] group-hover:text-[#d97757]" aria-hidden="true" />
</Link>
))}
</div>
<div className="mt-4 border border-[#d9b36f] bg-[#fff7e8] px-3 py-3">
<p className="text-xs font-semibold text-[#8a5a08]">{t("boundaryTitle")}</p>
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">
{t("boundaryDetail", {
owner: acceptedOwnerCount,
runtime: runtimeGateCount,
actions: actionButtonCount,
})}
</p>
</div>
</div>
</div>
</div>
</div>
</section>
);
}
function SecurityTenantScopeCandidatePanel() {
const t = useTranslations("awooop.tenants.securityTenantScopeCandidate");
const metrics = [
@@ -1365,12 +1627,12 @@ export default function TenantsPage() {
return (
<div className="space-y-6">
{/* Page Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-w-0 items-center gap-3">
<Building2 className="w-5 h-5 text-brand-accent" aria-hidden="true" />
<div>
<div className="min-w-0">
<h2 className="text-lg font-semibold text-foreground"></h2>
<p className="text-xs text-muted-foreground">
<p className="break-words text-xs text-muted-foreground">
{loading
? "載入中..."
: `${tenants.length} 個租戶 · ${assetInventory?.summary.product_surface_count ?? 0} 個產品 / 專案 · ${assetInventory?.summary.public_route_count ?? 0} 個網站入口`}
@@ -1388,6 +1650,8 @@ export default function TenantsPage() {
</button>
</div>
<ProductCommandMap inventory={assetInventory} tenants={tenants} loading={loading} />
<GlobalAssetCoveragePanel inventory={assetInventory} />
<SecurityTenantScopeCandidatePanel />