feat(platform): 顯示全域產品資產納管
This commit is contained in:
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user