From fb5c6fbaddfeb17a0a9ad0263ca59d912676d259 Mon Sep 17 00:00:00 2001 From: Your Name Date: Sun, 14 Jun 2026 15:18:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(platform):=20=E9=A1=AF=E7=A4=BA=E5=85=A8?= =?UTF-8?q?=E5=9F=9F=E7=94=A2=E5=93=81=E8=B3=87=E7=94=A2=E7=B4=8D=E7=AE=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/src/api/v1/platform/tenants.py | 78 +++ .../src/services/platform_operator_service.py | 457 +++++++++++++++++- .../test_awooop_tenant_asset_inventory.py | 83 ++++ apps/web/messages/en.json | 68 +++ apps/web/messages/zh-TW.json | 68 +++ .../src/app/[locale]/awooop/tenants/page.tsx | 370 +++++++++++++- docs/LOGBOOK.md | 30 ++ 7 files changed, 1146 insertions(+), 8 deletions(-) create mode 100644 apps/api/tests/test_awooop_tenant_asset_inventory.py diff --git a/apps/api/src/api/v1/platform/tenants.py b/apps/api/src/api/v1/platform/tenants.py index c44c3213c..030d0c65e 100644 --- a/apps/api/src/api/v1/platform/tenants.py +++ b/apps/api/src/api/v1/platform/tenants.py @@ -29,9 +29,87 @@ class TenantItem(BaseModel): created_at: datetime +class TenantAssetSummary(BaseModel): + tenant_table_count: int + product_surface_count: int + public_route_count: int + public_gateway_snapshot_route_count: int + source_candidate_repo_count: int + source_in_scope_repo_count: int + source_primary_ready_count: int + owner_response_received_count: int + owner_response_accepted_count: int + runtime_gate_count: int + action_button_count: int + + +class TenantProductSurface(BaseModel): + product_id: str + product_name: str + project_id: str + category: str + surface_kind: str + owner_lane: str + coverage_status: str + public_routes: list[str] + source_keys: list[str] + public_route_count: int + source_repo_count: int + missing_public_routes: list[str] + owner_response_received_count: int + owner_response_accepted_count: int + runtime_gate_count: int + action_button_count: int + + +class TenantPublicRouteAsset(BaseModel): + domain: str + product_id: str + product_name: str + category: str + coverage_status: str + control_tier: str + upstream_count: int + admin_route_count: int + websocket_route_count: int + public_route_smoke_required: bool + route_smoke_accepted: bool + owner_response_accepted: bool + runtime_gate_count: int + action_button_count: int + source: str + + +class TenantSourceRepoAsset(BaseModel): + github_repo: str + source_key: str + product_id: str + product_name: str + category: str + scope_status: str + readiness_state: str + risk: str + primary_ready: bool + blocker_count: int + runtime_gate_count: int + action_button_count: int + + +class TenantAssetInventory(BaseModel): + schema_version: str + mode: str + evidence_refs: list[str] + summary: TenantAssetSummary + products: list[TenantProductSurface] + public_routes: list[TenantPublicRouteAsset] + source_repos: list[TenantSourceRepoAsset] + boundaries: list[str] + + class ListTenantsResponse(BaseModel): tenants: list[TenantItem] total: int + asset_inventory: TenantAssetInventory @router.get( diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 7752279ed..42528d06d 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -9,6 +9,7 @@ ADR-106(AwoooP Agent Platform) from __future__ import annotations import asyncio +import json import os import re import time @@ -16,6 +17,7 @@ import uuid from collections import defaultdict from collections.abc import Mapping from datetime import UTC, datetime, timedelta +from pathlib import Path from typing import Any, get_args from urllib.parse import urlencode from uuid import UUID @@ -65,6 +67,7 @@ from src.services.operator_summary_cache import ( store_operator_summary_async, ) from src.services.run_state_machine import transition +from src.services.snapshot_paths import resolve_repo_root logger = structlog.get_logger(__name__) @@ -130,6 +133,454 @@ _CALLBACK_REPLY_AUDIT_SUMMARY_SCHEMA_VERSION = ( # Tenants # ============================================================================= +_PUBLIC_GATEWAY_INVENTORY_SNAPSHOT = ( + "docs/security/public-gateway-preflight-inventory.snapshot.json" +) +_SOURCE_CONTROL_READINESS_SNAPSHOT = ( + "docs/security/source-control-primary-readiness-gate.snapshot.json" +) + +_DOMAIN_PRODUCT_OVERRIDES: dict[str, dict[str, str]] = { + "aiops.wooo.work": { + "product_id": "awoooi", + "product_name": "AWOOOI / AwoooP / IwoooS", + "category": "core_platform", + }, + "awoooi.wooo.work": { + "product_id": "awoooi", + "product_name": "AWOOOI / AwoooP / IwoooS", + "category": "core_platform", + }, + "mo.wooo.work": { + "product_id": "ewoooc", + "product_name": "EwoooC / Mo", + "category": "business_product", + }, + "vibework.wooo.work": { + "product_id": "vibework", + "product_name": "VibeWork", + "category": "business_product", + }, + "agent.wooo.work": { + "product_id": "agent-bounty-protocol", + "product_name": "Agent Bounty Protocol", + "category": "business_product", + }, + "stock.wooo.work": { + "product_id": "stockplatform", + "product_name": "StockPlatform", + "category": "business_product", + }, + "bitan.wooo.work": { + "product_id": "bitan-pharmacy", + "product_name": "Bitan Pharmacy", + "category": "business_product", + }, + "tsenyang.com": { + "product_id": "tsenyang-website", + "product_name": "TsenYang Website", + "category": "public_site", + }, + "www.tsenyang.com": { + "product_id": "tsenyang-website", + "product_name": "TsenYang Website", + "category": "public_site", + }, + "vtuber.wooo.work": { + "product_id": "vtuber", + "product_name": "VTuber", + "category": "public_site", + }, + "gitea.wooo.work": { + "product_id": "source-control", + "product_name": "Source Control / DevOps", + "category": "platform_tool", + }, + "gitlab.wooo.work": { + "product_id": "source-control", + "product_name": "Source Control / DevOps", + "category": "platform_tool", + }, + "harbor.wooo.work": { + "product_id": "source-control", + "product_name": "Source Control / DevOps", + "category": "platform_tool", + }, + "registry.wooo.work": { + "product_id": "source-control", + "product_name": "Source Control / DevOps", + "category": "platform_tool", + }, + "sentry.wooo.work": { + "product_id": "observability-tooling", + "product_name": "Observability / LLMOps", + "category": "platform_tool", + }, + "signoz.wooo.work": { + "product_id": "observability-tooling", + "product_name": "Observability / LLMOps", + "category": "platform_tool", + }, + "langfuse.wooo.work": { + "product_id": "observability-tooling", + "product_name": "Observability / LLMOps", + "category": "platform_tool", + }, +} + +_CANONICAL_PRODUCT_SURFACES: tuple[dict[str, Any], ...] = ( + { + "product_id": "awoooi", + "product_name": "AWOOOI / AwoooP / IwoooS", + "project_id": "awoooi", + "category": "core_platform", + "surface_kind": "platform_product", + "owner_lane": "S4.9-S4.13", + "coverage_status": "read_only_visible", + "public_routes": ["awoooi.wooo.work", "aiops.wooo.work"], + "source_keys": ["wooo/awoooi"], + }, + { + "product_id": "ewoooc", + "product_name": "EwoooC / Mo", + "project_id": "ewoooc", + "category": "business_product", + "surface_kind": "product_console", + "owner_lane": "S4.10", + "coverage_status": "owner_response_required", + "public_routes": ["mo.wooo.work"], + "source_keys": ["wooo/ewoooc / root/momo-pro-system / momo working trees"], + }, + { + "product_id": "vibework", + "product_name": "VibeWork", + "project_id": "vibework", + "category": "business_product", + "surface_kind": "product_site", + "owner_lane": "S4.10", + "coverage_status": "owner_response_required", + "public_routes": ["vibework.wooo.work"], + "source_keys": ["vibework"], + }, + { + "product_id": "agent-bounty-protocol", + "product_name": "Agent Bounty Protocol", + "project_id": "agent-bounty-protocol", + "category": "business_product", + "surface_kind": "agent_protocol", + "owner_lane": "S4.10", + "coverage_status": "owner_response_required", + "public_routes": ["agent.wooo.work"], + "source_keys": ["agent-bounty-protocol"], + }, + { + "product_id": "stockplatform", + "product_name": "StockPlatform", + "project_id": "stockplatform", + "category": "business_product", + "surface_kind": "product_site", + "owner_lane": "P0-CRON-001", + "coverage_status": "read_only_visible", + "public_routes": ["stock.wooo.work"], + "source_keys": ["stockplatform"], + }, + { + "product_id": "bitan-pharmacy", + "product_name": "Bitan Pharmacy", + "project_id": "bitan-pharmacy", + "category": "business_product", + "surface_kind": "product_site", + "owner_lane": "P0-CRON-002", + "coverage_status": "read_only_visible", + "public_routes": ["bitan.wooo.work"], + "source_keys": ["bitan-pharmacy"], + }, + { + "product_id": "tsenyang-website", + "product_name": "TsenYang Website", + "project_id": "tsenyang-website", + "category": "public_site", + "surface_kind": "public_site", + "owner_lane": "S4.10", + "coverage_status": "owner_response_required", + "public_routes": ["tsenyang.com", "www.tsenyang.com"], + "source_keys": ["tsenyang-website"], + }, + { + "product_id": "vtuber", + "product_name": "VTuber", + "project_id": "vtuber", + "category": "public_site", + "surface_kind": "public_site", + "owner_lane": "public_gateway", + "coverage_status": "read_only_visible", + "public_routes": ["vtuber.wooo.work"], + "source_keys": [], + }, + { + "product_id": "source-control", + "product_name": "Source Control / DevOps", + "project_id": "__platform__", + "category": "platform_tool", + "surface_kind": "tooling", + "owner_lane": "S4.9-S4.13", + "coverage_status": "read_only_visible", + "public_routes": [ + "gitea.wooo.work", + "gitlab.wooo.work", + "harbor.wooo.work", + "registry.wooo.work", + ], + "source_keys": ["wooo/wooo-infra-config"], + }, + { + "product_id": "observability-tooling", + "product_name": "Observability / LLMOps", + "project_id": "__platform__", + "category": "platform_tool", + "surface_kind": "tooling", + "owner_lane": "P1-003", + "coverage_status": "read_only_visible", + "public_routes": ["sentry.wooo.work", "signoz.wooo.work", "langfuse.wooo.work"], + "source_keys": [], + }, +) + +_ADDITIONAL_PUBLIC_ROUTES: tuple[dict[str, Any], ...] = ( + { + "domain": "awoooi.wooo.work", + "coverage_status": "read_only_visible", + "source": "awoooi_canonical_route", + }, + { + "domain": "vibework.wooo.work", + "coverage_status": "owner_response_required", + "source": "vibework_onboarding_handoff", + }, + { + "domain": "agent.wooo.work", + "coverage_status": "owner_response_required", + "source": "agent_bounty_onboarding_handoff", + }, +) + + +def _load_committed_snapshot(relative_path: str) -> dict[str, Any]: + repo_root = resolve_repo_root(Path(__file__)) + snapshot_path = repo_root / relative_path + try: + return json.loads(snapshot_path.read_text(encoding="utf-8")) + except FileNotFoundError: + logger.warning("tenant_asset_snapshot_missing", path=relative_path) + except json.JSONDecodeError as exc: + logger.warning( + "tenant_asset_snapshot_invalid_json", + path=relative_path, + error=str(exc), + ) + return {} + + +def _domain_product(domain: str) -> dict[str, str]: + return _DOMAIN_PRODUCT_OVERRIDES.get( + domain, + { + "product_id": domain.replace(".", "-"), + "product_name": domain, + "category": "public_route", + }, + ) + + +def _route_asset_from_gateway_row(row: Mapping[str, Any]) -> dict[str, Any]: + domain = str(row.get("domain") or "") + product = _domain_product(domain) + route_smoke_accepted = bool(row.get("route_smoke_accepted")) + owner_response_accepted = bool(row.get("owner_response_accepted")) + coverage_status = ( + "verified" + if route_smoke_accepted and owner_response_accepted + else "read_only_pending_smoke" + ) + return { + "domain": domain, + "product_id": product["product_id"], + "product_name": product["product_name"], + "category": product["category"], + "coverage_status": coverage_status, + "control_tier": row.get("control_tier") or "unknown", + "upstream_count": int(row.get("upstream_count") or 0), + "admin_route_count": int(row.get("admin_route_count") or 0), + "websocket_route_count": int(row.get("websocket_route_count") or 0), + "public_route_smoke_required": bool(row.get("public_route_smoke_required")), + "route_smoke_accepted": route_smoke_accepted, + "owner_response_accepted": owner_response_accepted, + "runtime_gate_count": 0, + "action_button_count": 0, + "source": "public_gateway_preflight_inventory", + } + + +def _build_route_assets(public_gateway_snapshot: Mapping[str, Any]) -> list[dict[str, Any]]: + seen_domains: set[str] = set() + route_assets: list[dict[str, Any]] = [] + for row in public_gateway_snapshot.get("route_impacts") or []: + if not isinstance(row, Mapping): + continue + asset = _route_asset_from_gateway_row(row) + if not asset["domain"] or asset["domain"] in seen_domains: + continue + seen_domains.add(asset["domain"]) + route_assets.append(asset) + + for row in _ADDITIONAL_PUBLIC_ROUTES: + domain = str(row["domain"]) + if domain in seen_domains: + continue + product = _domain_product(domain) + route_assets.append( + { + "domain": domain, + "product_id": product["product_id"], + "product_name": product["product_name"], + "category": product["category"], + "coverage_status": row["coverage_status"], + "control_tier": "candidate", + "upstream_count": 0, + "admin_route_count": 0, + "websocket_route_count": 0, + "public_route_smoke_required": True, + "route_smoke_accepted": False, + "owner_response_accepted": False, + "runtime_gate_count": 0, + "action_button_count": 0, + "source": row["source"], + } + ) + return route_assets + + +def _build_source_repo_assets( + source_control_snapshot: Mapping[str, Any], +) -> list[dict[str, Any]]: + source_assets: list[dict[str, Any]] = [] + for row in source_control_snapshot.get("repo_readiness") or []: + if not isinstance(row, Mapping): + continue + source_key = str(row.get("source_key") or "") + github_repo = str(row.get("github_repo") or "") + product = { + "product_id": (source_key or github_repo).replace("/", "-") or "unknown-source", + "product_name": source_key or github_repo or "unknown-source", + "category": "source_repo", + } + for surface in _CANONICAL_PRODUCT_SURFACES: + if source_key in surface.get("source_keys", []) or github_repo in surface.get( + "source_keys", [] + ): + product = { + "product_id": str(surface["product_id"]), + "product_name": str(surface["product_name"]), + "category": str(surface["category"]), + } + break + source_assets.append( + { + "github_repo": github_repo, + "source_key": source_key, + "product_id": product["product_id"], + "product_name": product["product_name"], + "category": product["category"], + "scope_status": row.get("scope_status") or "unknown", + "readiness_state": row.get("readiness_state") or "unknown", + "risk": row.get("risk") or "UNKNOWN", + "primary_ready": bool(row.get("primary_ready")), + "blocker_count": len(row.get("blockers") or []), + "runtime_gate_count": 0, + "action_button_count": 0, + } + ) + return source_assets + + +def build_tenant_asset_inventory(tenants: list[Mapping[str, Any]]) -> dict[str, Any]: + """Build a read-only product / route / source-control asset view for tenants.""" + public_gateway = _load_committed_snapshot(_PUBLIC_GATEWAY_INVENTORY_SNAPSHOT) + source_control = _load_committed_snapshot(_SOURCE_CONTROL_READINESS_SNAPSHOT) + public_routes = _build_route_assets(public_gateway) + source_repos = _build_source_repo_assets(source_control) + + public_route_domains = {asset["domain"] for asset in public_routes} + source_keys = {asset["source_key"] for asset in source_repos} + products: list[dict[str, Any]] = [] + for surface in _CANONICAL_PRODUCT_SURFACES: + public_route_count = len( + [domain for domain in surface["public_routes"] if domain in public_route_domains] + ) + source_repo_count = len( + [source for source in surface["source_keys"] if source in source_keys] + ) + missing_public_routes = [ + domain for domain in surface["public_routes"] if domain not in public_route_domains + ] + products.append( + { + **surface, + "public_route_count": public_route_count, + "source_repo_count": source_repo_count, + "missing_public_routes": missing_public_routes, + "owner_response_received_count": 0, + "owner_response_accepted_count": 0, + "runtime_gate_count": 0, + "action_button_count": 0, + } + ) + + source_summary = source_control.get("summary") or {} + gateway_summary = public_gateway.get("summary") or {} + return { + "schema_version": "awooop_tenant_asset_inventory_v1", + "mode": "read_only_global_asset_inventory", + "evidence_refs": [ + _PUBLIC_GATEWAY_INVENTORY_SNAPSHOT, + _SOURCE_CONTROL_READINESS_SNAPSHOT, + "docs/security/SECURITY-MIRROR-STATUS-ROLLUP.md", + ], + "summary": { + "tenant_table_count": len(tenants), + "product_surface_count": len(products), + "public_route_count": len(public_routes), + "public_gateway_snapshot_route_count": int( + gateway_summary.get("route_impact_count") or 0 + ), + "source_candidate_repo_count": int( + source_summary.get("candidate_repo_count") or len(source_repos) + ), + "source_in_scope_repo_count": int(source_summary.get("in_scope_repo_count") or 0), + "source_primary_ready_count": int(source_summary.get("primary_ready_count") or 0), + "owner_response_received_count": 0, + "owner_response_accepted_count": 0, + "runtime_gate_count": 0, + "action_button_count": 0, + }, + "products": products, + "public_routes": public_routes, + "source_repos": source_repos, + "boundaries": [ + "read_only_inventory_only=true", + "owner_response_received_count=0", + "owner_response_accepted_count=0", + "runtime_execution_authorized=false", + "active_runtime_gate_count=0", + "action_buttons_allowed=false", + "repo_creation_authorized=false", + "refs_sync_authorized=false", + "workflow_modification_authorized=false", + "public_route_change_authorized=false", + ], + } + + async def list_tenants() -> dict[str, Any]: """列出所有 AwoooP 租戶(Operator Console,不依 RLS 過濾)。""" async with get_db_context("awoooi") as db: @@ -158,7 +609,11 @@ async def list_tenants() -> dict[str, Any]: } for r in rows ] - return {"tenants": tenants, "total": len(tenants)} + return { + "tenants": tenants, + "total": len(tenants), + "asset_inventory": build_tenant_asset_inventory(tenants), + } # ============================================================================= diff --git a/apps/api/tests/test_awooop_tenant_asset_inventory.py b/apps/api/tests/test_awooop_tenant_asset_inventory.py new file mode 100644 index 000000000..49288fa54 --- /dev/null +++ b/apps/api/tests/test_awooop_tenant_asset_inventory.py @@ -0,0 +1,83 @@ +from __future__ import annotations + +from src.api.v1.platform.tenants import ListTenantsResponse +from src.services.platform_operator_service import build_tenant_asset_inventory + + +def test_tenant_asset_inventory_merges_products_routes_and_repos() -> None: + tenants = [ + { + "project_id": "awoooi", + "display_name": "AWOOOI", + "migration_mode": "shadow", + "budget_limit_usd": None, + "is_active": True, + "created_at": "2026-06-14T00:00:00+08:00", + } + ] + + inventory = build_tenant_asset_inventory(tenants) + summary = inventory["summary"] + + assert inventory["schema_version"] == "awooop_tenant_asset_inventory_v1" + assert summary["tenant_table_count"] == 1 + assert summary["product_surface_count"] >= 10 + assert summary["public_route_count"] >= 17 + assert summary["source_candidate_repo_count"] == 10 + assert summary["source_in_scope_repo_count"] == 9 + assert summary["source_primary_ready_count"] == 0 + assert summary["owner_response_accepted_count"] == 0 + assert summary["runtime_gate_count"] == 0 + assert summary["action_button_count"] == 0 + + product_ids = {item["product_id"] for item in inventory["products"]} + assert { + "awoooi", + "ewoooc", + "vibework", + "agent-bounty-protocol", + "stockplatform", + "bitan-pharmacy", + "tsenyang-website", + }.issubset(product_ids) + + route_domains = {item["domain"] for item in inventory["public_routes"]} + assert { + "awoooi.wooo.work", + "stock.wooo.work", + "bitan.wooo.work", + "mo.wooo.work", + "vibework.wooo.work", + "agent.wooo.work", + "tsenyang.com", + }.issubset(route_domains) + + source_repos = {item["github_repo"] for item in inventory["source_repos"]} + assert "owenhytsai/VibeWork" in source_repos + assert "owenhytsai/agent-bounty-protocol" in source_repos + assert all(item["runtime_gate_count"] == 0 for item in inventory["source_repos"]) + assert all(item["action_button_count"] == 0 for item in inventory["public_routes"]) + + +def test_tenant_response_model_keeps_asset_inventory_contract() -> None: + tenant = { + "project_id": "awoooi", + "display_name": "AWOOOI", + "migration_mode": "shadow", + "budget_limit_usd": None, + "is_active": True, + "created_at": "2026-06-14T00:00:00+08:00", + } + inventory = build_tenant_asset_inventory([tenant]) + + response = ListTenantsResponse.model_validate( + { + "tenants": [tenant], + "total": 1, + "asset_inventory": inventory, + } + ) + + assert response.asset_inventory.summary.product_surface_count >= 10 + assert response.asset_inventory.summary.runtime_gate_count == 0 + assert response.asset_inventory.products[0].runtime_gate_count == 0 diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a47acbaac..fb7839394 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -8513,6 +8513,74 @@ } }, "tenants": { + "globalAssets": { + "eyebrow": "全域納管", + "title": "所有產品、網站與專案覆蓋盤", + "subtitle": "把租戶資料表、正式網站入口、前後台產品、平台工具與原始碼管控候選合併到同一個只讀視圖;這不是建立專案庫、改路由、部署、掃描或執行期授權。", + "loading": "讀取資產盤", + "itemsUnit": "項", + "productsTitle": "產品 / 專案納管", + "routesTitle": "網站與服務入口", + "reposTitle": "原始碼 / 專案庫範圍", + "boundaryTitle": "不可誤讀合約", + "boundaryLead": "目前只建立可見性與證據索引;負責人回覆、執行期閘門、路由變更與操作按鈕全部維持 0 / false。", + "metrics": { + "products": { + "label": "產品 / 專案", + "detail": "核心平台、公開網站、工具與新納管產品都列入同一張覆蓋盤。" + }, + "routes": { + "label": "網站入口", + "detail": "包含公開入口快照與候選正式入口;未驗收者標為待負責人回覆。" + }, + "repos": { + "label": "候選專案庫", + "detail": "來源於 source-control primary readiness,只顯示範圍與阻塞原因。" + }, + "tenants": { + "label": "租戶資料", + "detail": "保留 AwoooP tenant table,避免把產品範圍誤當租戶政策。" + }, + "ownerAccepted": { + "label": "已接受回覆", + "detail": "目前仍為 0;沒有脫敏負責人回覆就不提高納管等級。" + }, + "runtimeGate": { + "label": "執行期閘門", + "detail": "目前仍為 0;這個頁面不提供掃描、部署、修復或路由修改入口。" + } + }, + "labels": { + "routes": "入口", + "repos": "來源", + "gates": "閘門" + }, + "columns": { + "domain": "網站 / 服務入口", + "product": "產品 / 專案", + "status": "納管狀態", + "routeShape": "入口形態", + "repo": "專案庫", + "risk": "風險", + "readiness": "就緒狀態" + }, + "categories": { + "corePlatform": "核心平台", + "businessProduct": "產品", + "publicSite": "公開網站", + "platformTool": "平台工具", + "publicRoute": "網站入口", + "sourceRepo": "原始碼", + "unknown": "未分類" + }, + "statuses": { + "verified": "已驗證", + "readOnlyVisible": "只讀可見", + "readOnlyPendingSmoke": "待 smoke 證據", + "ownerResponseRequired": "待負責人回覆", + "unknown": "待分類" + } + }, "securityTenantScopeCandidate": { "title": "IwoooS 租戶資安範圍只讀候選", "subtitle": "租戶管理只顯示 AWOOOI 第一租戶與IwoooS 資安鏡像的保護範圍;這不是租戶遷移模式變更,也不會改租戶政策。", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index a47acbaac..fb7839394 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -8513,6 +8513,74 @@ } }, "tenants": { + "globalAssets": { + "eyebrow": "全域納管", + "title": "所有產品、網站與專案覆蓋盤", + "subtitle": "把租戶資料表、正式網站入口、前後台產品、平台工具與原始碼管控候選合併到同一個只讀視圖;這不是建立專案庫、改路由、部署、掃描或執行期授權。", + "loading": "讀取資產盤", + "itemsUnit": "項", + "productsTitle": "產品 / 專案納管", + "routesTitle": "網站與服務入口", + "reposTitle": "原始碼 / 專案庫範圍", + "boundaryTitle": "不可誤讀合約", + "boundaryLead": "目前只建立可見性與證據索引;負責人回覆、執行期閘門、路由變更與操作按鈕全部維持 0 / false。", + "metrics": { + "products": { + "label": "產品 / 專案", + "detail": "核心平台、公開網站、工具與新納管產品都列入同一張覆蓋盤。" + }, + "routes": { + "label": "網站入口", + "detail": "包含公開入口快照與候選正式入口;未驗收者標為待負責人回覆。" + }, + "repos": { + "label": "候選專案庫", + "detail": "來源於 source-control primary readiness,只顯示範圍與阻塞原因。" + }, + "tenants": { + "label": "租戶資料", + "detail": "保留 AwoooP tenant table,避免把產品範圍誤當租戶政策。" + }, + "ownerAccepted": { + "label": "已接受回覆", + "detail": "目前仍為 0;沒有脫敏負責人回覆就不提高納管等級。" + }, + "runtimeGate": { + "label": "執行期閘門", + "detail": "目前仍為 0;這個頁面不提供掃描、部署、修復或路由修改入口。" + } + }, + "labels": { + "routes": "入口", + "repos": "來源", + "gates": "閘門" + }, + "columns": { + "domain": "網站 / 服務入口", + "product": "產品 / 專案", + "status": "納管狀態", + "routeShape": "入口形態", + "repo": "專案庫", + "risk": "風險", + "readiness": "就緒狀態" + }, + "categories": { + "corePlatform": "核心平台", + "businessProduct": "產品", + "publicSite": "公開網站", + "platformTool": "平台工具", + "publicRoute": "網站入口", + "sourceRepo": "原始碼", + "unknown": "未分類" + }, + "statuses": { + "verified": "已驗證", + "readOnlyVisible": "只讀可見", + "readOnlyPendingSmoke": "待 smoke 證據", + "ownerResponseRequired": "待負責人回覆", + "unknown": "待分類" + } + }, "securityTenantScopeCandidate": { "title": "IwoooS 租戶資安範圍只讀候選", "subtitle": "租戶管理只顯示 AWOOOI 第一租戶與IwoooS 資安鏡像的保護範圍;這不是租戶遷移模式變更,也不會改租戶政策。", diff --git a/apps/web/src/app/[locale]/awooop/tenants/page.tsx b/apps/web/src/app/[locale]/awooop/tenants/page.tsx index 04688feef..b9186edf6 100644 --- a/apps/web/src/app/[locale]/awooop/tenants/page.tsx +++ b/apps/web/src/app/[locale]/awooop/tenants/page.tsx @@ -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 = { + verified: "steady", + read_only_visible: "steady", + read_only_pending_smoke: "warn", + owner_response_required: "warn", +}; + +const ASSET_CATEGORY_KEYS: Record = { + core_platform: "corePlatform", + business_product: "businessProduct", + public_site: "publicSite", + platform_tool: "platformTool", + public_route: "publicRoute", + source_repo: "sourceRepo", +}; + +const ASSET_STATUS_KEYS: Record = { + 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; +}) { + const tone = ASSET_STATUS_TONES[status] ?? "locked"; + const key = ASSET_STATUS_KEYS[status] ?? "unknown"; + return ( + + {t(`statuses.${key}` as never)} + + ); +} + +function assetCategoryLabel(category: string, t: ReturnType) { + 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 ( +
+
+
+
+ + {inventory?.schema_version ?? t("loading")} + +
+ +
+ {metrics.map(({ key, value, Icon, tone }) => ( +
+
+
+

+ {t(`metrics.${key}.label` as never)} +

+

+ {value} +

+
+ + +
+

+ {t(`metrics.${key}.detail` as never)} +

+
+ ))} +
+ +
+
+
+

{t("productsTitle")}

+ + {products.length} {t("itemsUnit")} + +
+
+ {products.map((item) => ( +
+
+
+

{item.product_name}

+

+ {item.project_id} · {assetCategoryLabel(item.category, t)} +

+
+ +
+
+
+

{t("labels.routes")}

+

{item.public_route_count}

+
+
+

{t("labels.repos")}

+

{item.source_repo_count}

+
+
+

{t("labels.gates")}

+

{item.runtime_gate_count}

+
+
+

+ {item.public_routes.join(" / ")} +

+
+ ))} +
+
+ +
+

{t("boundaryTitle")}

+

{t("boundaryLead")}

+
+ {boundaries.slice(0, 9).map((boundary) => ( + {boundary} + ))} +
+
+ {inventory?.evidence_refs.map((ref) => ( + + {ref} + + ))} +
+
+
+ +
+
+
+ {t("routesTitle")} +
+
+ + + + + + + + + + + {publicRoutes.map((route) => ( + + + + + + + ))} + +
{t("columns.domain")}{t("columns.product")}{t("columns.status")}{t("columns.routeShape")}
{route.domain}{route.product_name} + + + upstream={route.upstream_count}; admin={route.admin_route_count}; ws={route.websocket_route_count} +
+
+
+ +
+
+ {t("reposTitle")} +
+
+ + + + + + + + + + + {sourceRepos.map((repo) => ( + + + + + + + ))} + +
{t("columns.repo")}{t("columns.product")}{t("columns.risk")}{t("columns.readiness")}
{repo.github_repo}{repo.product_name}{repo.risk} + {repo.readiness_state} · blockers={repo.blocker_count} +
+
+
+
+
+ ); +} + // ============================================================================= // Main Component // ============================================================================= export default function TenantsPage() { const [tenants, setTenants] = useState([]); + const [assetInventory, setAssetInventory] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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() {

租戶管理

- {loading ? "載入中..." : `共 ${tenants.length} 個租戶`} + {loading + ? "載入中..." + : `共 ${tenants.length} 個租戶 · ${assetInventory?.summary.product_surface_count ?? 0} 個產品 / 專案 · ${assetInventory?.summary.public_route_count ?? 0} 個網站入口`}

@@ -681,6 +1035,8 @@ export default function TenantsPage() { + + diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 65090cfa7..1f3bcae3e 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,33 @@ +## 2026-06-14|AwoooP Tenants 全域產品 / 網站 / 專案納管本地完成 + +**背景**:統帥指出 `/zh-TW/awooop/tenants` 沒有把所有網站、專案、產品納入,頁面仍偏向 AwoooP 租戶資料表與 source-control owner response 範圍,無法作為 operator 進入全域資產狀況的入口。本輪接在 `gitea/main=30f2f490 docs(ops): record P2-143 recovery readback [skip ci]`,使用乾淨 worktree `/private/tmp/awoooi-tenants-global-assets-20260614`,不覆蓋 `/Users/ogt/awoooi` 的平行異動。 + +**完成項目**: +- `GET /api/v1/platform/tenants` response 新增 `asset_inventory`,schema 為 `awooop_tenant_asset_inventory_v1`。 +- `asset_inventory` 合併三類只讀證據:AwoooP tenant table、`public-gateway-preflight-inventory.snapshot.json`、`source-control-primary-readiness-gate.snapshot.json`。 +- 全域資產盤目前顯示:產品 / 專案 `10`、網站 / 服務入口 `17`、public gateway snapshot route `14`、source-control candidate repo `10`、in-scope repo `9`、primary ready `0`。 +- tenants 頁新增第一屏「所有產品、網站與專案覆蓋盤」,列出 AWOOOI / AwoooP / IwoooS、EwoooC / Mo、VibeWork、Agent Bounty Protocol、StockPlatform、Bitan Pharmacy、TsenYang Website、VTuber、Source Control / DevOps、Observability / LLMOps。 +- tenants 頁新增網站入口矩陣與原始碼 / 專案庫矩陣;包含 `awoooi.wooo.work`、`aiops.wooo.work`、`mo.wooo.work`、`vibework.wooo.work`、`agent.wooo.work`、`stock.wooo.work`、`bitan.wooo.work`、`tsenyang.com`、`www.tsenyang.com`、`vtuber.wooo.work`、`gitea.wooo.work`、`gitlab.wooo.work`、`harbor.wooo.work`、`registry.wooo.work`、`sentry.wooo.work`、`signoz.wooo.work`、`langfuse.wooo.work`。 +- 校正 tenants 舊數字:source-control 候選專案庫 `8 -> 10`、範圍內專案庫 `7 -> 9`、owner validation templates `22 -> 24`。 + +**本地驗證**: +- `python3 -m json.tool apps/web/messages/zh-TW.json` / `en.json` 通過。 +- `python3 -m py_compile apps/api/src/services/platform_operator_service.py apps/api/src/api/v1/platform/tenants.py` 通過。 +- `DATABASE_URL=... /Users/ogt/.pyenv/versions/3.11.7/bin/python -m pytest apps/api/tests/test_awooop_tenant_asset_inventory.py -q`:`2 passed`。 +- i18n key mirror:`I18N_MIRROR_OK 11967`。 +- `python3 scripts/security/source-control-owner-response-guard.py --root .`:`SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK`。 +- `python3 scripts/security/security-mirror-progress-guard.py --root .`:`SECURITY_MIRROR_PROGRESS_GUARD_OK`。 +- `python3 scripts/ops/doc-secrets-sanity-check.py docs .gitea`:`DOC_SECRET_SANITY_OK scanned_files=806`。 +- `git diff --check` 通過。 +- `pnpm --filter @awoooi/web typecheck` 在本乾淨 worktree 因未安裝 `node_modules` / `tsc` 而無法本地執行;不做 install,交由 Gitea CD build / typecheck 與 production smoke 補驗證。 + +**安全邊界**: +- 本段只建立全域資產可見性與證據索引;不建立專案庫、不改 visibility、不同步 / 刪除 / 強推 refs、不改 workflow / secret、不改 public route / Nginx、不讀 secret value、不做 live probe、不發 Telegram、不觸發 runtime execution。 +- `owner_response_received_count=0`、`owner_response_accepted_count=0`、`runtime_execution_authorized=false`、`active_runtime_gate_count=0`、`action_buttons_allowed=false` 仍維持。 + +**下一步**: +- 推送 feature commit 後等待 Gitea CD deploy marker,正式驗證 `/zh-TW/awooop/tenants` desktop / mobile:全域資產盤、10 個產品 / 專案、17 個網站入口、10 個候選專案庫、無水平溢位、無危險操作入口、無內部工作內容外露。 + ## 2026-06-14|P2-143 owner response 預檢後 recovery readback 無回歸 **背景**:平行協作流程已完成 P2-143 owner response 預檢與拒收邊界正式驗證,最新文件基準為 `gitea/main=b09eb1c6 docs(ai): 校準 P2-143 正式驗證紀錄`;runtime deploy marker 為 `667d6329 chore(cd): deploy 755b0a8 [skip ci]`,對應 feature commit `755b0a8d feat(governance): 新增 P2-143 owner response 預檢`。本輪只從 reboot / cold-start / backup recovery 角度做只讀 readback,不重複修改 P2-142 / P2-143 正式驗證、MASTER 或 AI worklist。