diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f7925417a..a6ddf3202 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -33,6 +33,7 @@ "home": "Home", "dashboard": "Dashboard", "approvals": "Approvals", + "errors": "Error Tracking", "actions": "Action Log", "knowledge": "Knowledge Base", "settings": "Settings" @@ -267,6 +268,7 @@ "previousApproval": "Previous", "nextApproval": "Next", "holdToApproveHint": "Hold button to approve or reject", + "swipeHint": "Swipe left for details, swipe right to go back", "holdYToApprove": "Hold Y to approve (2s)", "pressNToReject": "Press N to reject", "justNow": "just now", @@ -428,5 +430,37 @@ "forceRestart": "FORCE MANUAL RESTART", "detectingAnomaly": "[ DETECTING ANOMALY ]", "autoHealingAttempt": "Initiating Auto-Healing Protocol (Attempt {attempt}/3)" + }, + "errors": { + "title": "Error Tracking", + "subtitle": "Sentry Error Tracking + OpenClaw AI Analysis", + "overview": "Error Overview", + "recentIssues": "Recent Issues", + "errorTrend": "Error Trend", + "noData": "No error data", + "noIssues": "No issues at the moment", + "noTrendData": "No trend data", + "unresolvedIssues": "Unresolved Issues", + "errors24h": "Errors (24h)", + "criticalErrors": "Critical Errors", + "totalIssues": "Total Issues", + "totalErrors": "Total Errors ({period})", + "projects": "Projects", + "aiAnalyze": "AI Analyze", + "aiAnalysis": "AI Analysis Result", + "analyzing": "Analyzing...", + "rootCause": "Root Cause", + "fixSummary": "Fix Recommendation", + "category": "Category", + "confidence": "Confidence", + "loading": "Loading...", + "refresh": "Refresh", + "sentryDashboard": "Sentry Dashboard", + "footerInfo": "Data from Sentry Self-Hosted (192.168.0.110:9000) | AI Analysis: OpenClaw | Auto-refresh: 60s", + "timeAgo": { + "minutes": "{count}m ago", + "hours": "{count}h ago", + "days": "{count}d ago" + } } } diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index e375aef8f..59cdf0428 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -33,6 +33,7 @@ "home": "首頁", "dashboard": "儀表板", "approvals": "授權中心", + "errors": "錯誤追蹤", "actions": "行動日誌", "knowledge": "知識殿堂", "settings": "設定" @@ -267,6 +268,7 @@ "previousApproval": "上一個項目", "nextApproval": "下一個項目", "holdToApproveHint": "長按按鈕以批准或拒絕", + "swipeHint": "向左滑動查看詳情,向右滑動返回列表", "holdYToApprove": "長按 Y 鍵核准 (2秒)", "pressNToReject": "按 N 鍵拒絕", "justNow": "剛剛", @@ -428,5 +430,37 @@ "forceRestart": "強制手動重啟", "detectingAnomaly": "[ 偵測異常中 ]", "autoHealingAttempt": "啟動自動修復協議 (嘗試 {attempt}/3)" + }, + "errors": { + "title": "錯誤追蹤", + "subtitle": "Sentry 錯誤追蹤 + OpenClaw AI 分析", + "overview": "錯誤概覽", + "recentIssues": "近期問題", + "errorTrend": "錯誤趨勢", + "noData": "無錯誤數據", + "noIssues": "目前沒有錯誤", + "noTrendData": "無趨勢數據", + "unresolvedIssues": "未解決問題", + "errors24h": "24 小時內錯誤", + "criticalErrors": "嚴重錯誤", + "totalIssues": "總問題數", + "totalErrors": "錯誤總數 ({period})", + "projects": "專案", + "aiAnalyze": "AI 分析", + "aiAnalysis": "AI 分析結果", + "analyzing": "分析中...", + "rootCause": "根因", + "fixSummary": "修復建議", + "category": "類別", + "confidence": "信心度", + "loading": "載入中...", + "refresh": "重新整理", + "sentryDashboard": "Sentry 儀表板", + "footerInfo": "資料來源: Sentry Self-Hosted (192.168.0.110:9000) | AI 分析: OpenClaw | 自動刷新: 60 秒", + "timeAgo": { + "minutes": "{count} 分鐘前", + "hours": "{count} 小時前", + "days": "{count} 天前" + } } } diff --git a/apps/web/src/app/[locale]/errors/page.tsx b/apps/web/src/app/[locale]/errors/page.tsx new file mode 100644 index 000000000..0751f73cb --- /dev/null +++ b/apps/web/src/app/[locale]/errors/page.tsx @@ -0,0 +1,153 @@ +'use client' + +/** + * Errors Page - #44 錯誤追蹤頁面 + * ============================== + * Phase 10: Sentry + OpenClaw + UI 整合 + * + * Nothing.tech 視覺規範: + * - 純白底色 (bg-white) + * - 極細淺灰邊框 (border border-gray-200) + * - 無圓角或微圓角 (rounded-sm) + * - 嚴禁陰影 (shadow-none) + * + * 佈局: + * - 左側: 統計卡片 + 趨勢圖 + * - 右側: 問題列表 + * + * i18n: 100% next-intl,零硬編碼 + * + * 建立: 2026-03-26 (台北時區) + * 建立者: Claude Code (#44 Error UI) + */ + +import { useTranslations } from 'next-intl' +import { AppLayout } from '@/components/layout' +import { useErrors } from '@/hooks/useErrors' +import { + ErrorOverviewCard, + RecentIssuesList, + ErrorTrendChart, +} from '@/components/errors' +import { Bug, RefreshCw, ExternalLink } from 'lucide-react' +import type { SentryIssue } from '@/lib/api-client' + +// ============================================================================= +// Page Component +// ============================================================================= + +export default function ErrorsPage({ + params, +}: { + params: { locale: string } +}) { + const t = useTranslations('errors') + const { + stats, + issues, + trends, + loading, + error, + activePeriod, + refetch, + setPeriod, + } = useErrors() + + const handleIssueClick = (issue: SentryIssue) => { + // Open in new tab if permalink available + if (issue.permalink) { + window.open(issue.permalink, '_blank', 'noopener,noreferrer') + } + } + + return ( + +
+ {/* Header */} +
+
+ +
+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+
+ + {/* Actions */} +
+ + + + + {t('sentryDashboard')} + +
+
+ + {/* Error State */} + {error && ( +
+

{error}

+
+ )} + + {/* Main Grid */} +
+ {/* Left Column - Stats & Trends */} +
+ {/* Overview Card */} + + + {/* Trend Chart */} + +
+ + {/* Right Column - Issues List */} +
+ +
+
+ + {/* Footer Info */} +
+

+ {t('footerInfo')} +

+
+
+
+ ) +} diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx index f15576cf3..f5e744c97 100644 --- a/apps/web/src/app/providers.tsx +++ b/apps/web/src/app/providers.tsx @@ -3,6 +3,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { useState } from 'react' import { ToastProvider, ToastInitializer } from '@/components/ui/toast' +import { OmniTerminal } from '@/components/terminal/OmniTerminal' export function Providers({ children }: { children: React.ReactNode }) { const [queryClient] = useState( @@ -22,6 +23,7 @@ export function Providers({ children }: { children: React.ReactNode }) { {children} + ) diff --git a/apps/web/src/components/approval/conversational-view.tsx b/apps/web/src/components/approval/conversational-view.tsx index 4beaea0d2..1edbbf41b 100644 --- a/apps/web/src/components/approval/conversational-view.tsx +++ b/apps/web/src/components/approval/conversational-view.tsx @@ -4,17 +4,23 @@ * ConversationalView - 對話式 AI 審核介面 * ======================================== * Phase 11: ChatGPT / GitHub Copilot 風格 + * Phase 11.3: 響應式佈局 (#54-55) * * Features: * - 左側: ApprovalThread 對話列表 * - 右側: ApprovalCard 詳情面板 * - 鍵盤快捷鍵: Y/N/方向鍵 - * - 響應式: Desktop 雙欄 / Mobile 堆疊 + * - 響應式: + * - Desktop (≥1024px): 雙欄並排 + * - Tablet (768-1024px): 滑動切換 (#54) + * - Mobile (<768px): 全螢幕詳情模式 (#55) * * i18n: 100% next-intl + * + * @modified 2026-03-26 v11.3.0 (TPE) - Phase 11.3 響應式佈局 */ -import { useState, useCallback, useEffect, useMemo } from 'react' +import { useState, useCallback, useEffect, useMemo, useRef } from 'react' import { useTranslations } from 'next-intl' import { cn } from '@/lib/utils' import { @@ -34,6 +40,7 @@ import { Keyboard, Wifi, WifiOff, + ArrowLeft, } from 'lucide-react' // ============================================================================= @@ -70,8 +77,15 @@ export function ConversationalView({ // 選中的 Approval const [selectedId, setSelectedId] = useState(null) const [showShortcuts, setShowShortcuts] = useState(false) - // Phase 11.3: Mobile 詳情面板狀態 - const [showMobileDetail, setShowMobileDetail] = useState(false) + + // Phase 11.3: 響應式面板狀態 (#54-55) + // Mobile/Tablet: 控制是否顯示詳情面板 + const [showDetailPanel, setShowDetailPanel] = useState(false) + + // Phase 11.3: 觸控滑動支援 + const containerRef = useRef(null) + const touchStartX = useRef(0) + const touchDeltaX = useRef(0) // 轉換為前端格式 const approvals = useMemo(() => { @@ -101,17 +115,44 @@ export function ConversationalView({ } else { setSelectedId(null) } - // Phase 11.3: 關閉 Mobile 詳情面板 - setShowMobileDetail(false) + // Phase 11.3: 關閉詳情面板 + setShowDetailPanel(false) } }, [approvals, selectedId]) - // Phase 11.3: Mobile 選擇處理 - const handleMobileSelect = useCallback((id: string) => { + // Phase 11.3: 選擇項目並顯示詳情面板 (#54-55) + const handleSelectApproval = useCallback((id: string) => { setSelectedId(id) - setShowMobileDetail(true) + setShowDetailPanel(true) }, []) + // Phase 11.3: 返回列表 (#55) + const handleBackToList = useCallback(() => { + setShowDetailPanel(false) + }, []) + + // Phase 11.3: 觸控滑動處理 (#54 Tablet) + const handleTouchStart = useCallback((e: React.TouchEvent) => { + touchStartX.current = e.touches[0].clientX + touchDeltaX.current = 0 + }, []) + + const handleTouchMove = useCallback((e: React.TouchEvent) => { + touchDeltaX.current = e.touches[0].clientX - touchStartX.current + }, []) + + const handleTouchEnd = useCallback(() => { + const threshold = 80 // 最小滑動距離 + if (touchDeltaX.current > threshold && showDetailPanel) { + // 向右滑動:返回列表 + setShowDetailPanel(false) + } else if (touchDeltaX.current < -threshold && !showDetailPanel && selectedId) { + // 向左滑動:顯示詳情 + setShowDetailPanel(true) + } + touchDeltaX.current = 0 + }, [showDetailPanel, selectedId]) + // 導航函數 const goToPrev = useCallback(() => { if (selectedIndex > 0) { @@ -155,15 +196,31 @@ export function ConversationalView({ }) return ( -
- {/* 左側: 對話列表 - 響應式寬度 */} - {/* Mobile: 全寬 | Tablet: w-64 | Desktop: w-80 */} +
+ {/* Phase 11.3: 響應式佈局容器 (#54-55) */} + {/* + Desktop (≥1024px): 正常 flex 雙欄,左側固定 w-80,右側 flex-1 + Tablet (768-1024px): 200% 寬度 + transform 滑動切換 + Mobile (<768px): 列表全寬 + 詳情 fixed overlay + */} + + {/* 左側: 對話列表 */}
{/* 列表 Header */}
@@ -252,63 +309,108 @@ export function ConversationalView({ key={approval.id} approval={approval} isSelected={approval.id === selectedId} - onSelect={() => handleMobileSelect(approval.id)} + onSelect={() => handleSelectApproval(approval.id)} /> )) )}
- {/* 右側: 詳情面板 - 響應式 */} - {/* Mobile: 全屏覆蓋 | Tablet/Desktop: 右側面板 */} + {/* 右側: 詳情面板 */} + {/* + Desktop (≥1024px): flex-1 填滿剩餘空間 + Tablet (768-1024px): 50% 寬度 (#54) + Mobile (<768px): fixed 全屏覆蓋 (#55) + */}
- {/* Mobile 返回按鈕 */} -
- -
- {selectedIndex + 1} / {approvals.length} -
-
- - {/* Y 鍵長按進度指示器 */} - {isYKeyHolding && ( -
-
-
- )} - - {selectedApproval ? ( -
- -
- ) : ( -
-
- -

{t('selectApproval')}

+ {/* Phase 11.3: Mobile/Tablet 返回按鈕 (#55) */} +
+ +
+ {/* 分頁指示器 */} +
+ {selectedIndex + 1} / {approvals.length} +
+ {/* 快速導航按鈕 */} +
+ + +
- )} + + {/* Y 鍵長按進度指示器 */} + {isYKeyHolding && ( +
+
+
+ )} + + {selectedApproval ? ( +
+ +
+ ) : ( +
+
+
+
+ )}
{/* 快捷鍵說明 Modal */} diff --git a/apps/web/src/components/errors/error-overview-card.tsx b/apps/web/src/components/errors/error-overview-card.tsx new file mode 100644 index 000000000..4053478c1 --- /dev/null +++ b/apps/web/src/components/errors/error-overview-card.tsx @@ -0,0 +1,182 @@ +'use client' + +/** + * ErrorOverviewCard - #41 錯誤統計卡片 + * ===================================== + * Phase 10: Sentry + OpenClaw + UI 整合 + * + * Nothing.tech 視覺規範: + * - 純白底色 (bg-white) + * - 極細淺灰邊框 (border border-gray-200) + * - 無圓角或微圓角 (rounded-sm) + * - 嚴禁陰影 (shadow-none) + * + * 建立: 2026-03-26 (台北時區) + * 建立者: Claude Code (#41 Error UI) + */ + +import { useTranslations } from 'next-intl' +import { cn } from '@/lib/utils' +import { AlertTriangle, Bug, Clock, TrendingUp, TrendingDown, Minus } from 'lucide-react' +import type { ErrorStatsResponse } from '@/lib/api-client' + +// ============================================================================= +// Component Props +// ============================================================================= + +interface ErrorOverviewCardProps { + stats: ErrorStatsResponse | null + loading?: boolean + error?: string | null + changePercent?: number + className?: string +} + +// ============================================================================= +// Component +// ============================================================================= + +export function ErrorOverviewCard({ + stats, + loading = false, + error = null, + changePercent = 0, + className, +}: ErrorOverviewCardProps) { + const t = useTranslations('errors') + + // Loading state + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ) + } + + // Error state + if (error) { + return ( +
+
+ + {error} +
+
+ ) + } + + // Empty state + if (!stats) { + return ( +
+
+ +

{t('noData')}

+
+
+ ) + } + + // Trend indicator + const TrendIcon = changePercent > 0 ? TrendingUp : changePercent < 0 ? TrendingDown : Minus + const trendColor = changePercent > 0 ? 'text-red-500' : changePercent < 0 ? 'text-green-500' : 'text-gray-400' + + return ( +
+ {/* Header */} +
+
+ +

{t('overview')}

+
+ {changePercent !== 0 && ( +
+ + {Math.abs(changePercent).toFixed(1)}% +
+ )} +
+ + {/* Stats Grid */} +
+ {/* Unresolved Issues */} +
+
+ {stats.unresolved_issues} +
+
+ {t('unresolvedIssues')} +
+
+ + {/* 24h Errors */} +
+
+ + + {stats.error_count_24h} + +
+
+ {t('errors24h')} +
+
+ + {/* Critical Count */} +
+
+ + + {stats.critical_count} + +
+
+ {t('criticalErrors')} +
+
+ + {/* Total Issues */} +
+
+ {stats.total_issues} +
+
+ {t('totalIssues')} +
+
+
+ + {/* Projects */} + {stats.projects.length > 0 && ( +
+
+ {t('projects')}: {stats.projects.join(', ')} +
+
+ )} +
+ ) +} diff --git a/apps/web/src/components/errors/error-trend-chart.tsx b/apps/web/src/components/errors/error-trend-chart.tsx new file mode 100644 index 000000000..e9f8af255 --- /dev/null +++ b/apps/web/src/components/errors/error-trend-chart.tsx @@ -0,0 +1,275 @@ +'use client' + +/** + * ErrorTrendChart - #43 錯誤趨勢圖表 + * =================================== + * Phase 10: Sentry + OpenClaw + UI 整合 + * + * Nothing.tech 視覺規範: + * - 純白底色 (bg-white) + * - 極細淺灰邊框 (border border-gray-200) + * - 無圓角或微圓角 (rounded-sm) + * - 嚴禁陰影 (shadow-none) + * + * 建立: 2026-03-26 (台北時區) + * 建立者: Claude Code (#43 Error UI) + */ + +import { useMemo } from 'react' +import { useTranslations } from 'next-intl' +import { cn } from '@/lib/utils' +import { TrendingUp, TrendingDown, Minus, Activity } from 'lucide-react' +import type { ErrorTrendResponse, ErrorTrendPoint } from '@/lib/api-client' + +// ============================================================================= +// Component Props +// ============================================================================= + +interface ErrorTrendChartProps { + data: ErrorTrendResponse | null + loading?: boolean + error?: string | null + onPeriodChange?: (period: '24h' | '7d' | '30d') => void + activePeriod?: '24h' | '7d' | '30d' + className?: string +} + +// ============================================================================= +// Sparkline Component +// ============================================================================= + +function Sparkline({ + data, + width = 200, + height = 60, +}: { + data: ErrorTrendPoint[] + width?: number + height?: number +}) { + const path = useMemo(() => { + if (data.length === 0) return '' + + const maxCount = Math.max(...data.map((d) => d.count), 1) + const minCount = Math.min(...data.map((d) => d.count), 0) + const range = maxCount - minCount || 1 + + const points = data.map((d, i) => { + const x = (i / (data.length - 1)) * width + const y = height - ((d.count - minCount) / range) * height + return `${x},${y}` + }) + + return `M ${points.join(' L ')}` + }, [data, width, height]) + + const areaPath = useMemo(() => { + if (data.length === 0) return '' + + const maxCount = Math.max(...data.map((d) => d.count), 1) + const minCount = Math.min(...data.map((d) => d.count), 0) + const range = maxCount - minCount || 1 + + const points = data.map((d, i) => { + const x = (i / (data.length - 1)) * width + const y = height - ((d.count - minCount) / range) * height + return `${x},${y}` + }) + + return `M 0,${height} L ${points.join(' L ')} L ${width},${height} Z` + }, [data, width, height]) + + if (data.length === 0) { + return ( + + + + ) + } + + return ( + + {/* Area fill */} + + {/* Line */} + + {/* Gradient definition */} + + + + + + + + ) +} + +// ============================================================================= +// Period Selector Component +// ============================================================================= + +function PeriodSelector({ + activePeriod, + onChange, +}: { + activePeriod: '24h' | '7d' | '30d' + onChange?: (period: '24h' | '7d' | '30d') => void +}) { + const periods: Array<{ value: '24h' | '7d' | '30d'; label: string }> = [ + { value: '24h', label: '24h' }, + { value: '7d', label: '7d' }, + { value: '30d', label: '30d' }, + ] + + return ( +
+ {periods.map(({ value, label }) => ( + + ))} +
+ ) +} + +// ============================================================================= +// Main Component +// ============================================================================= + +export function ErrorTrendChart({ + data, + loading = false, + error = null, + onPeriodChange, + activePeriod = '24h', + className, +}: ErrorTrendChartProps) { + const t = useTranslations('errors') + + // Loading state + if (loading) { + return ( +
+
+
+
+
+
+
+
+
+
+
+
+
+ ) + } + + // Error state + if (error) { + return ( +
+
+ + {error} +
+
+ ) + } + + // Empty state + if (!data) { + return ( +
+
+ +

{t('noTrendData')}

+
+
+ ) + } + + // Trend indicator + const TrendIcon = data.change_percent > 0 ? TrendingUp : data.change_percent < 0 ? TrendingDown : Minus + const trendColor = data.change_percent > 0 ? 'text-red-500' : data.change_percent < 0 ? 'text-green-500' : 'text-gray-400' + const trendBg = data.change_percent > 0 ? 'bg-red-50' : data.change_percent < 0 ? 'bg-green-50' : 'bg-gray-50' + + return ( +
+ {/* Header */} +
+
+ +

{t('errorTrend')}

+
+ +
+ + {/* Chart */} +
+ +
+ + {/* Footer */} +
+ {/* Total Count */} +
+
+ {data.total_count.toLocaleString()} +
+
+ {t('totalErrors', { period: data.period })} +
+
+ + {/* Change Percent */} +
+ + + {data.change_percent > 0 ? '+' : ''}{data.change_percent.toFixed(1)}% + +
+
+
+ ) +} diff --git a/apps/web/src/components/errors/index.ts b/apps/web/src/components/errors/index.ts new file mode 100644 index 000000000..02ddfd042 --- /dev/null +++ b/apps/web/src/components/errors/index.ts @@ -0,0 +1,17 @@ +/** + * Error Components Index + * ====================== + * Phase 10: Sentry + OpenClaw + UI 整合 + * + * Components: + * - ErrorOverviewCard (#41) + * - RecentIssuesList (#42) + * - ErrorTrendChart (#43) + * + * 建立: 2026-03-26 (台北時區) + * 建立者: Claude Code (#41-44 Error UI) + */ + +export { ErrorOverviewCard } from './error-overview-card' +export { RecentIssuesList } from './recent-issues-list' +export { ErrorTrendChart } from './error-trend-chart' diff --git a/apps/web/src/components/errors/recent-issues-list.tsx b/apps/web/src/components/errors/recent-issues-list.tsx new file mode 100644 index 000000000..f263b81a2 --- /dev/null +++ b/apps/web/src/components/errors/recent-issues-list.tsx @@ -0,0 +1,333 @@ +'use client' + +/** + * RecentIssuesList - #42 近期錯誤列表 + * ==================================== + * Phase 10: Sentry + OpenClaw + UI 整合 + * + * Nothing.tech 視覺規範: + * - 純白底色 (bg-white) + * - 極細淺灰邊框 (border border-gray-200) + * - 無圓角或微圓角 (rounded-sm) + * - 嚴禁陰影 (shadow-none) + * + * 建立: 2026-03-26 (台北時區) + * 建立者: Claude Code (#42 Error UI) + */ + +import { useState } from 'react' +import { useTranslations } from 'next-intl' +import { cn } from '@/lib/utils' +import { AlertTriangle, Bug, Clock, Users, ChevronRight, Sparkles, ExternalLink } from 'lucide-react' +import type { SentryIssue, ErrorAnalysisResponse } from '@/lib/api-client' +import { apiClient } from '@/lib/api-client' + +// ============================================================================= +// Level Config +// ============================================================================= + +const LEVEL_CONFIG = { + fatal: { + color: 'text-red-700', + bgColor: 'bg-red-50', + borderColor: 'border-red-200', + icon: AlertTriangle, + }, + error: { + color: 'text-orange-700', + bgColor: 'bg-orange-50', + borderColor: 'border-orange-200', + icon: Bug, + }, + warning: { + color: 'text-amber-700', + bgColor: 'bg-amber-50', + borderColor: 'border-amber-200', + icon: AlertTriangle, + }, + info: { + color: 'text-blue-700', + bgColor: 'bg-blue-50', + borderColor: 'border-blue-200', + icon: Bug, + }, +} as const + +// ============================================================================= +// Component Props +// ============================================================================= + +interface RecentIssuesListProps { + issues: SentryIssue[] + loading?: boolean + error?: string | null + onIssueClick?: (issue: SentryIssue) => void + className?: string +} + +// ============================================================================= +// Issue Item Component +// ============================================================================= + +function IssueItem({ + issue, + onClick, +}: { + issue: SentryIssue + onClick?: () => void +}) { + const t = useTranslations('errors') + const [analyzing, setAnalyzing] = useState(false) + const [analysis, setAnalysis] = useState(null) + + const levelConfig = LEVEL_CONFIG[issue.level] || LEVEL_CONFIG.error + const LevelIcon = levelConfig.icon + + // Format relative time (i18n compliant) + const lastSeen = new Date(issue.last_seen) + const now = new Date() + const diffMs = now.getTime() - lastSeen.getTime() + const diffMins = Math.floor(diffMs / 60000) + const diffHours = Math.floor(diffMs / 3600000) + const diffDays = Math.floor(diffMs / 86400000) + + let timeAgo: string + if (diffMins < 60) { + timeAgo = t('timeAgo.minutes', { count: diffMins }) + } else if (diffHours < 24) { + timeAgo = t('timeAgo.hours', { count: diffHours }) + } else { + timeAgo = t('timeAgo.days', { count: diffDays }) + } + + const handleAnalyze = async (e: React.MouseEvent) => { + e.stopPropagation() + setAnalyzing(true) + try { + const result = await apiClient.analyzeError(issue.id) + setAnalysis(result) + } catch (err) { + console.error('Error analyzing issue:', err) + } finally { + setAnalyzing(false) + } + } + + return ( +
+ {/* Main Row */} +
+ {/* Level Badge */} +
+ + {issue.level} +
+ + {/* Content */} +
+ {/* Title */} +
+ {issue.title} +
+ + {/* Culprit */} + {issue.culprit && ( +
+ {issue.culprit} +
+ )} + + {/* Meta */} +
+ + + {issue.count.toLocaleString()} + + + + {issue.user_count} + + + + {timeAgo} + + + {issue.short_id} + +
+
+ + {/* Actions */} +
+ {/* AI Analyze Button */} + + + {/* External Link */} + {issue.permalink && ( + e.stopPropagation()} + className="text-gray-400 hover:text-gray-600" + > + + + )} + + +
+
+ + {/* AI Analysis Result */} + {analysis && analysis.status === 'completed' && analysis.analysis && ( +
+
+ + {t('aiAnalysis')} + + {analysis.analysis.severity} + +
+ +
+ {t('rootCause')}: {analysis.analysis.root_cause} +
+ +
+ {t('fixSummary')}: {analysis.analysis.fix_recommendation.summary} +
+ +
+ {t('category')}: {analysis.analysis.category} | {t('confidence')}: {(analysis.analysis.confidence * 100).toFixed(0)}% +
+
+ )} +
+ ) +} + +// ============================================================================= +// Main Component +// ============================================================================= + +export function RecentIssuesList({ + issues, + loading = false, + error = null, + onIssueClick, + className, +}: RecentIssuesListProps) { + const t = useTranslations('errors') + + // Loading state + if (loading) { + return ( +
+
+
+
+
+ {[1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+
+
+ ))} +
+
+ ) + } + + // Error state + if (error) { + return ( +
+
+ + {error} +
+
+ ) + } + + // Empty state + if (issues.length === 0) { + return ( +
+
+ +

{t('noIssues')}

+
+
+ ) + } + + return ( +
+ {/* Header */} +
+
+ +

{t('recentIssues')}

+ ({issues.length}) +
+
+ + {/* Issues List */} +
+ {issues.map((issue) => ( + onIssueClick?.(issue)} + /> + ))} +
+
+ ) +} diff --git a/apps/web/src/components/genui/ApprovalCard.tsx b/apps/web/src/components/genui/ApprovalCard.tsx new file mode 100644 index 000000000..1b7907891 --- /dev/null +++ b/apps/web/src/components/genui/ApprovalCard.tsx @@ -0,0 +1,88 @@ +import React, { useState } from 'react' +import { CheckCircle2, ShieldAlert } from 'lucide-react' + +interface ApprovalCardProps { + data: { + approvalId: string + riskLevel: 'LOW' | 'MEDIUM' | 'CRITICAL' + kubectl: string + } +} + +export const ApprovalCard: React.FC = ({ data }) => { + const [isAuthorizing, setIsAuthorizing] = useState(false) + const [authorized, setAuthorized] = useState(false) + + const handleAuthorize = () => { + setIsAuthorizing(true) + // Simulate API call for authorization + setTimeout(() => { + setAuthorized(true) + setIsAuthorizing(false) + }, 1500) + } + + const isCritical = data.riskLevel === 'CRITICAL' + + return ( +
+
+
+ + + Approval Required // {data.approvalId} + +
+
+ {data.riskLevel} +
+
+ +
+
+ +
+ + {data.kubectl} + +
+
+ +
+ {!authorized ? ( + + ) : ( +
+ + [ EXECUTION AUTHORIZED ] +
+ )} +
+ + {isCritical && !authorized && ( +

+ WARNING: This action has a HIGH blast radius. Multi-Sig required. +

+ )} +
+
+ ) +} + +const Label = ({ children }: { children: React.ReactNode }) => ( + + {children} + +) diff --git a/apps/web/src/components/layout/sidebar.tsx b/apps/web/src/components/layout/sidebar.tsx index 5e544c4ea..b480b9fca 100644 --- a/apps/web/src/components/layout/sidebar.tsx +++ b/apps/web/src/components/layout/sidebar.tsx @@ -30,6 +30,7 @@ import { Settings, ChevronLeft, ChevronRight, + Bug, } from 'lucide-react' import { apiClient } from '@/lib/api-client' @@ -59,6 +60,7 @@ type NavItemConfig = { const NAV_ITEMS: NavItemConfig[] = [ { id: 'warroom', href: '/', labelKey: 'dashboard', Icon: LayoutDashboard }, { id: 'authorizations', href: '/authorizations', labelKey: 'approvals', Icon: ShieldCheck, badge: true }, + { id: 'errors', href: '/errors', labelKey: 'errors', Icon: Bug }, { id: 'action-logs', href: '/action-logs', labelKey: 'actions', Icon: Zap }, { id: 'knowledge-base', href: '/knowledge-base', labelKey: 'knowledge', Icon: BookOpen }, { id: 'settings', href: '/settings', labelKey: 'settings', Icon: Settings }, diff --git a/apps/web/src/components/terminal/OmniTerminal.tsx b/apps/web/src/components/terminal/OmniTerminal.tsx new file mode 100644 index 000000000..b57f2a841 --- /dev/null +++ b/apps/web/src/components/terminal/OmniTerminal.tsx @@ -0,0 +1,144 @@ +'use client' + +import React, { useEffect, useRef, useState } from 'react' +import { useTerminalStore, TerminalMessage } from '@/stores/terminal.store' +import { ApprovalCard } from '@/components/genui/ApprovalCard' + +export const OmniTerminal = () => { + const { + isOpen, + messages, + isConnected, + sendIntent, + toggleTerminal, + openTerminal + } = useTerminalStore() + + const [inputValue, setInputValue] = useState('') + const inputRef = useRef(null) + const scrollRef = useRef(null) + + // CMD+K / Shortcut listener + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if ((e.metaKey || e.ctrlKey) && e.key === 'k') { + e.preventDefault() + toggleTerminal() + } + // If pressing '/' and terminal is closed, open and focus + if (e.key === '/' && !isOpen && document.activeElement?.tagName !== 'INPUT') { + e.preventDefault() + openTerminal() + } + } + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [toggleTerminal, openTerminal, isOpen]) + + // Focus input when opened + useEffect(() => { + if (isOpen && inputRef.current) { + inputRef.current.focus() + } + }, [isOpen]) + + // Auto-scroll to bottom + useEffect(() => { + if (scrollRef.current) { + scrollRef.current.scrollTop = scrollRef.current.scrollHeight + } + }, [messages]) + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault() + if (!inputValue.trim()) return + + sendIntent(inputValue) + setInputValue('') + } + + if (!isOpen) { + return ( + + ) + } + + const renderMessageContent = (msg: TerminalMessage) => { + if (msg.type === 'tool_call' && msg.payload) { + // GenUI: Render specific React components based on tool call + if (msg.payload.tool === 'ApprovalCard') { + return + } + return
[GenUI Tool Render Failed: Unknown Tool {msg.payload.tool}]
+ } + + return ( + + {msg.content} + + ) + } + + return ( +
+
+ + {/* Terminal Header */} +
+
+ AWOOOI // OMNI-TERMINAL +
+ + {isConnected ? 'SSE Live' : 'Offline'} +
+
+ +
+ + {/* Messages / GenUI Canvas */} +
+ {messages.map((msg, i) => ( +
+
+ + {msg.role === 'system' ? '[SYS]' : msg.role === 'user' ? 'root@awoooi:~#' : '[AGENT]'} + +
+ {renderMessageContent(msg)} +
+
+
+ ))} +
+ + {/* Input Bar */} +
+
+ {'>'} + setInputValue(e.target.value)} + placeholder="輸入指令或詢問 AI... (例如: /approval list)" + className="flex-1 bg-transparent border-none outline-none font-['VT323'] text-2xl text-nothing-black placeholder-gray-400" + autoComplete="off" + spellCheck="false" + /> +
+
+
+
+ ) +} diff --git a/apps/web/src/hooks/index.ts b/apps/web/src/hooks/index.ts index f58141d8e..4c682a7d4 100644 --- a/apps/web/src/hooks/index.ts +++ b/apps/web/src/hooks/index.ts @@ -5,3 +5,4 @@ export * from './useApprovalSSE' export * from './useIncidents' export * from './useGlobalPulseMetrics' export * from './useKeyboardShortcuts' +export * from './useErrors' diff --git a/apps/web/src/hooks/useErrors.ts b/apps/web/src/hooks/useErrors.ts new file mode 100644 index 000000000..0ecb3f233 --- /dev/null +++ b/apps/web/src/hooks/useErrors.ts @@ -0,0 +1,111 @@ +/** + * useErrors - Sentry Error Data Hook + * =================================== + * Phase 10: Sentry + OpenClaw + UI 整合 (#41-44) + * + * 提供錯誤數據的 React Hook: + * - 統計概覽 + * - 問題列表 + * - 趨勢數據 + * + * 建立: 2026-03-26 (台北時區) + * 建立者: Claude Code (#44 Error UI) + */ + +import { useState, useEffect, useCallback } from 'react' +import { + apiClient, + type ErrorStatsResponse, + type ErrorListResponse, + type ErrorTrendResponse, + type SentryIssue, +} from '@/lib/api-client' + +// ============================================================================= +// Types +// ============================================================================= + +interface UseErrorsState { + stats: ErrorStatsResponse | null + issues: SentryIssue[] + trends: ErrorTrendResponse | null + loading: boolean + error: string | null + activePeriod: '24h' | '7d' | '30d' +} + +interface UseErrorsReturn extends UseErrorsState { + refetch: () => Promise + setPeriod: (period: '24h' | '7d' | '30d') => void +} + +// ============================================================================= +// Hook +// ============================================================================= + +export function useErrors(): UseErrorsReturn { + const [state, setState] = useState({ + stats: null, + issues: [], + trends: null, + loading: true, + error: null, + activePeriod: '24h', + }) + + const fetchData = useCallback(async (period: '24h' | '7d' | '30d' = '24h') => { + setState((prev) => ({ ...prev, loading: true, error: null })) + + try { + const [statsResult, issuesResult, trendsResult] = await Promise.allSettled([ + apiClient.getErrorStats(), + apiClient.listErrors({ limit: 10 }), + apiClient.getErrorTrends(period), + ]) + + setState((prev) => ({ + ...prev, + stats: statsResult.status === 'fulfilled' ? statsResult.value : null, + issues: issuesResult.status === 'fulfilled' ? issuesResult.value.issues : [], + trends: trendsResult.status === 'fulfilled' ? trendsResult.value : null, + loading: false, + error: null, + activePeriod: period, + })) + } catch (err) { + setState((prev) => ({ + ...prev, + loading: false, + error: err instanceof Error ? err.message : 'Failed to fetch error data', + })) + } + }, []) + + const setPeriod = useCallback((period: '24h' | '7d' | '30d') => { + fetchData(period) + }, [fetchData]) + + const refetch = useCallback(() => { + return fetchData(state.activePeriod) + }, [fetchData, state.activePeriod]) + + // Initial fetch + useEffect(() => { + fetchData('24h') + }, [fetchData]) + + // Auto-refresh every 60 seconds + useEffect(() => { + const interval = setInterval(() => { + fetchData(state.activePeriod) + }, 60000) + + return () => clearInterval(interval) + }, [fetchData, state.activePeriod]) + + return { + ...state, + refetch, + setPeriod, + } +} diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 5305b41bf..4c388a2b9 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -180,6 +180,42 @@ export const apiClient = { const res = await fetch(`${API_BASE_URL}/approvals/pending`) return handleResponse(res) }, + + // ========================================================================= + // Phase 10: Sentry Errors API (#40 BFF) + // ========================================================================= + + async getErrorStats() { + const res = await fetch(`${API_BASE_URL}/errors/stats`) + return handleResponse(res) + }, + + async listErrors(params?: { status?: string; level?: string; limit?: number }) { + const searchParams = new URLSearchParams() + if (params?.status) searchParams.set('status', params.status) + if (params?.level) searchParams.set('level', params.level) + if (params?.limit) searchParams.set('limit', params.limit.toString()) + const query = searchParams.toString() ? `?${searchParams.toString()}` : '' + const res = await fetch(`${API_BASE_URL}/errors/issues${query}`) + return handleResponse(res) + }, + + async getErrorDetail(issueId: string) { + const res = await fetch(`${API_BASE_URL}/errors/issues/${issueId}`) + return handleResponse(res) + }, + + async getErrorTrends(period: '24h' | '7d' | '30d' = '24h') { + const res = await fetch(`${API_BASE_URL}/errors/trends?period=${period}`) + return handleResponse(res) + }, + + async analyzeError(issueId: string) { + const res = await fetch(`${API_BASE_URL}/errors/issues/${issueId}/analyze`, { + method: 'POST', + }) + return handleResponse(res) + }, } // ========================================================================= @@ -264,3 +300,86 @@ export interface ProposalGenerateResponse { proposal: ApprovalResponse | null incident_status: string | null } + +// ========================================================================= +// Phase 10: Sentry Error Types (#40 BFF) +// ========================================================================= + +export interface SentryIssue { + id: string + short_id: string + title: string + culprit: string | null + level: 'error' | 'warning' | 'info' | 'fatal' + status: 'unresolved' | 'resolved' | 'ignored' + count: number + user_count: number + first_seen: string + last_seen: string + permalink: string | null +} + +export interface ErrorStatsResponse { + total_issues: number + unresolved_issues: number + error_count_24h: number + critical_count: number + projects: string[] +} + +export interface ErrorListResponse { + issues: SentryIssue[] + total: number + has_more: boolean +} + +export interface ErrorDetailResponse { + issue: Record + latest_event: Record | null + sentry_url: string +} + +export interface ErrorTrendPoint { + timestamp: string + count: number +} + +export interface ErrorTrendResponse { + period: '24h' | '7d' | '30d' + data: ErrorTrendPoint[] + total_count: number + change_percent: number +} + +export interface FixRecommendation { + summary: string + steps: string[] + code_suggestion: string | null +} + +export interface PreventionMeasure { + type: string + description: string +} + +export interface ErrorAnalysis { + root_cause: string + category: string + severity: 'LOW' | 'MEDIUM' | 'HIGH' | 'CRITICAL' + impact_assessment: string + fix_recommendation: FixRecommendation + prevention: PreventionMeasure[] + related_files: string[] + confidence: number + reasoning: string +} + +export interface ErrorAnalysisResponse { + status: 'completed' | 'failed' + issue_id: string + provider: string + analysis?: ErrorAnalysis + analyzed_at?: string + sentry_url: string + message?: string +} diff --git a/apps/web/src/stores/terminal.store.ts b/apps/web/src/stores/terminal.store.ts new file mode 100644 index 000000000..72510bcf0 --- /dev/null +++ b/apps/web/src/stores/terminal.store.ts @@ -0,0 +1,135 @@ +import { create } from 'zustand' + +export type MessageRole = 'system' | 'user' | 'assistant' +export type MessageType = 'text' | 'tool_call' + +export interface TerminalMessage { + id: string + role: MessageRole + type: MessageType + content: string + payload?: Record + timestamp: Date +} + +interface TerminalState { + isOpen: boolean + isConnecting: boolean + isConnected: boolean + messages: TerminalMessage[] + inputBuffer: string + + // Actions + toggleTerminal: () => void + openTerminal: () => void + closeTerminal: () => void + + // Connection + setConnectionStatus: (status: 'connecting' | 'connected' | 'disconnected') => void + + // Messaging + appendMessage: (message: Omit) => void + updateLastMessage: (contentChunk: string) => void + clearMessages: () => void + + // Intent + sendIntent: (text: string) => void +} + +export const useTerminalStore = create((set, get) => ({ + isOpen: false, + isConnecting: false, + isConnected: false, + messages: [ + { + id: 'sys-load', + role: 'system', + type: 'text', + content: 'AWOOOI OS [Version 1.0.0]\n(c) WOOO Corporation. All rights reserved.', + timestamp: new Date(), + }, + { + id: 'sys-ready', + role: 'system', + type: 'text', + content: 'System ready. Waiting for input...', + timestamp: new Date(), + } + ], + inputBuffer: '', + + toggleTerminal: () => set((state) => ({ isOpen: !state.isOpen })), + openTerminal: () => set({ isOpen: true }), + closeTerminal: () => set({ isOpen: false }), + + setConnectionStatus: (status) => set({ + isConnecting: status === 'connecting', + isConnected: status === 'connected' + }), + + appendMessage: (msg) => set((state) => ({ + messages: [ + ...state.messages, + { + ...msg, + id: Math.random().toString(36).substring(2, 9), + timestamp: new Date(), + } + ] + })), + + updateLastMessage: (contentChunk) => set((state) => { + const messages = [...state.messages] + if (messages.length === 0) return state + + const lastMsg = messages[messages.length - 1] + if (lastMsg.role === 'assistant' && lastMsg.type === 'text') { + lastMsg.content += contentChunk + return { messages } + } + return state + }), + + clearMessages: () => set({ messages: [] }), + + sendIntent: (text) => { + if (!text.trim()) return + + // Optimistic UI update + get().appendMessage({ + role: 'user', + type: 'text', + content: text, + }) + + // Here we would implement the real fetch/SSE logic to the backend + // For now, simulate backend response for GenUI dev + setTimeout(() => { + // Demo: Simulated AI thinking + get().appendMessage({ + role: 'assistant', + type: 'text', + content: '[Agent] Analyzing intent: ' + text + '\n', + }) + + // Demo: Simulated Tool Call (GenUI trigger) + if (text.toLowerCase().includes('approve') || text.toLowerCase().includes('簽核')) { + setTimeout(() => { + get().appendMessage({ + role: 'assistant', + type: 'tool_call', + content: 'Rendering approval card...', + payload: { + tool: 'ApprovalCard', + args: { + approvalId: 'INC-SIMULATED-01', + riskLevel: 'CRITICAL', + kubectl: 'kubectl delete pod awoooi-faulty-node', + } + } + }) + }, 800) + } + }, 500) + } +}))