feat(web): add product command map to tenants
This commit is contained in:
@@ -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": "全域產品資產台帳",
|
||||
|
||||
@@ -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": "全域產品資產台帳",
|
||||
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user