-
-
-
{t('selectApproval')}
+ {/* Phase 11.3: Mobile/Tablet 返回按鈕 (#55) */}
+
+
+
+ {/* 分頁指示器 */}
+
+ {selectedIndex + 1} / {approvals.length}
+
+ {/* 快速導航按鈕 */}
+
+
+
+
- )}
+
+ {/* Y 鍵長按進度指示器 */}
+ {isYKeyHolding && (
+
+ )}
+
+ {selectedApproval ? (
+
+ ) : (
+
+
+
+
{t('selectApproval')}
+
+
+ )}
{/* 快捷鍵說明 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 (
+
+ )
+ }
+
+ // Empty state
+ if (!stats) {
+ return (
+
+ )
+ }
+
+ // 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 (
+
+ )
+}
+
+// =============================================================================
+// 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 (
+
+ )
+ }
+
+ // Empty state
+ if (!data) {
+ return (
+
+ )
+ }
+
+ // 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 */}
+
+
+ {/* 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 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 (
+
+ )
+ }
+
+ // Empty state
+ if (issues.length === 0) {
+ return (
+
+ )
+ }
+
+ 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 */}
+
+
+
+
+
+ )
+}
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)
+ }
+}))