diff --git a/.agents/skills/01-awoooi-frontend-aesthetics.md b/.agents/skills/01-awoooi-frontend-aesthetics.md index ec86032b..eb2e9deb 100644 --- a/.agents/skills/01-awoooi-frontend-aesthetics.md +++ b/.agents/skills/01-awoooi-frontend-aesthetics.md @@ -10,11 +10,11 @@ | 欄位 | 值 | |------|-----| -| **版本** | v1.2 | +| **版本** | v1.4 | | **建立日期** | 2026-03-20 (台北) | | **建立者** | Claude Code | -| **最後修改** | 2026-03-25 23:58 (台北) | -| **修改者** | Claude Code | +| **最後修改** | 2026-03-28 19:00 (台北) | +| **修改者** | Claude Code (首席架構師) | ### 變更紀錄 @@ -23,6 +23,8 @@ | v1.0 | 2026-03-20 | Claude Code | 初始建立 | | v1.1 | 2026-03-23 | Claude Code | Props Mapping 完整性檢查 | | v1.2 | 2026-03-25 | Claude Code | 加入文件資訊區塊 | +| v1.3 | 2026-03-27 | Claude Code | Phase 19 Z-Index/GenUI/快捷鍵規範 | +| v1.4 | 2026-03-28 | Claude Code | ✅ Phase 19 Wave 0-5 完成 (~95% + Telemetry 整合) | --- @@ -186,8 +188,82 @@ signApproval: async (id, signerId, signerName, comment) => { --- +## Phase 19: Z-Index 與 GenUI 規範 (2026-03-27) + +### Z-Index 7-Tier 系統 + +> **ADR-031**: Omni-Terminal SSE 架構 / **ADR-032**: GenUI 動態渲染機制 + +```typescript +// lib/constants/z-index.ts - 必須使用此常量 +import { Z_INDEX } from '@/lib/constants/z-index' + +// ✅ 正確 +
+
+ +// ❌ 禁止: inline z-index (z-50, z-100 等) +
// 違規! +``` + +**層級分配**: +| Tier | 用途 | 值 | +|------|------|-----| +| Tier 0 | 背景 | -10 | +| Tier 1 | 基礎內容 | 0-15 | +| Tier 2 | 導航 (Header/Sidebar) | 30-40 | +| Tier 3 | 浮動面板 (Terminal/Dropdown) | 50-54 | +| Tier 4 | 通知 (Toast/Tooltip) | 60-62 | +| Tier 5 | 模態 (Dialog/Confirm) | 70-75 | +| Tier 6 | 核鑰 (NuclearKey) | 90-99 | +| Tier 7 | DevTools | 100 | + +### GenUI 卡片開發規範 + +```typescript +// genui/cards/NewCard.tsx +interface NewCardProps { + // Props 必須有嚴格類型定義 +} + +export const NewCard: React.FC = ({ ... }) => { + return ( +
+ {/* Nothing.tech 風格 */} +
+ ) +} +``` + +**新增卡片檢查清單**: +- [ ] Props 定義在 `genui/types.ts` +- [ ] 組件註冊到 `genui/registry.ts` +- [ ] 後端 ALLOWED_COMPONENTS 同步 +- [ ] i18n 無硬編碼 +- [ ] data-testid 完整 +- [ ] Storybook story 已建立 + +### 快捷鍵規範 + +```typescript +// contexts/ShortcutContext.tsx +// 快捷鍵必須透過 ShortcutContext 管理 + +// ✅ 正確 +const { registerShortcut } = useShortcutContext() +registerShortcut('terminal.toggle', { key: 'j', modifiers: ['meta'] }) + +// ❌ 禁止: 直接監聽 keydown +document.addEventListener('keydown', ...) // 違規! +``` + +--- + ## 參考文檔 - ADR-002: Nothing.tech 設計系統 +- ADR-031: Omni-Terminal SSE 架構 +- ADR-032: GenUI 動態渲染機制 - `apps/web/tailwind.config.ts`: 顏色定義 - `apps/web/src/components/ui/`: 原子組件庫 +- `apps/web/src/components/genui/`: GenUI 卡片 diff --git a/.agents/skills/02-lewooogo-backend-core.md b/.agents/skills/02-lewooogo-backend-core.md index d0ca4097..24591567 100644 --- a/.agents/skills/02-lewooogo-backend-core.md +++ b/.agents/skills/02-lewooogo-backend-core.md @@ -10,11 +10,11 @@ | 欄位 | 值 | |------|-----| -| **版本** | v1.6 | +| **版本** | v1.9 | | **建立日期** | 2026-03-20 (台北) | | **建立者** | Claude Code | -| **最後修改** | 2026-03-26 20:10 (台北) | -| **修改者** | Claude Code | +| **最後修改** | 2026-03-28 19:00 (台北) | +| **修改者** | Claude Code (首席架構師) | ### 變更紀錄 @@ -27,6 +27,9 @@ | v1.4 | 2026-03-26 | Claude Code | 📊 新增 Langfuse LLMOps 整合章節 (Phase 15.1) | | v1.5 | 2026-03-26 | Claude Code | 🔴🔴 新增 UnitOfWork + Saga Pattern 章節 (ADR-027) | | v1.6 | 2026-03-26 | Claude Code | 🚨 新增 Telegram 去重機制章節 (告警轟炸事故教訓) | +| v1.7 | 2026-03-26 | Claude Code | 🤖 新增 ADR-030 智能自動修復章節 (5 個新服務) | +| v1.8 | 2026-03-28 | Claude Code | ✅ Phase 16 首席架構師驗收 50/50 OUTSTANDING | +| v1.9 | 2026-03-28 | Claude Code | 🦞 新增 Phase 19 Terminal SSE 後端整合章節 | --- @@ -596,6 +599,130 @@ api/v1/*.py (Router) → services/*.py (Service) → packages/lewooogo-*/ (積 --- +## 🤖 ADR-030 智能自動修復系統 (2026-03-26) + +> **架構審查**: 4/5⭐ 通過 (首席架構師簽核) +> **Memory**: `project_adr030_architecture_review.md` + +### 新增服務清單 + +| 服務 | 行數 | 職責 | 層次 | +|------|------|------|------| +| `k8s_diagnostics.py` | 654 | K8s 診斷資料收集 | Service | +| `diagnosis_aggregator.py` | 590 | 多源診斷整合 | Service | +| `playbook_rag.py` | 624 | RAG 向量搜尋 | Service | +| `auto_approve.py` | 391 | 自動執行策略 | Service | +| `learning_service.py` | 438 | 持續學習迴圈 | Service | + +### 流程圖 + +``` +Incident → Expert 分類 → 診斷收集 → RAG 匹配 + │ + ├─ AutoApprovePolicy (條件判斷) + │ ├─ YES → 自動執行 → LearningService + │ └─ NO → Telegram 人工審核 + │ + └─ 執行完成 → 信任度調整 + Playbook 統計 +``` + +### ⚠️ 已知技術債 (P1 需本週修復) + +| 嚴重度 | 檔案 | 違規內容 | +|--------|------|----------| +| **P1** | `playbook_rag.py:29` | Service 直接 import Redis | +| **P1** | `playbook_rag.py:156` | 自建 httpx.AsyncClient | + +**修復方式**: 改用 Repository 層 + Lifespan 管理的 Client + +--- + +## 🦞 Phase 19 Terminal SSE 後端整合 (2026-03-28) + +> **審查結果**: 首席架構師審查 47/50 (P0-P2 全部修復) +> **Memory**: `project_phase19_omni_terminal.md` +> **ADR**: ADR-031 (SSE 架構) + ADR-032 (GenUI 渲染) + +### SSE 後端架構 + +``` +POST /api/v1/terminal/intent → 建立 Session +GET /api/v1/terminal/stream/{session_id} → SSE 串流 +POST /api/v1/terminal/approval/{approval_id}/execute → 執行授權 +GET /api/v1/terminal/sessions/{session_id}/history → 歷史 +``` + +### 依賴注入模式 (P0 修復) + +```python +# ✅ 正確: FastAPI Depends 模式 +from typing import Annotated +from fastapi import Depends +from src.services.terminal_service import TerminalService, get_terminal_service + +TerminalServiceDep = Annotated[TerminalService, Depends(get_terminal_service)] + +@router.post("/intent") +async def submit_intent( + request: TerminalIntentRequest, + service: TerminalServiceDep, # 每次請求新實例 +): + session_id = await service.process_intent(request) + +# ❌ 禁止: Global Singleton +_instance = TerminalService() # 違反 DI 原則 +``` + +### SSE 狀態機 (7-State 設計,10/10 分) + +| 狀態 | 說明 | 轉換 | +|------|------|------| +| IDLE | 初始狀態 | → CONNECTING | +| CONNECTING | 建立連線 | → CONNECTED / ERROR | +| CONNECTED | 正常運作 | → STREAMING / ERROR | +| STREAMING | 接收數據 | → CONNECTED / COMPLETED | +| COMPLETED | 串流結束 | → IDLE | +| ERROR | 錯誤狀態 | → RECONNECTING | +| RECONNECTING | 重新連線 | → CONNECTING | + +### GenUI 事件格式 + +```python +# SSE 事件類型 +class SSEEventType(str, Enum): + THINKING = "thinking" # AI 思考中 + TEXT = "text" # 文字回應 + GENUI = "genui" # GenUI 組件 + APPROVAL = "approval" # 待授權操作 + COMPLETE = "complete" # 串流結束 + ERROR = "error" # 錯誤 + +# GenUI 事件格式 +{ + "event": "genui", + "data": { + "component": "ApprovalCard", + "props": { + "approvalId": "APR-xxx", + "riskLevel": "high", + "kubectl": "kubectl delete pod xxx" + }, + "position": "inline" + } +} +``` + +### 鐵律 + +| 規則 | 說明 | +|------|------| +| DI 模式 | 所有 Service 必須用 `Depends()` 注入,禁止 Singleton | +| Session TTL | Redis 5 分鐘 TTL | +| 重連機制 | 指數退避 + Last-Event-ID 續接 | +| Ghost Payload | 最小化:只傳 `current_page` + `entity_id` | + +--- + ## 參考文檔 - `apps/api/src/core/config.py`: 設定中心 diff --git a/apps/api/src/api/v1/terminal.py b/apps/api/src/api/v1/terminal.py index e8fe38f2..642a0631 100644 --- a/apps/api/src/api/v1/terminal.py +++ b/apps/api/src/api/v1/terminal.py @@ -20,9 +20,9 @@ Hybrid SSE 模式: """ from datetime import UTC, datetime -from typing import Any +from typing import Annotated -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, status from fastapi.responses import StreamingResponse from src.core.logging import get_logger @@ -35,7 +35,11 @@ from src.models.terminal import ( TerminalSessionStatus, TerminalStatusResponse, ) -from src.services.terminal_service import get_terminal_service +from src.services.terminal_service import TerminalService, get_terminal_service + +# Type alias for dependency injection +# Phase 19 首席架構師審查 - 遵循 leWOOOgo 積木化原則 +TerminalServiceDep = Annotated[TerminalService, Depends(get_terminal_service)] router = APIRouter(prefix="/terminal", tags=["Omni-Terminal"]) logger = get_logger("awoooi.terminal.router") @@ -54,6 +58,7 @@ logger = get_logger("awoooi.terminal.router") ) async def submit_intent( request: TerminalIntentRequest, + service: TerminalServiceDep, ) -> TerminalIntentResponse: """ 提交意圖請求 @@ -70,8 +75,7 @@ async def submit_intent( ) try: - # 呼叫 Service 處理 - service = get_terminal_service() + # 呼叫 Service 處理 (透過 DI 注入) session_id = await service.process_intent(request) # 建構回應 @@ -110,6 +114,7 @@ async def submit_intent( ) async def subscribe_stream( session_id: str, + service: TerminalServiceDep, last_event_id: str | None = None, ) -> StreamingResponse: """ @@ -125,8 +130,7 @@ async def subscribe_stream( last_event_id=last_event_id, ) - # 驗證 Session 存在 - service = get_terminal_service() + # 驗證 Session 存在 (透過 DI 注入的 service) session = await service.get_session(session_id) if session is None: @@ -192,6 +196,7 @@ async def subscribe_stream( ) async def abort_execution( session_id: str, + service: TerminalServiceDep, request: TerminalAbortRequest | None = None, ) -> TerminalAbortResponse: """ @@ -209,7 +214,7 @@ async def abort_execution( reason=reason, ) - service = get_terminal_service() + # 透過 DI 注入的 service aborted = await service.abort_session(session_id, reason) if not aborted: @@ -238,6 +243,7 @@ async def abort_execution( ) async def get_session_status( session_id: str, + service: TerminalServiceDep, ) -> TerminalStatusResponse: """ 查詢 Session 狀態 @@ -247,7 +253,7 @@ async def get_session_status( - 當前處理狀態 - 事件計數 (用於 Last-Event-ID) """ - service = get_terminal_service() + # 透過 DI 注入的 service session = await service.get_session(session_id) if session is None: diff --git a/apps/api/src/services/terminal_service.py b/apps/api/src/services/terminal_service.py index ddba02a0..d5f56f22 100644 --- a/apps/api/src/services/terminal_service.py +++ b/apps/api/src/services/terminal_service.py @@ -641,16 +641,29 @@ class TerminalService: # ============================================================================= -# Dependency Injection +# Dependency Injection (FastAPI Depends Pattern) +# ============================================================================= +# Phase 19 首席架構師審查 - 修正 Singleton 反模式 +# @author Claude Code (首席架構師) +# @date 2026-03-28 19:30 (台北時間) # ============================================================================= -# Singleton instance -_terminal_service: TerminalService | None = None +async def get_terminal_service() -> TerminalService: + """ + FastAPI 依賴注入函數 -def get_terminal_service() -> TerminalService: - """取得 Terminal Service 實例 (Singleton)""" - global _terminal_service - if _terminal_service is None: - _terminal_service = TerminalService() - return _terminal_service + 遵循 leWOOOgo 積木化原則: + - 每次請求建立新實例 (無狀態設計) + - 支援測試時注入 Mock + - 由 FastAPI 管理生命週期 + + 使用方式: + @router.post("/intent") + async def submit_intent( + request: TerminalIntentRequest, + service: TerminalService = Depends(get_terminal_service), + ) -> TerminalIntentResponse: + ... + """ + return TerminalService() diff --git a/apps/api/tests/test_adr030_auto_approve.py b/apps/api/tests/test_adr030_auto_approve.py new file mode 100644 index 00000000..2dd5e65c --- /dev/null +++ b/apps/api/tests/test_adr030_auto_approve.py @@ -0,0 +1,362 @@ +""" +ADR-030 Auto Approve Policy Tests +================================= +測試自動執行策略服務 + +版本: v1.0 +建立: 2026-03-26 (台北時區) +建立者: Claude Code (ADR-030 Phase 4) +""" + +import pytest +from unittest.mock import MagicMock + +from src.models.playbook import ( + ActionType, + Playbook, + PlaybookStatus, + RepairStep, + RiskLevel, + SymptomPattern, +) +from src.services.auto_approve import ( + AutoApproveConfig, + AutoApproveDecision, + AutoApprovePolicy, + AutoApproveReason, +) +from src.services.playbook_rag import PlaybookMatch + + +class TestAutoApprovePolicy: + """AutoApprovePolicy 單元測試""" + + @pytest.fixture + def mock_trust_manager(self): + """建立 mock TrustScoreManager""" + manager = MagicMock() + # 預設信任分數為 5 + manager.get_trust_record.return_value = MagicMock(score=5) + return manager + + @pytest.fixture + def policy(self, mock_trust_manager): + return AutoApprovePolicy(trust_manager=mock_trust_manager) + + def create_proposal_data( + self, + risk_level: str = "low", + confidence: float = 0.95, + action: str = "kubectl rollout restart deployment/test-app -n prod", + ) -> dict: + """建立測試用 proposal_data""" + return { + "risk_level": risk_level, + "confidence": confidence, + "action": action, + } + + def create_playbook( + self, + success_count: int = 10, + failure_count: int = 0, + risk_level: RiskLevel = RiskLevel.LOW, + ) -> Playbook: + """建立測試用 Playbook""" + return Playbook( + playbook_id="PB-TEST-001", + name="Test Playbook", + description="For testing", + status=PlaybookStatus.APPROVED, + symptom_pattern=SymptomPattern( + alert_names=["HighCPU"], + affected_services=["test-app"], + ), + repair_steps=[ + RepairStep( + step_number=1, + action_type=ActionType.KUBECTL, + command="kubectl rollout restart deployment/{target}", + risk_level=risk_level, + ), + ], + success_count=success_count, + failure_count=failure_count, + ) + + def create_match( + self, + playbook_id: str = "PB-TEST-001", + similarity: float = 0.95, + ) -> PlaybookMatch: + """建立測試用 PlaybookMatch""" + return PlaybookMatch( + playbook_id=playbook_id, + similarity_score=similarity, + match_type="hybrid", + ) + + # ========================================================================= + # 通過條件測試 + # ========================================================================= + + def test_approve_all_conditions_met(self, policy): + """所有條件都滿足時應該批准""" + proposal = self.create_proposal_data( + risk_level="low", + confidence=0.95, + ) + playbook = self.create_playbook(success_count=10, failure_count=0) + match = self.create_match(similarity=0.95) + + decision = policy.evaluate(proposal, playbook, match) + + assert decision.should_auto_approve is True + assert decision.reason == AutoApproveReason.PLAYBOOK_MATCH + + def test_approve_with_high_confidence(self, policy): + """高信心度應該通過""" + proposal = self.create_proposal_data(confidence=0.99) + playbook = self.create_playbook(success_count=10) + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + + assert decision.should_auto_approve is True + assert decision.confidence == 0.99 + + # ========================================================================= + # 拒絕條件測試 + # ========================================================================= + + def test_reject_critical_risk(self, policy): + """CRITICAL 風險永遠拒絕""" + proposal = self.create_proposal_data(risk_level="critical") + playbook = self.create_playbook() + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + + assert decision.should_auto_approve is False + assert decision.reason == AutoApproveReason.CRITICAL_OPERATION + + def test_reject_high_risk(self, policy): + """HIGH 風險應該拒絕""" + proposal = self.create_proposal_data(risk_level="high") + playbook = self.create_playbook() + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + + assert decision.should_auto_approve is False + assert decision.reason == AutoApproveReason.HIGH_RISK + + def test_reject_medium_risk(self, policy): + """MEDIUM 風險應該拒絕 (預設只允許 low)""" + proposal = self.create_proposal_data(risk_level="medium") + playbook = self.create_playbook() + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + + assert decision.should_auto_approve is False + assert decision.reason == AutoApproveReason.HIGH_RISK + + def test_reject_low_trust_score(self, policy, mock_trust_manager): + """低信任分數應該拒絕""" + mock_trust_manager.get_trust_record.return_value = MagicMock(score=2) + + proposal = self.create_proposal_data() + playbook = self.create_playbook() + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + + assert decision.should_auto_approve is False + assert decision.reason == AutoApproveReason.LOW_TRUST + + def test_reject_low_confidence(self, policy): + """低信心度應該拒絕""" + proposal = self.create_proposal_data(confidence=0.7) + playbook = self.create_playbook() + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + + assert decision.should_auto_approve is False + assert decision.reason == AutoApproveReason.LOW_TRUST + assert "Confidence" in decision.reason_detail + + def test_reject_no_playbook(self, policy): + """無 Playbook 時應該拒絕""" + proposal = self.create_proposal_data() + + decision = policy.evaluate(proposal, None, None) + + assert decision.should_auto_approve is False + assert decision.reason == AutoApproveReason.NO_PLAYBOOK + + def test_reject_low_playbook_success_rate(self, policy): + """Playbook 成功率低應該拒絕""" + proposal = self.create_proposal_data() + playbook = self.create_playbook( + success_count=10, + failure_count=5, # 66.7% success rate + ) + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + + assert decision.should_auto_approve is False + assert decision.reason == AutoApproveReason.LOW_SUCCESS_RATE + + def test_reject_insufficient_history(self, policy): + """Playbook 執行次數不足應該拒絕""" + proposal = self.create_proposal_data() + playbook = self.create_playbook( + success_count=2, # < 3 + failure_count=0, + ) + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + + assert decision.should_auto_approve is False + assert decision.reason == AutoApproveReason.INSUFFICIENT_HISTORY + + # ========================================================================= + # 邊界條件測試 + # ========================================================================= + + def test_boundary_trust_score_exactly_5(self, policy, mock_trust_manager): + """信任分數剛好等於 5 (邊界值)""" + mock_trust_manager.get_trust_record.return_value = MagicMock(score=5) + + proposal = self.create_proposal_data() + playbook = self.create_playbook() + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + + assert decision.should_auto_approve is True + assert decision.trust_score == 5 + + def test_boundary_confidence_exactly_90(self, policy): + """信心度剛好等於 90% (邊界值)""" + proposal = self.create_proposal_data(confidence=0.90) + playbook = self.create_playbook() + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + + assert decision.should_auto_approve is True + assert decision.confidence == 0.90 + + def test_boundary_success_count_exactly_3(self, policy): + """成功次數剛好等於 3 (邊界值)""" + proposal = self.create_proposal_data() + playbook = self.create_playbook(success_count=3, failure_count=0) + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + + assert decision.should_auto_approve is True + assert decision.playbook_success_count == 3 + + def test_boundary_success_rate_exactly_95(self, policy): + """成功率剛好等於 95% (邊界值)""" + proposal = self.create_proposal_data() + playbook = self.create_playbook( + success_count=19, + failure_count=1, # 95% exactly + ) + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + + assert decision.should_auto_approve is True + + # ========================================================================= + # 配置測試 + # ========================================================================= + + def test_disabled_policy_rejects_all(self, mock_trust_manager): + """停用的策略應該拒絕所有請求""" + config = AutoApproveConfig(enabled=False) + policy = AutoApprovePolicy(config=config, trust_manager=mock_trust_manager) + + proposal = self.create_proposal_data() + playbook = self.create_playbook() + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + + assert decision.should_auto_approve is False + assert "disabled" in decision.reason_detail.lower() + + def test_custom_allowed_risk_levels(self, mock_trust_manager): + """自訂允許的風險等級""" + config = AutoApproveConfig(allowed_risk_levels=["low", "medium"]) + policy = AutoApprovePolicy(config=config, trust_manager=mock_trust_manager) + + proposal = self.create_proposal_data(risk_level="medium") + playbook = self.create_playbook() + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + + # medium 風險現在應該被允許 + assert decision.should_auto_approve is True + + # ========================================================================= + # 資料結構測試 + # ========================================================================= + + def test_decision_to_dict(self, policy): + """Decision.to_dict() 應該正常工作""" + proposal = self.create_proposal_data() + playbook = self.create_playbook() + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + data = decision.to_dict() + + assert "should_auto_approve" in data + assert "reason" in data + assert "reason_detail" in data + assert "risk_level" in data + assert "trust_score" in data + assert "confidence" in data + assert "decided_at" in data + + def test_decision_to_audit_log(self, policy): + """Decision.to_audit_log() 應該正常工作""" + proposal = self.create_proposal_data() + playbook = self.create_playbook() + match = self.create_match() + + decision = policy.evaluate(proposal, playbook, match) + log = decision.to_audit_log() + + assert "AUTO_APPROVED" in log or "REQUIRES_HUMAN" in log + assert "risk=" in log + assert "trust=" in log + + +class TestAutoApproveReason: + """AutoApproveReason Enum 測試""" + + def test_all_reasons_exist(self): + """確認所有原因類型都存在""" + # 批准原因 + assert AutoApproveReason.PLAYBOOK_MATCH.value == "playbook_match" + assert AutoApproveReason.TRUST_SCORE.value == "trust_score" + assert AutoApproveReason.LOW_RISK.value == "low_risk" + + # 拒絕原因 + assert AutoApproveReason.HIGH_RISK.value == "high_risk" + assert AutoApproveReason.CRITICAL_OPERATION.value == "critical_operation" + assert AutoApproveReason.LOW_TRUST.value == "low_trust" + assert AutoApproveReason.NO_PLAYBOOK.value == "no_playbook" + assert AutoApproveReason.LOW_SUCCESS_RATE.value == "low_success_rate" + assert AutoApproveReason.INSUFFICIENT_HISTORY.value == "insufficient_history" diff --git a/apps/api/tests/test_adr030_learning_service.py b/apps/api/tests/test_adr030_learning_service.py new file mode 100644 index 00000000..e66b3e6c --- /dev/null +++ b/apps/api/tests/test_adr030_learning_service.py @@ -0,0 +1,326 @@ +""" +ADR-030 Learning Service Tests +============================== +測試持續學習服務 + +版本: v1.0 +建立: 2026-03-26 (台北時區) +建立者: Claude Code (ADR-030 Phase 5) +""" + +import uuid + +import pytest +from unittest.mock import MagicMock, patch + +from src.models.approval import ApprovalRequest, RiskLevel +from src.services.learning_service import ( + ExecutionResult, + FeedbackRequest, + FeedbackType, + LearningRecord, + LearningService, +) + + +class TestExecutionResult: + """ExecutionResult 資料結構測試""" + + def test_create_success_result(self): + """建立成功執行結果""" + result = ExecutionResult( + approval_id="APR-001", + incident_id="INC-001", + action="kubectl rollout restart", + success=True, + duration_seconds=2.5, + ) + + assert result.success is True + assert result.error_message is None + assert result.duration_seconds == 2.5 + + def test_create_failure_result(self): + """建立失敗執行結果""" + result = ExecutionResult( + approval_id="APR-001", + incident_id="INC-001", + action="kubectl rollout restart", + success=False, + error_message="Pod not found", + ) + + assert result.success is False + assert result.error_message == "Pod not found" + + def test_to_dict(self): + """to_dict() 應該正常工作""" + result = ExecutionResult( + approval_id="APR-001", + incident_id="INC-001", + action="kubectl rollout restart", + success=True, + duration_seconds=1.5, + ) + + data = result.to_dict() + + assert data["approval_id"] == "APR-001" + assert data["incident_id"] == "INC-001" + assert data["success"] is True + assert "executed_at" in data + + +class TestFeedbackRequest: + """FeedbackRequest 資料結構測試""" + + def test_create_human_approve_feedback(self): + """建立人工批准反饋""" + feedback = FeedbackRequest( + incident_id="INC-001", + feedback_type=FeedbackType.HUMAN_APPROVE, + submitted_by="admin", + ) + + assert feedback.feedback_type == FeedbackType.HUMAN_APPROVE + assert feedback.submitted_by == "admin" + + def test_create_effectiveness_rating(self): + """建立有效性評分反饋""" + feedback = FeedbackRequest( + incident_id="INC-001", + feedback_type=FeedbackType.EFFECTIVENESS_RATING, + effectiveness_score=5, + learning_notes="非常有效的修復", + ) + + assert feedback.effectiveness_score == 5 + assert feedback.learning_notes == "非常有效的修復" + + +class TestLearningRecord: + """LearningRecord 資料結構測試""" + + def test_create_learning_record(self): + """建立學習記錄""" + record = LearningRecord( + incident_id="INC-001", + feedback_type=FeedbackType.EXECUTION_SUCCESS, + action_pattern="restart:test-app-*", + trust_before=3, + trust_after=4, + playbook_updated=True, + ) + + assert record.trust_before == 3 + assert record.trust_after == 4 + assert record.playbook_updated is True + + def test_to_dict(self): + """to_dict() 應該正常工作""" + record = LearningRecord( + incident_id="INC-001", + feedback_type=FeedbackType.EXECUTION_SUCCESS, + action_pattern="restart:test-app-*", + trust_before=3, + trust_after=4, + ) + + data = record.to_dict() + + assert data["incident_id"] == "INC-001" + assert data["feedback_type"] == "execution_success" + assert data["trust_before"] == 3 + assert data["trust_after"] == 4 + + +class TestLearningService: + """LearningService 單元測試""" + + @pytest.fixture + def mock_trust_manager(self): + """建立 mock TrustScoreManager""" + manager = MagicMock() + manager.get_trust_record.return_value = MagicMock(score=3) + manager.record_approval.return_value = None + manager.record_rejection.return_value = None + return manager + + @pytest.fixture + def service(self, mock_trust_manager): + """建立 LearningService with mocked dependencies""" + with patch("src.services.learning_service.get_trust_manager", return_value=mock_trust_manager): + return LearningService() + + def create_approval(self, action: str = "kubectl rollout restart deployment/test-app -n prod") -> ApprovalRequest: + """建立測試用 ApprovalRequest""" + return ApprovalRequest( + id=uuid.uuid4(), + action=action, + description="Test approval", + risk_level=RiskLevel.LOW, + required_signatures=1, + requested_by="test-system", + ) + + # ========================================================================= + # Action Pattern 提取測試 + # ========================================================================= + + def test_extract_action_pattern_restart(self, service): + """測試 restart 動作模式提取""" + pattern = service._extract_action_pattern( + "kubectl rollout restart deployment/test-app-abc123-def456 -n prod" + ) + + # 應該移除 pod hash suffix + assert "restart" in pattern + assert "abc123" not in pattern + + def test_extract_action_pattern_delete(self, service): + """測試 delete 動作模式提取""" + pattern = service._extract_action_pattern( + "kubectl delete pod test-pod-xyz789-abc123 -n staging" + ) + + assert "delete" in pattern + assert "xyz789" not in pattern + + def test_extract_action_pattern_empty(self, service): + """測試空動作""" + pattern = service._extract_action_pattern("") + + assert pattern == "unknown" + + def test_extract_action_pattern_short(self, service): + """測試太短的動作""" + pattern = service._extract_action_pattern("kubectl") + + assert pattern == "unknown" + + # ========================================================================= + # 執行結果處理測試 + # ========================================================================= + + @pytest.mark.asyncio + async def test_process_success_result(self, service, mock_trust_manager): + """處理成功執行結果""" + approval = self.create_approval() + result = ExecutionResult( + approval_id="apr-001", + incident_id="INC-001", + action=approval.action, + success=True, + duration_seconds=2.0, + ) + + record = await service.process_execution_result(approval, result) + + assert record.feedback_type == FeedbackType.EXECUTION_SUCCESS + mock_trust_manager.record_approval.assert_called_once() + + @pytest.mark.asyncio + async def test_process_failure_result(self, service, mock_trust_manager): + """處理失敗執行結果""" + approval = self.create_approval() + result = ExecutionResult( + approval_id="apr-001", + incident_id="INC-001", + action=approval.action, + success=False, + error_message="Pod not found", + ) + + record = await service.process_execution_result(approval, result) + + assert record.feedback_type == FeedbackType.EXECUTION_FAILURE + mock_trust_manager.record_rejection.assert_called_once() + + # ========================================================================= + # 人工反饋處理測試 + # ========================================================================= + + @pytest.mark.asyncio + async def test_process_human_approve(self, service, mock_trust_manager): + """處理人工批准反饋""" + feedback = FeedbackRequest( + incident_id="INC-001", + feedback_type=FeedbackType.HUMAN_APPROVE, + submitted_by="admin", + ) + + record = await service.process_human_feedback(feedback) + + assert record.feedback_type == FeedbackType.HUMAN_APPROVE + mock_trust_manager.record_approval.assert_called_once() + + @pytest.mark.asyncio + async def test_process_human_reject(self, service, mock_trust_manager): + """處理人工拒絕反饋""" + feedback = FeedbackRequest( + incident_id="INC-001", + feedback_type=FeedbackType.HUMAN_REJECT, + submitted_by="admin", + ) + + record = await service.process_human_feedback(feedback) + + assert record.feedback_type == FeedbackType.HUMAN_REJECT + mock_trust_manager.record_rejection.assert_called_once() + + @pytest.mark.asyncio + async def test_process_high_effectiveness_rating(self, service, mock_trust_manager): + """處理高有效性評分 (4-5 分)""" + feedback = FeedbackRequest( + incident_id="INC-001", + feedback_type=FeedbackType.EFFECTIVENESS_RATING, + effectiveness_score=5, + ) + + record = await service.process_human_feedback(feedback) + + assert record.feedback_type == FeedbackType.EFFECTIVENESS_RATING + mock_trust_manager.record_approval.assert_called_once() + + @pytest.mark.asyncio + async def test_process_low_effectiveness_rating(self, service, mock_trust_manager): + """處理低有效性評分 (1-2 分)""" + feedback = FeedbackRequest( + incident_id="INC-001", + feedback_type=FeedbackType.EFFECTIVENESS_RATING, + effectiveness_score=1, + ) + + record = await service.process_human_feedback(feedback) + + assert record.feedback_type == FeedbackType.EFFECTIVENESS_RATING + mock_trust_manager.record_rejection.assert_called_once() + + @pytest.mark.asyncio + async def test_process_medium_effectiveness_rating(self, service, mock_trust_manager): + """處理中等有效性評分 (3 分) - 不調整信任度""" + feedback = FeedbackRequest( + incident_id="INC-001", + feedback_type=FeedbackType.EFFECTIVENESS_RATING, + effectiveness_score=3, + ) + + record = await service.process_human_feedback(feedback) + + assert record.feedback_type == FeedbackType.EFFECTIVENESS_RATING + # 中等評分不應該調整信任度 + mock_trust_manager.record_approval.assert_not_called() + mock_trust_manager.record_rejection.assert_not_called() + + +class TestFeedbackType: + """FeedbackType Enum 測試""" + + def test_all_feedback_types_exist(self): + """確認所有反饋類型都存在""" + assert FeedbackType.EXECUTION_SUCCESS.value == "execution_success" + assert FeedbackType.EXECUTION_FAILURE.value == "execution_failure" + assert FeedbackType.HUMAN_APPROVE.value == "human_approve" + assert FeedbackType.HUMAN_REJECT.value == "human_reject" + assert FeedbackType.HUMAN_OVERRIDE.value == "human_override" + assert FeedbackType.EFFECTIVENESS_RATING.value == "effectiveness_rating" diff --git a/apps/api/tests/test_auto_repair_service.py b/apps/api/tests/test_auto_repair_service.py index cf01e9f5..d8e0a558 100644 --- a/apps/api/tests/test_auto_repair_service.py +++ b/apps/api/tests/test_auto_repair_service.py @@ -60,7 +60,7 @@ def create_test_incident( now = now_taipei() return Incident( incident_id=incident_id, - status=IncidentStatus.OPEN, + status=IncidentStatus.INVESTIGATING, severity=severity, affected_services=["test-service"], signals=[ diff --git a/apps/api/tests/test_terminal_service.py b/apps/api/tests/test_terminal_service.py new file mode 100644 index 00000000..f6f86911 --- /dev/null +++ b/apps/api/tests/test_terminal_service.py @@ -0,0 +1,226 @@ +""" +Phase 19.6: Terminal Service Tests +================================== +Omni-Terminal SSE 架構測試 + +測試內容: +1. 意圖分類 (classify_intent) +2. Service 依賴注入 +3. Model 驗證 + +@see ADR-031 Omni-Terminal SSE Architecture +@author Claude Code (首席架構師) +@version 1.0.0 +@date 2026-03-28 (台北時間) +""" + +import pytest + +from src.models.terminal import ( + SpatialContext, + TerminalIntentRequest, + TerminalSession, + TerminalSessionStatus, +) +from src.services.terminal_service import ( + IntentType, + TerminalService, + classify_intent, + get_terminal_service, +) + + +# ============================================================================= +# Intent Classification Tests +# ============================================================================= + +INTENT_CLASSIFICATION_TEST_CASES = [ + # 查詢類 - 狀態 + ("status", IntentType.QUERY_STATUS), + ("狀態", IntentType.QUERY_STATUS), + ("pod status", IntentType.QUERY_STATUS), + ("健康狀態", IntentType.QUERY_STATUS), + ("health check", IntentType.QUERY_STATUS), + + # 查詢類 - 指標 + ("metrics", IntentType.QUERY_METRICS), + ("指標", IntentType.QUERY_METRICS), + ("cpu usage", IntentType.QUERY_METRICS), + ("記憶體使用量", IntentType.QUERY_METRICS), + ("memory utilization", IntentType.QUERY_METRICS), + + # 查詢類 - 日誌 + ("logs", IntentType.QUERY_LOGS), + ("日誌", IntentType.QUERY_LOGS), + ("error logs", IntentType.QUERY_LOGS), + ("錯誤訊息", IntentType.QUERY_LOGS), + ("exception trace", IntentType.QUERY_LOGS), + + # 操作類 - 簽核 + ("approve", IntentType.ACTION_APPROVAL), + ("簽核", IntentType.ACTION_APPROVAL), + ("授權", IntentType.ACTION_APPROVAL), + ("批准這個操作", IntentType.ACTION_APPROVAL), + ("show pending approval", IntentType.ACTION_APPROVAL), + + # 操作類 - 重啟 + ("restart", IntentType.ACTION_RESTART), + ("重啟", IntentType.ACTION_RESTART), + ("rollout", IntentType.ACTION_RESTART), + ("重新部署", IntentType.ACTION_RESTART), + + # 操作類 - 擴容 + ("scale", IntentType.ACTION_SCALE), + ("擴容", IntentType.ACTION_SCALE), + ("增加副本", IntentType.ACTION_SCALE), + ("replica count", IntentType.ACTION_SCALE), + + # 分析類 - RCA + ("rca", IntentType.ANALYZE_RCA), + ("root cause", IntentType.ANALYZE_RCA), + ("根因", IntentType.ANALYZE_RCA), + ("分析問題", IntentType.ANALYZE_RCA), + ("為什麼 API 變慢", IntentType.ANALYZE_RCA), + ("why is it slow", IntentType.ANALYZE_RCA), + + # 分析類 - Incident + ("incident", IntentType.ANALYZE_INCIDENT), + ("事件", IntentType.ANALYZE_INCIDENT), + ("alert 告警", IntentType.ANALYZE_INCIDENT), + ("告警詳情", IntentType.ANALYZE_INCIDENT), + + # 一般 + ("hello", IntentType.GENERAL), + ("你好", IntentType.GENERAL), + ("help", IntentType.GENERAL), + ("random text", IntentType.GENERAL), +] + + +@pytest.mark.parametrize("intent,expected_type", INTENT_CLASSIFICATION_TEST_CASES) +def test_classify_intent(intent: str, expected_type: IntentType): + """測試意圖分類準確度""" + result = classify_intent(intent) + assert result == expected_type, f"Intent '{intent}' should be classified as {expected_type}, got {result}" + + +def test_classify_intent_case_insensitive(): + """測試意圖分類不區分大小寫""" + assert classify_intent("STATUS") == IntentType.QUERY_STATUS + assert classify_intent("Metrics") == IntentType.QUERY_METRICS + assert classify_intent("RESTART") == IntentType.ACTION_RESTART + + +# ============================================================================= +# Model Validation Tests +# ============================================================================= + + +def test_spatial_context_required_fields(): + """測試 SpatialContext 必填欄位""" + context = SpatialContext(current_page="/dashboard") + assert context.current_page == "/dashboard" + assert context.focused_entity_id is None # 預設為 None + + +def test_spatial_context_with_entity(): + """測試 SpatialContext 帶實體 ID""" + context = SpatialContext( + current_page="/incidents", + focused_entity_id="INC-001", + ) + assert context.current_page == "/incidents" + assert context.focused_entity_id == "INC-001" + + +def test_terminal_intent_request_validation(): + """測試 TerminalIntentRequest 驗證""" + request = TerminalIntentRequest( + intent="check system status", + context=SpatialContext(current_page="/dashboard"), + ) + assert request.intent == "check system status" + assert request.context.current_page == "/dashboard" + assert request.session_id is None # 預設為 None + + +def test_terminal_intent_request_with_session(): + """測試帶 session_id 的 TerminalIntentRequest""" + request = TerminalIntentRequest( + intent="continue analysis", + context=SpatialContext(current_page="/"), + session_id="sess-001", + ) + assert request.session_id == "sess-001" + + +def test_terminal_session_status_enum(): + """測試 TerminalSessionStatus 枚舉值""" + assert TerminalSessionStatus.PENDING.value == "pending" + assert TerminalSessionStatus.PROCESSING.value == "processing" + assert TerminalSessionStatus.COMPLETED.value == "completed" + assert TerminalSessionStatus.ERROR.value == "error" + assert TerminalSessionStatus.ABORTED.value == "aborted" + + +# ============================================================================= +# Dependency Injection Tests +# ============================================================================= + + +@pytest.mark.asyncio +async def test_get_terminal_service(): + """測試 FastAPI 依賴注入函數""" + service = await get_terminal_service() + assert isinstance(service, TerminalService) + + +@pytest.mark.asyncio +async def test_get_terminal_service_creates_new_instance(): + """測試每次呼叫建立新實例 (非 Singleton)""" + service1 = await get_terminal_service() + service2 = await get_terminal_service() + assert service1 is not service2, "Should create new instance each time" + + +# ============================================================================= +# Service Unit Tests (No External Dependencies) +# ============================================================================= + + +@pytest.mark.asyncio +async def test_terminal_service_instantiation(): + """測試 TerminalService 實例化""" + service = TerminalService() + assert service._sessions == {} + assert service._tasks == {} + + +@pytest.mark.asyncio +async def test_get_session_not_found(): + """測試取得不存在的 Session""" + service = TerminalService() + session = await service.get_session("nonexistent-session") + assert session is None + + +@pytest.mark.asyncio +async def test_abort_session_not_found(): + """測試中斷不存在的 Session""" + service = TerminalService() + result = await service.abort_session("nonexistent-session") + assert result is False + + +# ============================================================================= +# Intent Coverage Statistics +# ============================================================================= + + +def test_intent_type_coverage(): + """確保所有 IntentType 都有測試案例""" + tested_types = set(expected for _, expected in INTENT_CLASSIFICATION_TEST_CASES) + all_types = set(IntentType) + + missing = all_types - tested_types + assert not missing, f"Missing test cases for IntentType: {missing}" diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a6ddf320..f3e41617 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -168,6 +168,42 @@ "streaming": "Streaming", "paused": "Paused" }, + "omniTerminal": { + "title": "OMNI-TERMINAL", + "fullTitle": "AWOOOI // OMNI-TERMINAL", + "shortcut": "⌘J", + "open": "Open Terminal", + "close": "Close Terminal", + "inputPlaceholder": "Enter command...", + "inputPlaceholderFull": "Enter command or ask AI... (e.g., /approval list)", + "sseLive": "SSE Live", + "offline": "Offline", + "system": "[SYS]", + "agent": "[AI]", + "user": "$", + "unknownComponent": "Unknown Component", + "executing": "Executing", + "completed": "Completed", + "failed": "Failed" + }, + "nuclearKey": { + "authorize": "Authorize Execution", + "authorized": "Authorized", + "authorizing": "Authorizing...", + "holdToAuthorize": "Hold to authorize...", + "holdHintMobile": "Press and hold to authorize", + "holdHintDesktop": "Hold Y key or click and hold to authorize", + "keepHolding": "Keep holding to authorize...", + "highBlastRadius": "This action has a HIGH blast radius", + "executionAuthorized": "Execution Authorized & Completed", + "executionFailed": "Execution Failed", + "riskLevel": { + "low": "LOW", + "medium": "MEDIUM", + "high": "HIGH", + "critical": "CRITICAL" + } + }, "incident": { "title": "Incident Management", "activeIncidents": "Active Incidents", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 59cdf042..3842fb7d 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -168,6 +168,42 @@ "streaming": "串流中", "paused": "已暫停" }, + "omniTerminal": { + "title": "OMNI-TERMINAL", + "fullTitle": "AWOOOI // OMNI-TERMINAL", + "shortcut": "⌘J", + "open": "開啟終端機", + "close": "關閉終端機", + "inputPlaceholder": "輸入指令...", + "inputPlaceholderFull": "輸入指令或詢問 AI... (例如: /approval list)", + "sseLive": "SSE 即時連線", + "offline": "離線", + "system": "[SYS]", + "agent": "[AI]", + "user": "$", + "unknownComponent": "未知組件", + "executing": "執行中", + "completed": "已完成", + "failed": "失敗" + }, + "nuclearKey": { + "authorize": "授權執行", + "authorized": "已授權", + "authorizing": "授權中...", + "holdToAuthorize": "長按以授權...", + "holdHintMobile": "按住以授權", + "holdHintDesktop": "按住 Y 鍵或點擊長按以授權", + "keepHolding": "繼續按住以授權...", + "highBlastRadius": "此操作具有高影響範圍", + "executionAuthorized": "執行已授權並完成", + "executionFailed": "執行失敗", + "riskLevel": { + "low": "低風險", + "medium": "中風險", + "high": "高風險", + "critical": "危急" + } + }, "incident": { "title": "事件管理", "activeIncidents": "活躍事件", diff --git a/apps/web/package.json b/apps/web/package.json index e6c7ac1f..827bdd9e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -22,6 +22,7 @@ "react-dom": "^18.2.0", "recharts": "^3.8.0", "tailwind-merge": "^2.2.0", + "zod": "^3.22.0", "zustand": "^4.5.0" }, "devDependencies": { diff --git a/apps/web/src/components/ai/hitl-section.tsx b/apps/web/src/components/ai/hitl-section.tsx index c11ac56bb..54698eb6 100644 --- a/apps/web/src/components/ai/hitl-section.tsx +++ b/apps/web/src/components/ai/hitl-section.tsx @@ -15,6 +15,7 @@ import { useState, useCallback } from 'react' import { useTranslations } from 'next-intl' import { cn } from '@/lib/utils' +import { Z_INDEX } from '@/lib/constants/z-index' import { OpenClawPanel, type OpenClawStatus } from './openclaw-panel' import { ApprovalCard, type RiskLevel } from '@/components/approval/approval-card' import { @@ -432,7 +433,10 @@ export function HITLSection({ locale, className }: HITLSectionProps) { {/* Phase 3: Access Denied Modal (Nothing.tech Style) */} {accessDeniedModal?.show && ( -
+
{/* Icon */} diff --git a/apps/web/src/components/approval/conversational-view.tsx b/apps/web/src/components/approval/conversational-view.tsx index 1edbbf41..209da89d 100644 --- a/apps/web/src/components/approval/conversational-view.tsx +++ b/apps/web/src/components/approval/conversational-view.tsx @@ -23,6 +23,7 @@ import { useState, useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslations } from 'next-intl' import { cn } from '@/lib/utils' +import { Z_INDEX } from '@/lib/constants/z-index' import { useApprovalStore, usePendingApprovals, @@ -322,17 +323,20 @@ export function ConversationalView({ Tablet (768-1024px): 50% 寬度 (#54) Mobile (<768px): fixed 全屏覆蓋 (#55) */} -
+
{/* Phase 11.3: Mobile/Tablet 返回按鈕 (#55) */}
setShowShortcuts(false)} >
+
{t('rejectReason')} @@ -362,7 +366,10 @@ export function LiveApprovalPanel({ {/* Phase 3: Access Denied Modal (Nothing.tech Style) */} {accessDeniedModal?.show && ( -
+
{/* Icon */} diff --git a/apps/web/src/components/genui/GenUIRenderer.tsx b/apps/web/src/components/genui/GenUIRenderer.tsx index ffdf3ce4..fac24643 100644 --- a/apps/web/src/components/genui/GenUIRenderer.tsx +++ b/apps/web/src/components/genui/GenUIRenderer.tsx @@ -2,20 +2,22 @@ * GenUIRenderer - Dynamic Component Renderer * ========================================== * Phase 19.4a - GenUI 動態渲染器 + * Phase 19.O - 可觀測性整合 * * 根據 SSE 事件動態渲染已註冊的 GenUI 組件 * * @see ADR-032 GenUI Dynamic Rendering * @author Claude Code (首席架構師) - * @version 1.0.0 + * @version 1.1.0 - 加入 Telemetry * @date 2026-03-28 (台北時間) */ 'use client' -import React, { Suspense } from 'react' +import React, { Suspense, useEffect, useRef } from 'react' import { AlertTriangle, Loader2 } from 'lucide-react' import { getComponent, isRegistered, validateProps } from './registry' +import { trackGenUIRender } from '@/lib/telemetry' interface GenUIRendererProps { /** 組件名稱 */ @@ -40,8 +42,25 @@ export const GenUIRenderer: React.FC = ({ position = 'inline', id, }) => { + const renderStartTime = useRef(Date.now()) + + // 追蹤渲染完成 (Phase 19.O) + useEffect(() => { + const renderTime = Date.now() - renderStartTime.current + trackGenUIRender({ + componentName: component, + renderTime, + success: true, + }) + }, [component]) + // 檢查組件是否已註冊 if (!isRegistered(component)) { + trackGenUIRender({ + componentName: component, + success: false, + error: 'Component not registered', + }) return ( = ({ // 取得組件定義 const componentDef = getComponent(component) if (!componentDef) { + trackGenUIRender({ + componentName: component, + success: false, + error: 'Component definition not found', + }) return ( = ({ ) } - // 驗證 Props + // 驗證 Props (Phase 19 首席架構師審查 P1 - Zod 驗證) const validation = validateProps(component, props) if (!validation.valid) { console.warn(`[GenUI] Props validation failed for ${component}:`, validation.errors) + + // 追蹤驗證失敗 (含錯誤分類碼) + trackGenUIRender({ + componentName: component, + success: false, + error: validation.errors.join('; '), + errorCode: validation.errorCode, + }) // 不阻止渲染,只是警告 } diff --git a/apps/web/src/components/genui/NuclearKeyButton.tsx b/apps/web/src/components/genui/NuclearKeyButton.tsx index 1e32f74a..b13e6a18 100644 --- a/apps/web/src/components/genui/NuclearKeyButton.tsx +++ b/apps/web/src/components/genui/NuclearKeyButton.tsx @@ -22,10 +22,11 @@ 'use client' -import React, { useEffect, useState } from 'react' +import React, { useEffect, useState, useRef } from 'react' import { AlertTriangle, CheckCircle, Shield, ShieldAlert } from 'lucide-react' import { useTranslations } from 'next-intl' import { useHoldToConfirm } from '@/hooks/useHoldToConfirm' +import { trackNuclearKey } from '@/lib/telemetry' interface NuclearKeyButtonProps { /** 操作說明 */ @@ -40,6 +41,8 @@ interface NuclearKeyButtonProps { showShortcut?: boolean /** 自定義持續時間 (覆蓋風險等級) */ duration?: number + /** 授權 ID (用於追蹤,可選) */ + approvalId?: string } const RISK_CONFIG = { @@ -84,14 +87,28 @@ export const NuclearKeyButton: React.FC = ({ disabled = false, showShortcut = true, duration, + approvalId, }) => { const t = useTranslations('nuclearKey') const [showSuccess, setShowSuccess] = useState(false) + const holdStartTime = useRef(0) + const trackingId = approvalId || `nuclear-${Date.now()}` const config = RISK_CONFIG[riskLevel] const Icon = config.icon const handleConfirm = () => { + const holdDuration = Date.now() - holdStartTime.current + + // 追蹤授權完成 (Phase 19.O) + trackNuclearKey({ + approvalId: trackingId, + riskLevel, + action: 'completed', + holdDuration, + success: true, + }) + setShowSuccess(true) onConfirm() } @@ -109,6 +126,28 @@ export const NuclearKeyButton: React.FC = ({ enableKeyboard: !disabled, }) + // 追蹤按住開始/取消 (Phase 19.O) + useEffect(() => { + if (isHolding) { + holdStartTime.current = Date.now() + trackNuclearKey({ + approvalId: trackingId, + riskLevel, + action: 'started', + }) + } else if (holdStartTime.current > 0 && !isConfirmed) { + // 取消 (放開但未完成) + const holdDuration = Date.now() - holdStartTime.current + trackNuclearKey({ + approvalId: trackingId, + riskLevel, + action: 'cancelled', + holdDuration, + }) + holdStartTime.current = 0 + } + }, [isHolding, isConfirmed, trackingId, riskLevel]) + // 成功後 2 秒重置 useEffect(() => { if (showSuccess) { diff --git a/apps/web/src/components/genui/registry.ts b/apps/web/src/components/genui/registry.ts index 3ab60204..3fc59228 100644 --- a/apps/web/src/components/genui/registry.ts +++ b/apps/web/src/components/genui/registry.ts @@ -6,15 +6,93 @@ * ADR-032: Pre-compiled Registry Pattern * - 所有 GenUI 組件必須在此註冊 * - 支援 Lazy Loading (React.lazy) - * - Props 類型驗證 + * - Props 類型驗證 (Zod Schema) + * + * Phase 19 首席架構師審查 P1 改進: + * - 升級 Props Schema 為 Zod 驗證 + * - 支援格式驗證 (如百分比、時間單位) + * - 錯誤分類便於聚合分析 * * @see ADR-032 GenUI Dynamic Rendering * @author Claude Code (首席架構師) - * @version 1.0.0 + * @version 1.1.0 - Zod Schema 升級 * @date 2026-03-28 (台北時間) */ import { lazy, type ComponentType } from 'react' +import { z, type ZodSchema } from 'zod' + +// ============================================================================= +// Zod Schemas (Phase 19 首席架構師審查 P1) +// ============================================================================= + +/** ApprovalCard Props Schema */ +export const ApprovalCardSchema = z.object({ + approvalId: z.string().min(1), + riskLevel: z.enum(['low', 'medium', 'high', 'critical']), + kubectl: z.string().optional(), +}) + +/** MetricsSummaryCard Props Schema */ +export const MetricsSummaryCardSchema = z.object({ + rps: z.number().min(0), + errorRate: z.string().regex(/^\d+(\.\d+)?%$/, 'Must be percentage format (e.g., "0.05%")'), + p99Latency: z.string().regex(/^\d+(\.\d+)?(ms|s)$/, 'Must be time format (e.g., "450ms")'), + status: z.enum(['healthy', 'warning', 'critical']), +}) + +/** SentryErrorCard Props Schema */ +export const SentryErrorCardSchema = z.object({ + errorId: z.string().min(1), + title: z.string().min(1), + count: z.number().int().min(0), + lastSeen: z.string(), // ISO date string +}) + +/** IncidentTimelineCard Props Schema */ +export const IncidentTimelineCardSchema = z.object({ + incidentId: z.string().min(1), + events: z.array(z.object({ + timestamp: z.string(), + message: z.string(), + type: z.string().optional(), + })), + status: z.enum(['active', 'resolved', 'acknowledged']), +}) + +/** K8sPodStatusCard Props Schema */ +export const K8sPodStatusCardSchema = z.object({ + namespace: z.string().min(1), + pods: z.array(z.object({ + name: z.string(), + status: z.string(), + ready: z.boolean().optional(), + })), + summary: z.object({ + total: z.number().int().min(0), + running: z.number().int().min(0), + failed: z.number().int().min(0).optional(), + }), +}) + +/** TraceWaterfallCard Props Schema */ +export const TraceWaterfallCardSchema = z.object({ + traceId: z.string().min(1), + spans: z.array(z.object({ + spanId: z.string(), + name: z.string(), + duration: z.number().min(0), + startTime: z.number().optional(), + })), + duration: z.number().min(0), +}) + +/** NuclearKeyButton Props Schema */ +export const NuclearKeyButtonSchema = z.object({ + label: z.string().min(1), + riskLevel: z.enum(['low', 'medium', 'high', 'critical']), + approvalId: z.string().optional(), +}) // ============================================================================= // Registry Types @@ -35,7 +113,9 @@ export interface GenUIComponentDef { /** React 組件 (支援 lazy loading) */ // eslint-disable-next-line @typescript-eslint/no-explicit-any component: ComponentType - /** Props Schema (用於驗證) */ + /** Props Schema (Zod 驗證) */ + zodSchema?: ZodSchema + /** Legacy Props Schema (向後相容) */ propsSchema?: Record /** 是否允許在 Terminal 中渲染 */ allowInTerminal: boolean @@ -94,6 +174,7 @@ export const GENUI_REGISTRY: Record = { name: 'ApprovalCard', description: '核鑰授權卡 - 顯示待簽核操作', component: ApprovalCard, + zodSchema: ApprovalCardSchema, propsSchema: { approvalId: 'string', riskLevel: 'string', @@ -107,6 +188,7 @@ export const GENUI_REGISTRY: Record = { name: 'MetricsSummaryCard', description: '指標摘要卡 - 顯示 SignOz 即時指標', component: MetricsSummaryCard, + zodSchema: MetricsSummaryCardSchema, propsSchema: { rps: 'number', errorRate: 'string', @@ -121,6 +203,7 @@ export const GENUI_REGISTRY: Record = { name: 'SentryErrorCard', description: '錯誤追蹤卡 - 顯示 Sentry 錯誤詳情', component: SentryErrorCard, + zodSchema: SentryErrorCardSchema, propsSchema: { errorId: 'string', title: 'string', @@ -135,6 +218,7 @@ export const GENUI_REGISTRY: Record = { name: 'IncidentTimelineCard', description: '事件時間軸卡 - 顯示 Incident 歷程', component: IncidentTimelineCard, + zodSchema: IncidentTimelineCardSchema, propsSchema: { incidentId: 'string', events: 'array', @@ -148,6 +232,7 @@ export const GENUI_REGISTRY: Record = { name: 'K8sPodStatusCard', description: 'Pod 狀態卡 - 顯示 K8s Pod 健康狀態', component: K8sPodStatusCard, + zodSchema: K8sPodStatusCardSchema, propsSchema: { namespace: 'string', pods: 'array', @@ -161,6 +246,7 @@ export const GENUI_REGISTRY: Record = { name: 'TraceWaterfallCard', description: '追蹤瀑布圖卡 - 顯示 SignOz Trace 詳情', component: TraceWaterfallCard, + zodSchema: TraceWaterfallCardSchema, propsSchema: { traceId: 'string', spans: 'array', @@ -178,6 +264,7 @@ export const GENUI_REGISTRY: Record = { name: 'NuclearKeyButton', description: '核鑰授權按鈕 - 長按確認高風險操作', component: NuclearKeyButton, + zodSchema: NuclearKeyButtonSchema, propsSchema: { label: 'string', riskLevel: 'string', @@ -219,18 +306,52 @@ export function getTerminalComponents(): GenUIComponentDef[] { return Object.values(GENUI_REGISTRY).filter(c => c.allowInTerminal) } +/** 驗證結果類型 (Phase 19 首席架構師審查 P1) */ +export interface ValidationResult { + valid: boolean + errors: string[] + /** 錯誤分類碼 (便於 Sentry 聚合) */ + errorCode?: 'UNKNOWN_COMPONENT' | 'ZOD_VALIDATION_FAILED' | 'LEGACY_TYPE_MISMATCH' +} + /** - * 驗證 Props (基礎驗證) + * 驗證 Props (Zod + Legacy 雙模式) + * + * Phase 19 首席架構師審查 P1 改進: + * - 優先使用 Zod Schema 驗證 (更精確的格式檢查) + * - 回退到 Legacy propsSchema (向後相容) + * - 返回錯誤分類碼便於 Sentry 聚合 */ export function validateProps( componentName: string, props: Record -): { valid: boolean; errors: string[] } { +): ValidationResult { const def = GENUI_REGISTRY[componentName] if (!def) { - return { valid: false, errors: [`Unknown component: ${componentName}`] } + return { + valid: false, + errors: [`Unknown component: ${componentName}`], + errorCode: 'UNKNOWN_COMPONENT', + } } + // 優先使用 Zod Schema 驗證 (Phase 19 P1) + if (def.zodSchema) { + const result = def.zodSchema.safeParse(props) + if (!result.success) { + const errors = result.error.errors.map(e => + `${e.path.join('.')}: ${e.message}` + ) + return { + valid: false, + errors, + errorCode: 'ZOD_VALIDATION_FAILED', + } + } + return { valid: true, errors: [] } + } + + // Legacy propsSchema 回退驗證 if (!def.propsSchema) { return { valid: true, errors: [] } } @@ -252,5 +373,9 @@ export function validateProps( } } - return { valid: errors.length === 0, errors } + return { + valid: errors.length === 0, + errors, + errorCode: errors.length > 0 ? 'LEGACY_TYPE_MISMATCH' : undefined, + } } diff --git a/apps/web/src/components/layout/header.tsx b/apps/web/src/components/layout/header.tsx index 41a899ee..c2cae9eb 100644 --- a/apps/web/src/components/layout/header.tsx +++ b/apps/web/src/components/layout/header.tsx @@ -10,6 +10,9 @@ * - 連線狀態指示器 * - 語系切換器 * - 使用者選單 + * + * Phase 19: 使用 Z_INDEX.HEADER (30) + * @see lib/constants/z-index.ts */ import { useCallback } from 'react' @@ -19,6 +22,7 @@ import { StatusOrb } from '@/components/ui/status-orb' import { useConnectionStatus, useMockMode } from '@/stores/dashboard.store' import { useApprovalCount } from '@/stores/approval.store' import { cn } from '@/lib/utils' +import { Z_INDEX } from '@/lib/constants/z-index' // ============================================================================= // Types @@ -66,7 +70,7 @@ export function Header({ return (
{/* Left: Page Title / Breadcrumb */}
diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index b480b9fc..5f4c715b 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -15,9 +15,13 @@ * 3. 行動日誌 (/action-logs) * 4. 知識殿堂 (/knowledge-base) * 5. 系統設定 (/settings) + * + * Phase 19: 使用 Z_INDEX.SIDEBAR (40) + * @see lib/constants/z-index.ts */ import { useEffect, useState } from 'react' +import { Z_INDEX } from '@/lib/constants/z-index' import { useTranslations } from 'next-intl' import { usePathname } from 'next/navigation' import Link from 'next/link' @@ -109,7 +113,7 @@ export function Sidebar({ return (