diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 61af2475..9587d961 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -433,6 +433,8 @@ jobs: ;; ops/runner/verify-awoooi-non110-cd-closure.py) ;; + scripts/dev/awoooi-navigation-coverage-guard.py) + ;; scripts/ci/wait-host-web-build-pressure.sh) ;; scripts/reboot-recovery/dr-escrow-evidence-checklist.py) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a2fc38cb..16ccc590 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -36,10 +36,12 @@ }, "nav": { "home": "首頁", + "overview": "總覽", "dashboard": "儀表板", "approvals": "授權中心", "errors": "錯誤追蹤", "actions": "行動日誌", + "alertOperationLogs": "告警操作紀錄", "knowledge": "知識殿堂", "settings": "設定", "alerts": "告警", @@ -53,6 +55,7 @@ "tickets": "工單", "cost": "成本分析", "reports": "報表", + "runs": "執行紀錄", "terminal": "終端", "apps": "應用", "services": "服務目錄", @@ -60,7 +63,11 @@ "notifications": "通知", "billing": "帳單", "help": "說明", + "demo": "Demo", "drift": "漂移偵測", + "authorizations": "授權中心", + "aiopsTimeline": "AI Ops 時間線", + "openclawLiveOps": "OpenClaw Live Ops", "neuralCommand": "神經指揮中心", "commandCenter": "指令中心", "delivery": "交付閉環", @@ -608,7 +615,8 @@ "addCritical": "+ 嚴重", "addMedium": "+ 中度", "creating": "建立中...", - "liveDashboard": "即時事件流 (SSE)" + "liveDashboard": "即時事件流 (SSE)", + "disabled": "展示頁已在正式環境停用。" }, "host": { "devops": { diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index f785a2da..6ed474f3 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -36,10 +36,12 @@ }, "nav": { "home": "首頁", + "overview": "總覽", "dashboard": "儀表板", "approvals": "授權中心", "errors": "錯誤追蹤", "actions": "行動日誌", + "alertOperationLogs": "告警操作紀錄", "knowledge": "知識殿堂", "settings": "設定", "alerts": "告警", @@ -53,6 +55,7 @@ "tickets": "工單", "cost": "成本分析", "reports": "報表", + "runs": "執行紀錄", "terminal": "終端", "apps": "應用", "services": "服務目錄", @@ -60,7 +63,11 @@ "notifications": "通知", "billing": "帳單", "help": "說明", + "demo": "Demo", "drift": "漂移偵測", + "authorizations": "授權中心", + "aiopsTimeline": "AI Ops 時間線", + "openclawLiveOps": "OpenClaw Live Ops", "neuralCommand": "神經指揮中心", "commandCenter": "指令中心", "delivery": "交付閉環", @@ -608,7 +615,8 @@ "addCritical": "+ 嚴重", "addMedium": "+ 中度", "creating": "建立中...", - "liveDashboard": "即時事件流 (SSE)" + "liveDashboard": "即時事件流 (SSE)", + "disabled": "展示頁已在正式環境停用。" }, "host": { "devops": { diff --git a/apps/web/src/app/[locale]/demo/page.tsx b/apps/web/src/app/[locale]/demo/page.tsx index 4b7f772a..29cec11b 100644 --- a/apps/web/src/app/[locale]/demo/page.tsx +++ b/apps/web/src/app/[locale]/demo/page.tsx @@ -92,15 +92,7 @@ export default function DemoPage({ params }: { params: { locale: string } }) { const tMock = useTranslations('mockData') const tDryRun = useTranslations('dryRun') const locale = params.locale - - // 生產環境保護: 需要 NEXT_PUBLIC_ENABLE_DEMO=true 才能存取 - if (process.env.NEXT_PUBLIC_ENABLE_DEMO !== 'true') { - return ( -
-

展示頁已在正式環境停用。

-
- ) - } + const demoEnabled = process.env.NEXT_PUBLIC_ENABLE_DEMO === 'true' const [_isCreating, setIsCreating] = useState(false) const [createError, setCreateError] = useState(null) @@ -153,6 +145,15 @@ export default function DemoPage({ params }: { params: { locale: string } }) { } }, [approvalConfigs, tApproval]) + // 生產環境保護: 需要 NEXT_PUBLIC_ENABLE_DEMO=true 才能存取 + if (!demoEnabled) { + return ( +
+

{t('disabled')}

+
+ ) + } + return (
diff --git a/apps/web/src/components/command-palette/CommandPalette.tsx b/apps/web/src/components/command-palette/CommandPalette.tsx index e710bd2f..ccd994a3 100644 --- a/apps/web/src/components/command-palette/CommandPalette.tsx +++ b/apps/web/src/components/command-palette/CommandPalette.tsx @@ -15,7 +15,7 @@ import React, { useEffect, useRef, useState, useCallback } from 'react' import { useTranslations } from 'next-intl' -import { useRouter, usePathname } from 'next/navigation' +import { useRouter } from 'next/navigation' import { useLocale } from 'next-intl' import { Search, @@ -26,6 +26,7 @@ import { Z_INDEX } from '@/lib/constants/z-index' import { PRODUCT_BOTTOM_NAV_ITEMS, PRODUCT_NAV_SECTIONS, + type ProductNavItem, } from '@/lib/navigation/product-ia' interface PaletteItem { @@ -47,12 +48,15 @@ const COMMAND_PALETTE_PRIMARY_NAV_IDS = new Set([ 'operations', ]) +type PaletteNavSource = ProductNavItem & { + parentId?: string +} + export function CommandPalette() { const t = useTranslations('commandPalette') const tNav = useTranslations('nav') const router = useRouter() const locale = useLocale() - const pathname = usePathname() const { openTerminal } = useTerminalStore() const [open, setOpen] = useState(false) @@ -66,10 +70,21 @@ export function CommandPalette() { setOpen(false) } - const navigationItems: PaletteItem[] = [ - ...PRODUCT_NAV_SECTIONS.flatMap(section => section.items), + const navigationSources: PaletteNavSource[] = [ + ...PRODUCT_NAV_SECTIONS.flatMap(section => section.items.flatMap(item => [ + item, + ...(item.children ?? []).map(child => ({ + ...child, + id: `${item.id}-${child.id}`, + Icon: item.Icon, + parentId: item.id, + surface: 'secondary' as const, + })), + ])), ...PRODUCT_BOTTOM_NAV_ITEMS, - ].map((item) => { + ] + + const navigationItems: PaletteItem[] = navigationSources.map((item) => { const path = item.href === '/' ? '' : item.href return { @@ -82,6 +97,7 @@ export function CommandPalette() { item.id, item.href, item.labelKey, + item.parentId ?? '', ...(item.aliases ?? []), ...(item.relatedPaths ?? []), ], diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 7383ae1f..23355b86 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -33,6 +33,7 @@ import { import { PRODUCT_BOTTOM_NAV_ITEMS, PRODUCT_NAV_SECTIONS, + type ProductNavChild, type ProductNavItem, } from '@/lib/navigation/product-ia' // Phase 8.0 #15: 改用 approval store SSE (移除 polling) @@ -103,8 +104,10 @@ export function Sidebar({ const isActive = (item: ProductNavItem) => ( isRouteActive(item.href, item.exact) || item.aliases?.some(alias => isRouteActive(alias)) === true || - item.relatedPaths?.some(path => isRouteActive(path)) === true + item.relatedPaths?.some(path => isRouteActive(path)) === true || + item.children?.some(child => isChildActive(child)) === true ) + const isChildActive = (item: ProductNavChild) => isRouteActive(item.href, item.exact) const sidebarWidth = width ?? (compact && collapsed ? 48 : collapsed ? 64 : 224) return ( @@ -150,48 +153,88 @@ export function Sidebar({ const count = item.badge && mounted ? pendingCount : 0 const secondary = item.surface === 'secondary' return ( - -
diff --git a/apps/web/src/lib/navigation/product-ia.ts b/apps/web/src/lib/navigation/product-ia.ts index 1da2c227..67259109 100644 --- a/apps/web/src/lib/navigation/product-ia.ts +++ b/apps/web/src/lib/navigation/product-ia.ts @@ -30,6 +30,7 @@ export type ProductNavItem = { exact?: boolean badge?: boolean surface?: 'primary' | 'secondary' + children?: ProductNavChild[] } export type ProductNavSection = { @@ -37,6 +38,14 @@ export type ProductNavSection = { items: ProductNavItem[] } +export type ProductNavChild = { + id: string + href: string + labelKey: string + exact?: boolean + badge?: boolean +} + export type WorkflowNavItem = { labelKey: string href: string @@ -57,6 +66,10 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [ labelKey: 'awooopWarRoom', Icon: BrainCircuit, exact: true, + children: [ + { id: 'aiops-timeline', href: '/aiops/timeline', labelKey: 'aiopsTimeline' }, + { id: 'openclaw-live-ops-space', href: '/openclaw/live-ops-space', labelKey: 'openclawLiveOps' }, + ], }, { id: 'observability', @@ -64,6 +77,17 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [ labelKey: 'observabilityCommandCenter', Icon: Monitor, aliases: ['/monitoring', '/apm', '/errors', '/apps', '/services', '/topology', '/alerts', '/alert-operation-logs', '/notifications'], + children: [ + { id: 'monitoring', href: '/monitoring', labelKey: 'monitoring' }, + { id: 'apm', href: '/apm', labelKey: 'apm' }, + { id: 'errors', href: '/errors', labelKey: 'errors' }, + { id: 'apps', href: '/apps', labelKey: 'apps' }, + { id: 'services', href: '/services', labelKey: 'services' }, + { id: 'topology', href: '/topology', labelKey: 'topology' }, + { id: 'alerts', href: '/alerts', labelKey: 'alerts' }, + { id: 'alert-operation-logs', href: '/alert-operation-logs', labelKey: 'alertOperationLogs' }, + { id: 'notifications', href: '/notifications', labelKey: 'notifications' }, + ], }, ], }, @@ -82,6 +106,13 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [ labelKey: 'knowledgeAutomation', Icon: BookOpen, aliases: ['/knowledge', '/automation', '/auto-repair', '/drift', '/neural-command'], + children: [ + { id: 'knowledge', href: '/knowledge', labelKey: 'knowledge' }, + { id: 'automation', href: '/automation', labelKey: 'automation' }, + { id: 'auto-repair', href: '/auto-repair', labelKey: 'autoRepair' }, + { id: 'drift', href: '/drift', labelKey: 'drift' }, + { id: 'neural-command', href: '/neural-command', labelKey: 'neuralCommand' }, + ], }, { id: 'awooop-tenants', @@ -110,6 +141,9 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [ aliases: ['/authorizations'], badge: true, surface: 'secondary', + children: [ + { id: 'authorizations', href: '/authorizations', labelKey: 'authorizations' }, + ], }, { id: 'awooop-contracts', @@ -134,21 +168,14 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [ href: '/iwooos', labelKey: 'iwooos', Icon: ShieldCheck, - }, - { - id: 'iwooos-security-compliance', - href: '/security-compliance', - labelKey: 'iwooosSecurityCompliance', - Icon: ShieldCheck, - aliases: ['/security', '/compliance'], - surface: 'secondary', - }, - { - id: 'governance-security', - href: '/governance', - labelKey: 'governanceSecurity', - Icon: Radar, - surface: 'secondary', + aliases: ['/security-compliance'], + relatedPaths: ['/security', '/compliance', '/governance'], + children: [ + { id: 'security-compliance', href: '/security-compliance', labelKey: 'securityCompliance' }, + { id: 'security', href: '/security', labelKey: 'security' }, + { id: 'compliance', href: '/compliance', labelKey: 'compliance' }, + { id: 'governance', href: '/governance', labelKey: 'governance' }, + ], }, { id: 'operations', @@ -156,12 +183,24 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [ labelKey: 'operationsOverview', Icon: Package, aliases: ['/deployments', '/tickets', '/cost', '/billing', '/action-logs', '/reports'], + children: [ + { id: 'deployments', href: '/deployments', labelKey: 'deployments' }, + { id: 'tickets', href: '/tickets', labelKey: 'tickets' }, + { id: 'cost', href: '/cost', labelKey: 'cost' }, + { id: 'billing', href: '/billing', labelKey: 'billing' }, + { id: 'action-logs', href: '/action-logs', labelKey: 'actions' }, + { id: 'reports', href: '/reports', labelKey: 'reports' }, + ], }, { id: 'settings', href: '/settings', labelKey: 'settings', Icon: Settings, + aliases: ['/users'], + children: [ + { id: 'users', href: '/users', labelKey: 'users' }, + ], }, ], }, @@ -184,7 +223,17 @@ export const PRODUCT_NAV_SECTIONS: ProductNavSection[] = [ surface: 'secondary', }, { id: 'terminal', href: '/terminal', labelKey: 'terminal', Icon: Terminal, surface: 'secondary' }, - { id: 'classic', href: '/classic', labelKey: 'classicAICenter', Icon: Radar, surface: 'secondary' }, + { + id: 'classic', + href: '/classic', + labelKey: 'classicAICenter', + Icon: Radar, + surface: 'secondary', + aliases: ['/demo'], + children: [ + { id: 'demo', href: '/demo', labelKey: 'demo' }, + ], + }, ], }, ] diff --git a/ops/runner/test_cd_controlled_runtime_profile.py b/ops/runner/test_cd_controlled_runtime_profile.py index 8334065b..f8764fe3 100644 --- a/ops/runner/test_cd_controlled_runtime_profile.py +++ b/ops/runner/test_cd_controlled_runtime_profile.py @@ -97,6 +97,11 @@ def test_iwooos_security_operation_api_stays_on_controlled_runtime_profile() -> assert source in text +def test_navigation_coverage_guard_stays_on_controlled_runtime_profile() -> None: + text = _workflow_text() + assert "scripts/dev/awoooi-navigation-coverage-guard.py)" in text + + def test_ai_autonomous_runtime_control_stays_on_controlled_runtime_profile() -> None: text = _workflow_text() expected_sources = [ diff --git a/scripts/dev/awoooi-navigation-coverage-guard.py b/scripts/dev/awoooi-navigation-coverage-guard.py new file mode 100644 index 00000000..a849a731 --- /dev/null +++ b/scripts/dev/awoooi-navigation-coverage-guard.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Guard AWOOOI product navigation against orphaned frontend routes.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path + + +ROUTE_RE = re.compile(r"href:\s*'([^']+)'") +ROUTE_LIST_RE = re.compile(r"(?:aliases|relatedPaths):\s*\[([^\]]*)\]") +LABEL_KEY_RE = re.compile(r"labelKey:\s*'([^']+)'") +QUOTED_RE = re.compile(r"'([^']+)'") + + +def locale_routes(root: Path) -> set[str]: + app_root = root / "apps/web/src/app/[locale]" + routes: set[str] = set() + for page in app_root.rglob("page.tsx"): + rel = page.relative_to(app_root) + route_parts = rel.parts[:-1] + if any(part.startswith("[") and part.endswith("]") for part in route_parts): + continue + route = "/" + "/".join(route_parts) + routes.add("/" if route == "/" else route.rstrip("/")) + return routes + + +def navigation_routes(root: Path) -> set[str]: + source = (root / "apps/web/src/lib/navigation/product-ia.ts").read_text( + encoding="utf-8" + ) + routes = set(ROUTE_RE.findall(source)) + for match in ROUTE_LIST_RE.findall(source): + routes.update(QUOTED_RE.findall(match)) + return {route.rstrip("/") or "/" for route in routes} + + +def navigation_label_keys(root: Path) -> set[str]: + source = (root / "apps/web/src/lib/navigation/product-ia.ts").read_text( + encoding="utf-8" + ) + return set(LABEL_KEY_RE.findall(source)) + + +def missing_nav_labels(root: Path, label_keys: set[str]) -> dict[str, list[str]]: + missing: dict[str, list[str]] = {} + for messages_file in sorted((root / "apps/web/messages").glob("*.json")): + with messages_file.open(encoding="utf-8") as handle: + payload = json.load(handle) + nav = payload.get("nav") if isinstance(payload, dict) else None + nav_keys = set(nav) if isinstance(nav, dict) else set() + gaps = sorted(label_keys - nav_keys) + if gaps: + missing[messages_file.name] = gaps + return missing + + +def covered(route: str, nav_routes: set[str]) -> bool: + if route in nav_routes: + return True + return any(parent != "/" and route.startswith(parent + "/") for parent in nav_routes) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--root", default=".", help="Repository root") + args = parser.parse_args() + + root = Path(args.root).resolve() + routes = locale_routes(root) + nav_routes = navigation_routes(root) + label_keys = navigation_label_keys(root) + missing = sorted(route for route in routes if not covered(route, nav_routes)) + missing_labels = missing_nav_labels(root, label_keys) + + sidebar = (root / "apps/web/src/components/layout/sidebar.tsx").read_text( + encoding="utf-8" + ) + hidden_secondary_filter = "surface !== 'secondary'" in sidebar + + if missing or missing_labels or hidden_secondary_filter: + if missing: + print("NAVIGATION_COVERAGE_GUARD_MISSING_ROUTES") + for route in missing: + print(f"- {route}") + if missing_labels: + print("NAVIGATION_COVERAGE_GUARD_MISSING_LABEL_KEYS") + for file_name, gaps in missing_labels.items(): + print(f"- {file_name}: {', '.join(gaps)}") + if hidden_secondary_filter: + print("NAVIGATION_COVERAGE_GUARD_HIDDEN_SECONDARY_ITEMS") + print("- Sidebar must not filter product nav items by surface !== 'secondary'.") + return 1 + + print( + "AWOOOI_NAVIGATION_COVERAGE_GUARD_OK " + f"routes={len(routes)} nav_routes={len(nav_routes)} nav_label_keys={len(label_keys)}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())