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 (
+
+ )
+ }
+
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 (
-
-
- {!collapsed && (
-
- {t(item.labelKey)}
-
- )}
- {item.badge && count > 0 && (
-
- {count > 99 ? '99+' : count}
-
- )}
-
+
+
+
+ {!collapsed && (
+
+ {t(item.labelKey)}
+
+ )}
+ {item.badge && count > 0 && (
+
+ {count > 99 ? '99+' : count}
+
+ )}
+
+ {!collapsed && item.children?.length ? (
+
+ {item.children.map(child => {
+ const childActive = isChildActive(child)
+ return (
+
+ {t(child.labelKey)}
+
+ )
+ })}
+
+ ) : null}
+
)
})}
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())