feat(web): 前移 Tenants 全域資產地圖
This commit is contained in:
@@ -9306,7 +9306,26 @@
|
||||
"gates": "閘門",
|
||||
"redactedScope": "已脫敏範圍",
|
||||
"blockers": "待補證據 {count}",
|
||||
"routeShape": "上游 {upstream};後台 {admin};即時通道 {websocket}"
|
||||
"routeShape": "上游 {upstream};後台 {admin};即時通道 {websocket}",
|
||||
"ownerRequired": "需負責人",
|
||||
"routePending": "待 smoke",
|
||||
"sourceBlockers": "來源缺口"
|
||||
},
|
||||
"atlas": {
|
||||
"eyebrow": "全域資產地圖",
|
||||
"totalAssets": "可視資產已納入",
|
||||
"totalDetail": "{products} 個產品 / 專案、{routes} 個網站 / 服務入口、{repos} 個來源範圍已進入同一張只讀台帳。",
|
||||
"productMapTitle": "產品 / 專案分類",
|
||||
"productMapDetail": "先看資產類型與納管狀態,再下鑽到產品列。",
|
||||
"routeMapTitle": "網站 / 服務入口",
|
||||
"routeMapDetail": "公開前台、後台工具、平台工具與模型服務入口合併顯示。",
|
||||
"moreRoutes": "+{count} 個入口"
|
||||
},
|
||||
"sourceGate": {
|
||||
"primaryReady": "主要來源就緒",
|
||||
"ownerAccepted": "已接受回覆",
|
||||
"runtimeGate": "執行閘門",
|
||||
"actionButtons": "操作入口"
|
||||
},
|
||||
"columns": {
|
||||
"domain": "網站 / 服務入口",
|
||||
|
||||
@@ -9306,7 +9306,26 @@
|
||||
"gates": "閘門",
|
||||
"redactedScope": "已脫敏範圍",
|
||||
"blockers": "待補證據 {count}",
|
||||
"routeShape": "上游 {upstream};後台 {admin};即時通道 {websocket}"
|
||||
"routeShape": "上游 {upstream};後台 {admin};即時通道 {websocket}",
|
||||
"ownerRequired": "需負責人",
|
||||
"routePending": "待 smoke",
|
||||
"sourceBlockers": "來源缺口"
|
||||
},
|
||||
"atlas": {
|
||||
"eyebrow": "全域資產地圖",
|
||||
"totalAssets": "可視資產已納入",
|
||||
"totalDetail": "{products} 個產品 / 專案、{routes} 個網站 / 服務入口、{repos} 個來源範圍已進入同一張只讀台帳。",
|
||||
"productMapTitle": "產品 / 專案分類",
|
||||
"productMapDetail": "先看資產類型與納管狀態,再下鑽到產品列。",
|
||||
"routeMapTitle": "網站 / 服務入口",
|
||||
"routeMapDetail": "公開前台、後台工具、平台工具與模型服務入口合併顯示。",
|
||||
"moreRoutes": "+{count} 個入口"
|
||||
},
|
||||
"sourceGate": {
|
||||
"primaryReady": "主要來源就緒",
|
||||
"ownerAccepted": "已接受回覆",
|
||||
"runtimeGate": "執行閘門",
|
||||
"actionButtons": "操作入口"
|
||||
},
|
||||
"columns": {
|
||||
"domain": "網站 / 服務入口",
|
||||
|
||||
@@ -308,6 +308,19 @@ function assetCategoryLabel(category: string, t: ReturnType<typeof useTranslatio
|
||||
return t(`categories.${key}` as never);
|
||||
}
|
||||
|
||||
function assetStatusLabel(status: string, t: ReturnType<typeof useTranslations>) {
|
||||
const key = ASSET_STATUS_KEYS[status] ?? "unknown";
|
||||
return t(`statuses.${key}` as never);
|
||||
}
|
||||
|
||||
function countBy<T>(items: T[], selectKey: (item: T) => string) {
|
||||
return items.reduce<Record<string, number>>((counts, item) => {
|
||||
const key = selectKey(item);
|
||||
counts[key] = (counts[key] ?? 0) + 1;
|
||||
return counts;
|
||||
}, {});
|
||||
}
|
||||
|
||||
function sourceRiskKey(risk: string) {
|
||||
const normalized = risk.toLowerCase();
|
||||
if (normalized === "high" || normalized === "medium" || normalized === "low") return normalized;
|
||||
@@ -922,6 +935,39 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
|
||||
const products = inventory?.products ?? [];
|
||||
const publicRoutes = inventory?.public_routes ?? [];
|
||||
const sourceRepos = inventory?.source_repos ?? [];
|
||||
const visibleAssetCount = products.length + publicRoutes.length + sourceRepos.length;
|
||||
const categoryCounts = countBy(products, (item) => item.category);
|
||||
const productStatusCounts = countBy(products, (item) => item.coverage_status);
|
||||
const routeStatusCounts = countBy(publicRoutes, (item) => item.coverage_status);
|
||||
const routePreviewLimit = 18;
|
||||
const routePreview = publicRoutes.slice(0, routePreviewLimit);
|
||||
const remainingRouteCount = Math.max(publicRoutes.length - routePreview.length, 0);
|
||||
const ownerRequiredProducts = products.filter(
|
||||
(item) => item.coverage_status === "owner_response_required"
|
||||
).length;
|
||||
const pendingRouteSmokeCount = publicRoutes.filter((item) => !item.route_smoke_accepted).length;
|
||||
const sourceBlockerCount = sourceRepos.reduce((total, item) => total + item.blocker_count, 0);
|
||||
const productStatusEntries = Object.entries(productStatusCounts);
|
||||
const routeStatusEntries = Object.entries(routeStatusCounts);
|
||||
const categoryEntries = Object.entries(categoryCounts);
|
||||
const sourceGateRows = [
|
||||
{
|
||||
label: t("sourceGate.primaryReady"),
|
||||
value: `${summary?.source_primary_ready_count ?? 0}/${summary?.source_candidate_repo_count ?? sourceRepos.length}`,
|
||||
},
|
||||
{
|
||||
label: t("sourceGate.ownerAccepted"),
|
||||
value: `${summary?.owner_response_accepted_count ?? 0}`,
|
||||
},
|
||||
{
|
||||
label: t("sourceGate.runtimeGate"),
|
||||
value: `${summary?.runtime_gate_count ?? 0}`,
|
||||
},
|
||||
{
|
||||
label: t("sourceGate.actionButtons"),
|
||||
value: `${summary?.action_button_count ?? 0}`,
|
||||
},
|
||||
];
|
||||
const metrics = [
|
||||
{
|
||||
key: "products",
|
||||
@@ -1007,6 +1053,128 @@ function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInvento
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-px border-b border-[#e0ddd4] bg-[#e0ddd4] xl:grid-cols-[0.95fr_1.05fr_1fr]">
|
||||
<div className="min-w-0 bg-[#2f2a24] px-4 py-4 text-white">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#d9b36f]">
|
||||
{t("atlas.eyebrow")}
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-semibold leading-none">{visibleAssetCount || "--"}</p>
|
||||
<p className="mt-2 text-sm font-semibold">{t("atlas.totalAssets")}</p>
|
||||
<p className="mt-3 text-xs leading-5 text-[#e5ddd0]">
|
||||
{t("atlas.totalDetail", {
|
||||
products: products.length,
|
||||
routes: publicRoutes.length,
|
||||
repos: sourceRepos.length,
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-3 gap-2 text-[11px]">
|
||||
<div className="border border-[#5e5448] px-2 py-2">
|
||||
<p className="text-[#c8bfb2]">{t("labels.ownerRequired")}</p>
|
||||
<p className="mt-1 font-mono text-lg font-semibold text-white">{ownerRequiredProducts}</p>
|
||||
</div>
|
||||
<div className="border border-[#5e5448] px-2 py-2">
|
||||
<p className="text-[#c8bfb2]">{t("labels.routePending")}</p>
|
||||
<p className="mt-1 font-mono text-lg font-semibold text-white">{pendingRouteSmokeCount}</p>
|
||||
</div>
|
||||
<div className="border border-[#5e5448] px-2 py-2">
|
||||
<p className="text-[#c8bfb2]">{t("labels.sourceBlockers")}</p>
|
||||
<p className="mt-1 font-mono text-lg font-semibold text-white">{sourceBlockerCount}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 bg-white px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t("atlas.productMapTitle")}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-[#141413]">{t("atlas.productMapDetail")}</p>
|
||||
</div>
|
||||
<span className="border border-[#9bc7a4] bg-[#f0faf2] px-2 py-0.5 font-mono text-xs font-semibold text-[#17602a]">
|
||||
{products.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{categoryEntries.map(([category, count]) => (
|
||||
<div key={category} className="grid grid-cols-[128px_minmax(0,1fr)_40px] items-center gap-2 text-[11px]">
|
||||
<span className="truncate font-semibold text-[#5f5b52]">
|
||||
{assetCategoryLabel(category, t)}
|
||||
</span>
|
||||
<div className="h-2 border border-[#eee9dd] bg-[#faf9f3]">
|
||||
<div
|
||||
className="h-full bg-[#6f8f72]"
|
||||
style={{
|
||||
width: `${products.length ? Math.max(8, (count / products.length) * 100) : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-right font-mono font-semibold text-[#141413]">{count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{productStatusEntries.map(([status, count]) => (
|
||||
<span
|
||||
key={status}
|
||||
className="inline-flex border border-[#eee9dd] bg-[#faf9f3] px-2 py-1 text-[11px] text-[#5f5b52]"
|
||||
>
|
||||
<span className="font-semibold text-[#141413]">{assetStatusLabel(status, t)}</span>
|
||||
<span className="ml-1 font-mono">{count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 bg-white px-4 py-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t("atlas.routeMapTitle")}</p>
|
||||
<p className="mt-1 text-sm font-semibold text-[#141413]">{t("atlas.routeMapDetail")}</p>
|
||||
</div>
|
||||
<span className="border border-[#d9b36f] bg-[#fff7e8] px-2 py-0.5 font-mono text-xs font-semibold text-[#8a5a08]">
|
||||
{publicRoutes.length}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-1.5">
|
||||
{routePreview.map((route, index) => (
|
||||
<span
|
||||
key={route.domain}
|
||||
className="inline-flex max-w-full border border-[#eee9dd] bg-[#faf9f3] px-2 py-1 font-mono text-[10px] text-[#141413]"
|
||||
title={routePublicProductName(route, index)}
|
||||
>
|
||||
{route.domain}
|
||||
</span>
|
||||
))}
|
||||
{remainingRouteCount > 0 && (
|
||||
<span className="inline-flex border border-[#d8d3c7] bg-white px-2 py-1 font-mono text-[10px] font-semibold text-[#5f5b52]">
|
||||
{t("atlas.moreRoutes", { count: remainingRouteCount })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 grid gap-2">
|
||||
{sourceGateRows.map((row) => (
|
||||
<div
|
||||
key={row.label}
|
||||
className="grid grid-cols-[minmax(0,1fr)_72px] items-center gap-3 border border-[#eee9dd] bg-[#faf9f3] px-2 py-1.5 text-[11px]"
|
||||
>
|
||||
<span className="truncate font-semibold text-[#5f5b52]">{row.label}</span>
|
||||
<span className="text-right font-mono font-semibold text-[#141413]">{row.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-4 flex flex-wrap gap-2">
|
||||
{routeStatusEntries.map(([status, count]) => (
|
||||
<span
|
||||
key={status}
|
||||
className="inline-flex border border-[#eee9dd] bg-[#faf9f3] px-2 py-1 text-[11px] text-[#5f5b52]"
|
||||
>
|
||||
<span className="font-semibold text-[#141413]">{assetStatusLabel(status, t)}</span>
|
||||
<span className="ml-1 font-mono">{count}</span>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 p-4 xl:grid-cols-[minmax(0,1.05fr)_minmax(0,0.95fr)]">
|
||||
<div className="min-w-0 border border-[#e0ddd4] bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||
|
||||
Reference in New Issue
Block a user