feat(platform): 顯示全域產品資產納管
Some checks failed
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / tests (push) Successful in 1m33s
CD Pipeline / build-and-deploy (push) Successful in 4m17s
CD Pipeline / post-deploy-checks (push) Has been cancelled

This commit is contained in:
Your Name
2026-06-14 15:18:54 +08:00
parent 30f2f490c7
commit fb5c6fbadd
7 changed files with 1146 additions and 8 deletions

View File

@@ -10,6 +10,7 @@ import { useTranslations } from "next-intl";
import { Link } from "@/i18n/routing";
import {
ArrowRight,
Boxes,
Building2,
RefreshCw,
AlertCircle,
@@ -17,9 +18,9 @@ import {
Ban,
CheckCircle2,
GitBranch,
Globe2,
ListChecks,
Lock,
SearchCheck,
ShieldCheck,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -48,8 +49,86 @@ interface ApiResponse {
tenants?: Tenant[];
items?: Tenant[];
total: number;
asset_inventory?: TenantAssetInventory;
}
type TenantAssetSummary = {
tenant_table_count: number;
product_surface_count: number;
public_route_count: number;
public_gateway_snapshot_route_count: number;
source_candidate_repo_count: number;
source_in_scope_repo_count: number;
source_primary_ready_count: number;
owner_response_received_count: number;
owner_response_accepted_count: number;
runtime_gate_count: number;
action_button_count: number;
};
type TenantProductSurface = {
product_id: string;
product_name: string;
project_id: string;
category: string;
surface_kind: string;
owner_lane: string;
coverage_status: string;
public_routes: string[];
source_keys: string[];
public_route_count: number;
source_repo_count: number;
missing_public_routes: string[];
owner_response_received_count: number;
owner_response_accepted_count: number;
runtime_gate_count: number;
action_button_count: number;
};
type TenantPublicRouteAsset = {
domain: string;
product_id: string;
product_name: string;
category: string;
coverage_status: string;
control_tier: string;
upstream_count: number;
admin_route_count: number;
websocket_route_count: number;
public_route_smoke_required: boolean;
route_smoke_accepted: boolean;
owner_response_accepted: boolean;
runtime_gate_count: number;
action_button_count: number;
source: string;
};
type TenantSourceRepoAsset = {
github_repo: string;
source_key: string;
product_id: string;
product_name: string;
category: string;
scope_status: string;
readiness_state: string;
risk: string;
primary_ready: boolean;
blocker_count: number;
runtime_gate_count: number;
action_button_count: number;
};
type TenantAssetInventory = {
schema_version: string;
mode: string;
evidence_refs: string[];
summary: TenantAssetSummary;
products: TenantProductSurface[];
public_routes: TenantPublicRouteAsset[];
source_repos: TenantSourceRepoAsset[];
boundaries: string[];
};
type GitHubTenantReadinessMetric = {
key: string;
value: string;
@@ -69,6 +148,8 @@ type OwnerResponseValidationTenantRef = {
contract: string;
};
type AssetTone = "steady" | "warn" | "locked";
// =============================================================================
// 常數
// =============================================================================
@@ -76,9 +157,9 @@ type OwnerResponseValidationTenantRef = {
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
const githubTenantReadinessMetrics: GitHubTenantReadinessMetric[] = [
{ key: "candidateRepos", value: "8", icon: GitBranch, tone: "neutral" },
{ key: "inScopeRepos", value: "7", icon: ListChecks, tone: "warn" },
{ key: "ownerResponses", value: "0/22", icon: ShieldCheck, tone: "warn" },
{ key: "candidateRepos", value: "10", icon: GitBranch, tone: "neutral" },
{ key: "inScopeRepos", value: "9", icon: ListChecks, tone: "warn" },
{ key: "ownerResponses", value: "0/24", icon: ShieldCheck, tone: "warn" },
{ key: "tenantScopeChanges", value: "0", icon: Lock, tone: "warn" },
];
@@ -96,7 +177,7 @@ const githubTenantReadinessScopes: GitHubTenantReadinessScope[] = [
{
key: "githubTargetOwner",
name: "S4.10 GitHub 目標負責人決策",
status: "0/7",
status: "0/9",
},
{
key: "workflowSecretOwner",
@@ -160,6 +241,56 @@ const ownerResponseValidationTenantBoundaries = [
"action_buttons_allowed=false",
];
const ASSET_STATUS_TONES: Record<string, AssetTone> = {
verified: "steady",
read_only_visible: "steady",
read_only_pending_smoke: "warn",
owner_response_required: "warn",
};
const ASSET_CATEGORY_KEYS: Record<string, string> = {
core_platform: "corePlatform",
business_product: "businessProduct",
public_site: "publicSite",
platform_tool: "platformTool",
public_route: "publicRoute",
source_repo: "sourceRepo",
};
const ASSET_STATUS_KEYS: Record<string, string> = {
verified: "verified",
read_only_visible: "readOnlyVisible",
read_only_pending_smoke: "readOnlyPendingSmoke",
owner_response_required: "ownerResponseRequired",
};
function assetToneClass(tone: AssetTone) {
if (tone === "steady") return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]";
if (tone === "locked") return "border-[#b7add8] bg-[#f8f6fc] text-[#5b4b91]";
return "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]";
}
function AssetStatusBadge({
status,
t,
}: {
status: string;
t: ReturnType<typeof useTranslations>;
}) {
const tone = ASSET_STATUS_TONES[status] ?? "locked";
const key = ASSET_STATUS_KEYS[status] ?? "unknown";
return (
<span className={cn("inline-flex border px-2 py-0.5 text-[11px] font-semibold", assetToneClass(tone))}>
{t(`statuses.${key}` as never)}
</span>
);
}
function assetCategoryLabel(category: string, t: ReturnType<typeof useTranslations>) {
const key = ASSET_CATEGORY_KEYS[category] ?? "unknown";
return t(`categories.${key}` as never);
}
const MIGRATION_MODE_CONFIG: Record<
MigrationMode,
{ label: string; bg: string; text: string; border: string }
@@ -530,7 +661,7 @@ function OwnerResponseValidationTenantScopePanel() {
},
{
label: t("metrics.templates"),
value: "22",
value: "24",
detail: t("metrics.templatesDetail"),
},
{
@@ -629,12 +760,232 @@ function OwnerResponseValidationTenantScopePanel() {
);
}
function GlobalAssetCoveragePanel({ inventory }: { inventory: TenantAssetInventory | null }) {
const t = useTranslations("awooop.tenants.globalAssets");
const summary = inventory?.summary;
const products = inventory?.products ?? [];
const publicRoutes = inventory?.public_routes ?? [];
const sourceRepos = inventory?.source_repos ?? [];
const boundaries = inventory?.boundaries ?? [];
const metrics = [
{
key: "products",
value: summary?.product_surface_count ?? "--",
Icon: Boxes,
tone: "steady" as const,
},
{
key: "routes",
value: summary?.public_route_count ?? "--",
Icon: Globe2,
tone: "steady" as const,
},
{
key: "repos",
value: summary?.source_candidate_repo_count ?? "--",
Icon: GitBranch,
tone: "warn" as const,
},
{
key: "tenants",
value: summary?.tenant_table_count ?? "--",
Icon: Building2,
tone: "steady" as const,
},
{
key: "ownerAccepted",
value: summary?.owner_response_accepted_count ?? "--",
Icon: ShieldCheck,
tone: "warn" as const,
},
{
key: "runtimeGate",
value: summary?.runtime_gate_count ?? "--",
Icon: Lock,
tone: "locked" as const,
},
];
return (
<section className="overflow-hidden border border-[#d8d3c7] bg-white shadow-[0_1px_4px_rgba(0,0,0,0.04)]">
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<div className="flex min-w-0 items-start gap-2">
<Boxes className="mt-0.5 h-4 w-4 shrink-0 text-[#d97757]" aria-hidden="true" />
<div className="min-w-0">
<p className="text-[11px] font-semibold uppercase tracking-[0.14em] text-[#a15f3b]">
{t("eyebrow")}
</p>
<h3 className="mt-1 text-base font-semibold text-[#141413]">{t("title")}</h3>
<p className="mt-1 max-w-4xl text-xs leading-5 text-[#5f5b52]">{t("subtitle")}</p>
</div>
</div>
<span className="inline-flex border border-[#9bc7a4] bg-[#f0faf2] px-2 py-0.5 text-xs font-semibold text-[#17602a]">
{inventory?.schema_version ?? t("loading")}
</span>
</div>
<div className="grid gap-px bg-[#e0ddd4] sm:grid-cols-2 xl:grid-cols-6">
{metrics.map(({ key, value, Icon, tone }) => (
<div key={key} className="min-h-[122px] bg-white px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-xs font-semibold text-[#77736a]">
{t(`metrics.${key}.label` as never)}
</p>
<p className="mt-2 break-words 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]">
{t(`metrics.${key}.detail` as never)}
</p>
</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">
<p className="text-xs font-semibold text-[#141413]">{t("productsTitle")}</p>
<span className="font-mono text-xs text-[#77736a]">
{products.length} {t("itemsUnit")}
</span>
</div>
<div className="grid gap-px bg-[#eee9dd] md:grid-cols-2">
{products.map((item) => (
<article key={item.product_id} className="min-w-0 bg-white px-4 py-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="break-words text-sm font-semibold text-[#141413]">{item.product_name}</p>
<p className="mt-1 font-mono text-[11px] text-[#77736a]">
{item.project_id} · {assetCategoryLabel(item.category, t)}
</p>
</div>
<AssetStatusBadge status={item.coverage_status} t={t} />
</div>
<div className="mt-3 grid grid-cols-3 gap-2 text-[11px]">
<div className="border border-[#eee9dd] bg-[#faf9f3] px-2 py-1.5">
<p className="text-[#77736a]">{t("labels.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.5">
<p className="text-[#77736a]">{t("labels.repos")}</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.5">
<p className="text-[#77736a]">{t("labels.gates")}</p>
<p className="font-mono font-semibold text-[#141413]">{item.runtime_gate_count}</p>
</div>
</div>
<p className="mt-3 break-words font-mono text-[11px] leading-4 text-[#5f5b52]">
{item.public_routes.join(" / ")}
</p>
</article>
))}
</div>
</div>
<div className="min-w-0 border border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<p className="text-xs font-semibold text-[#77736a]">{t("boundaryTitle")}</p>
<p className="mt-2 text-sm font-semibold text-[#141413]">{t("boundaryLead")}</p>
<div className="mt-4 grid gap-2 break-all font-mono text-xs text-[#141413]">
{boundaries.slice(0, 9).map((boundary) => (
<span key={boundary}>{boundary}</span>
))}
</div>
<div className="mt-4 grid gap-2 text-xs leading-5 text-[#5f5b52]">
{inventory?.evidence_refs.map((ref) => (
<span key={ref} className="break-all font-mono">
{ref}
</span>
))}
</div>
</div>
</div>
<div className="grid gap-4 border-t border-[#e0ddd4] bg-[#faf9f3] p-4 xl:grid-cols-2">
<div className="min-w-0 overflow-hidden border border-[#e0ddd4] bg-white">
<div className="border-b border-[#e0ddd4] px-4 py-3 text-xs font-semibold text-[#141413]">
{t("routesTitle")}
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] text-left text-xs">
<thead className="bg-[#faf9f3] text-[#77736a]">
<tr>
<th className="px-3 py-2 font-semibold">{t("columns.domain")}</th>
<th className="px-3 py-2 font-semibold">{t("columns.product")}</th>
<th className="px-3 py-2 font-semibold">{t("columns.status")}</th>
<th className="px-3 py-2 font-semibold">{t("columns.routeShape")}</th>
</tr>
</thead>
<tbody className="divide-y divide-[#eee9dd]">
{publicRoutes.map((route) => (
<tr key={route.domain}>
<td className="px-3 py-2 font-mono text-[#141413]">{route.domain}</td>
<td className="px-3 py-2 text-[#5f5b52]">{route.product_name}</td>
<td className="px-3 py-2">
<AssetStatusBadge status={route.coverage_status} t={t} />
</td>
<td className="px-3 py-2 font-mono text-[#5f5b52]">
upstream={route.upstream_count}; admin={route.admin_route_count}; ws={route.websocket_route_count}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
<div className="min-w-0 overflow-hidden border border-[#e0ddd4] bg-white">
<div className="border-b border-[#e0ddd4] px-4 py-3 text-xs font-semibold text-[#141413]">
{t("reposTitle")}
</div>
<div className="overflow-x-auto">
<table className="w-full min-w-[760px] text-left text-xs">
<thead className="bg-[#faf9f3] text-[#77736a]">
<tr>
<th className="px-3 py-2 font-semibold">{t("columns.repo")}</th>
<th className="px-3 py-2 font-semibold">{t("columns.product")}</th>
<th className="px-3 py-2 font-semibold">{t("columns.risk")}</th>
<th className="px-3 py-2 font-semibold">{t("columns.readiness")}</th>
</tr>
</thead>
<tbody className="divide-y divide-[#eee9dd]">
{sourceRepos.map((repo) => (
<tr key={`${repo.github_repo}:${repo.source_key}`}>
<td className="px-3 py-2 font-mono text-[#141413]">{repo.github_repo}</td>
<td className="px-3 py-2 text-[#5f5b52]">{repo.product_name}</td>
<td className="px-3 py-2 font-mono text-[#5f5b52]">{repo.risk}</td>
<td className="px-3 py-2 font-mono text-[#5f5b52]">
{repo.readiness_state} · blockers={repo.blocker_count}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</div>
</section>
);
}
// =============================================================================
// Main Component
// =============================================================================
export default function TenantsPage() {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [assetInventory, setAssetInventory] = useState<TenantAssetInventory | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -646,6 +997,7 @@ export default function TenantsPage() {
const data: ApiResponse = await res.json();
const rows = Array.isArray(data.tenants) ? data.tenants : data.items;
setTenants(Array.isArray(rows) ? rows : []);
setAssetInventory(data.asset_inventory ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : "載入失敗");
} finally {
@@ -666,7 +1018,9 @@ export default function TenantsPage() {
<div>
<h2 className="text-lg font-semibold text-foreground"></h2>
<p className="text-xs text-muted-foreground">
{loading ? "載入中..." : `${tenants.length} 個租戶`}
{loading
? "載入中..."
: `${tenants.length} 個租戶 · ${assetInventory?.summary.product_surface_count ?? 0} 個產品 / 專案 · ${assetInventory?.summary.public_route_count ?? 0} 個網站入口`}
</p>
</div>
</div>
@@ -681,6 +1035,8 @@ export default function TenantsPage() {
</button>
</div>
<GlobalAssetCoveragePanel inventory={assetInventory} />
<SecurityTenantScopeCandidatePanel />
<GitHubTenantReadinessScopePanel />