feat(web): Error Dashboard + GenUI + OmniTerminal 組件

新增功能:
- errors/: Sentry 錯誤儀表板 (overview-card, trend-chart, issues-list)
- genui/ApprovalCard: GenUI 風格簽核卡片
- terminal/OmniTerminal: AI 終端機組件
- useErrors.ts: 錯誤數據 hooks
- terminal.store.ts: 終端機狀態管理

更新:
- conversational-view.tsx: 改進對話式 UI
- providers.tsx: 新增 provider
- sidebar.tsx: 新增 Errors 導航
- api-client.ts: 錯誤 API 整合
- i18n messages: 新增錯誤相關翻譯

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-03-26 19:09:36 +08:00
parent 2f5986df5c
commit 5172c4c925
16 changed files with 1794 additions and 62 deletions

View File

@@ -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"
}
}
}

View File

@@ -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} 天前"
}
}
}

View File

@@ -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 (
<AppLayout locale={params.locale}>
<div className="p-6 max-w-7xl mx-auto">
{/* Header */}
<div className="flex items-center justify-between mb-6">
<div className="flex items-center gap-3">
<Bug className="h-6 w-6 text-gray-700" />
<div>
<h1 className="text-xl font-semibold text-gray-900">
{t('title')}
</h1>
<p className="text-sm text-gray-500">
{t('subtitle')}
</p>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-3">
<button
onClick={refetch}
disabled={loading}
className="flex items-center gap-1.5 px-3 py-1.5 text-sm
bg-gray-100 hover:bg-gray-200 rounded transition-colors
disabled:opacity-50 disabled:cursor-not-allowed"
>
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} />
{loading ? t('loading') : t('refresh')}
</button>
<a
href="http://192.168.0.110:9000"
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 text-sm
bg-purple-100 text-purple-700 hover:bg-purple-200
rounded transition-colors"
>
<ExternalLink className="h-4 w-4" />
{t('sentryDashboard')}
</a>
</div>
</div>
{/* Error State */}
{error && (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-sm">
<p className="text-sm text-red-700">{error}</p>
</div>
)}
{/* Main Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column - Stats & Trends */}
<div className="lg:col-span-1 space-y-6">
{/* Overview Card */}
<ErrorOverviewCard
stats={stats}
loading={loading}
changePercent={trends?.change_percent}
/>
{/* Trend Chart */}
<ErrorTrendChart
data={trends}
loading={loading}
activePeriod={activePeriod}
onPeriodChange={setPeriod}
/>
</div>
{/* Right Column - Issues List */}
<div className="lg:col-span-2">
<RecentIssuesList
issues={issues}
loading={loading}
onIssueClick={handleIssueClick}
/>
</div>
</div>
{/* Footer Info */}
<div className="mt-6 pt-4 border-t border-gray-100">
<p className="text-xs text-gray-400 text-center">
{t('footerInfo')}
</p>
</div>
</div>
</AppLayout>
)
}

View File

@@ -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 }) {
<ToastProvider>
<ToastInitializer />
{children}
<OmniTerminal />
</ToastProvider>
</QueryClientProvider>
)

View File

@@ -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<string | null>(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<HTMLDivElement>(null)
const touchStartX = useRef<number>(0)
const touchDeltaX = useRef<number>(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 (
<div className={cn('flex h-full bg-nothing-gray-50', className)}>
{/* 左側: 對話列表 - 響應式寬度 */}
{/* Mobile: 全寬 | Tablet: w-64 | Desktop: w-80 */}
<div
ref={containerRef}
className={cn('flex h-full bg-nothing-gray-50 overflow-hidden', className)}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
>
{/* Phase 11.3: 響應式佈局容器 (#54-55) */}
{/*
Desktop (≥1024px): 正常 flex 雙欄,左側固定 w-80右側 flex-1
Tablet (768-1024px): 200% 寬度 + transform 滑動切換
Mobile (<768px): 列表全寬 + 詳情 fixed overlay
*/}
{/* 左側: 對話列表 */}
<div className={cn(
'flex-shrink-0 border-r border-nothing-gray-200 bg-white flex flex-col',
// Phase 11.3: 響應式寬度
'w-full md:w-64 lg:w-80',
// Desktop (≥1024px): 固定寬度,永遠顯示
'lg:w-80',
// Tablet (768-1024px): 50% 寬度用於滑動 (#54)
'md:w-1/2',
// Mobile (<768px): 全寬,詳情時隱藏 (#55)
'w-full',
// Mobile: 詳情面板打開時隱藏列表
showMobileDetail && 'hidden md:flex'
showDetailPanel && 'hidden md:flex'
)}>
{/* 列表 Header */}
<div className="flex-shrink-0 p-4 border-b border-nothing-gray-100">
@@ -252,63 +309,108 @@ export function ConversationalView({
key={approval.id}
approval={approval}
isSelected={approval.id === selectedId}
onSelect={() => handleMobileSelect(approval.id)}
onSelect={() => handleSelectApproval(approval.id)}
/>
))
)}
</div>
</div>
{/* 右側: 詳情面板 - 響應式 */}
{/* Mobile: 全屏覆蓋 | Tablet/Desktop: 右側面板 */}
{/* 右側: 詳情面板 */}
{/*
Desktop (≥1024px): flex-1 填滿剩餘空間
Tablet (768-1024px): 50% 寬度 (#54)
Mobile (<768px): fixed 全屏覆蓋 (#55)
*/}
<div className={cn(
'flex flex-col overflow-hidden relative',
// Phase 11.3: Mobile 全屏覆蓋模式
showMobileDetail
? 'fixed inset-0 z-40 bg-nothing-gray-50 md:relative md:inset-auto md:z-auto md:flex-1'
: 'hidden md:flex md:flex-1'
'flex flex-col overflow-hidden relative bg-nothing-gray-50',
// Desktop (≥1024px): 填滿剩餘空間,永遠顯示
'lg:flex-1 lg:relative lg:inset-auto lg:z-auto',
// Tablet (768-1024px): 50% 寬度 (#54)
'md:flex md:w-1/2 md:relative md:inset-auto md:z-auto',
// Mobile (<768px): 全屏覆蓋模式 (#55)
showDetailPanel
? 'fixed inset-0 z-40 w-full'
: 'hidden'
)}>
{/* Mobile 返回按鈕 */}
<div className="flex-shrink-0 flex items-center justify-between p-4 border-b border-nothing-gray-200 bg-white md:hidden">
<button
onClick={() => setShowMobileDetail(false)}
className="flex items-center gap-2 text-claw-blue hover:text-claw-blue/80 transition-colors"
>
<ChevronLeft className="w-5 h-5" />
<span className="text-sm font-medium">{t('backToList')}</span>
</button>
<div className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-mono bg-nothing-gray-100">
<span>{selectedIndex + 1} / {approvals.length}</span>
</div>
</div>
{/* Y 鍵長按進度指示器 */}
{isYKeyHolding && (
<div className="absolute top-0 left-0 right-0 h-1 bg-nothing-gray-200 z-10 md:top-0">
<div
className="h-full bg-status-healthy transition-all duration-100"
style={{ width: `${yKeyProgress}%` }}
/>
</div>
)}
{selectedApproval ? (
<div className="flex-1 overflow-y-auto p-4 md:p-6">
<ApprovalCard
request={selectedApproval}
onApprove={handleApprove}
onReject={handleReject}
holdDuration={2000}
/>
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-nothing-gray-400">
<MessageSquare className="w-12 h-12 mx-auto mb-4 opacity-50" />
<p className="text-sm font-mono">{t('selectApproval')}</p>
{/* Phase 11.3: Mobile/Tablet 返回按鈕 (#55) */}
<div className={cn(
'flex-shrink-0 flex items-center justify-between p-4 border-b border-nothing-gray-200 bg-white',
// Desktop: 隱藏返回按鈕
'lg:hidden'
)}>
<button
onClick={handleBackToList}
className="flex items-center gap-2 text-claw-blue hover:text-claw-blue/80 transition-colors focus:outline-none focus:ring-2 focus:ring-claw-blue/50 rounded-lg px-2 py-1 -ml-2"
aria-label={t('backToList')}
>
<ArrowLeft className="w-5 h-5" aria-hidden="true" />
<span className="text-sm font-medium">{t('backToList')}</span>
</button>
<div className="flex items-center gap-2">
{/* 分頁指示器 */}
<div className="flex items-center gap-1 px-2 py-1 rounded-lg text-xs font-mono bg-nothing-gray-100">
<span>{selectedIndex + 1} / {approvals.length}</span>
</div>
{/* 快速導航按鈕 */}
<div className="flex items-center gap-1">
<button
onClick={goToPrev}
disabled={selectedIndex <= 0}
className={cn(
'p-1.5 rounded-lg transition-colors',
selectedIndex <= 0
? 'text-nothing-gray-300 cursor-not-allowed'
: 'text-nothing-gray-600 hover:bg-nothing-gray-100 active:bg-nothing-gray-200'
)}
aria-label={t('previousApproval')}
>
<ChevronLeft className="w-4 h-4" aria-hidden="true" />
</button>
<button
onClick={goToNext}
disabled={selectedIndex >= approvals.length - 1}
className={cn(
'p-1.5 rounded-lg transition-colors',
selectedIndex >= approvals.length - 1
? 'text-nothing-gray-300 cursor-not-allowed'
: 'text-nothing-gray-600 hover:bg-nothing-gray-100 active:bg-nothing-gray-200'
)}
aria-label={t('nextApproval')}
>
<ChevronRight className="w-4 h-4" aria-hidden="true" />
</button>
</div>
</div>
</div>
)}
{/* Y 鍵長按進度指示器 */}
{isYKeyHolding && (
<div className="absolute top-0 left-0 right-0 h-1 bg-nothing-gray-200 z-10 lg:top-0">
<div
className="h-full bg-status-healthy transition-all duration-100"
style={{ width: `${yKeyProgress}%` }}
/>
</div>
)}
{selectedApproval ? (
<div className="flex-1 overflow-y-auto p-4 md:p-6">
<ApprovalCard
request={selectedApproval}
onApprove={handleApprove}
onReject={handleReject}
holdDuration={2000}
/>
</div>
) : (
<div className="flex-1 flex items-center justify-center">
<div className="text-center text-nothing-gray-400">
<MessageSquare className="w-12 h-12 mx-auto mb-4 opacity-50" aria-hidden="true" />
<p className="text-sm font-mono">{t('selectApproval')}</p>
</div>
</div>
)}
</div>
{/* 快捷鍵說明 Modal */}

View File

@@ -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 (
<div className={cn(
'bg-white border border-gray-200 rounded-sm p-4',
className
)}>
<div className="animate-pulse space-y-3">
<div className="h-4 bg-gray-200 rounded w-1/3" />
<div className="grid grid-cols-2 gap-4">
<div className="h-12 bg-gray-100 rounded" />
<div className="h-12 bg-gray-100 rounded" />
</div>
<div className="grid grid-cols-2 gap-4">
<div className="h-12 bg-gray-100 rounded" />
<div className="h-12 bg-gray-100 rounded" />
</div>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className={cn(
'bg-white border border-red-200 rounded-sm p-4',
className
)}>
<div className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-4 w-4" />
<span className="text-sm">{error}</span>
</div>
</div>
)
}
// Empty state
if (!stats) {
return (
<div className={cn(
'bg-white border border-gray-200 rounded-sm p-4',
className
)}>
<div className="text-center text-gray-500 py-4">
<Bug className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t('noData')}</p>
</div>
</div>
)
}
// 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 (
<div className={cn(
'bg-white border border-gray-200 rounded-sm p-4',
className
)}>
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<Bug className="h-4 w-4 text-gray-600" />
<h3 className="text-sm font-medium text-gray-900">{t('overview')}</h3>
</div>
{changePercent !== 0 && (
<div className={cn('flex items-center gap-1 text-xs', trendColor)}>
<TrendIcon className="h-3 w-3" />
<span>{Math.abs(changePercent).toFixed(1)}%</span>
</div>
)}
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-3">
{/* Unresolved Issues */}
<div className="bg-red-50 border border-red-100 rounded-sm p-3">
<div className="text-2xl font-semibold text-red-700">
{stats.unresolved_issues}
</div>
<div className="text-xs text-red-600 mt-1">
{t('unresolvedIssues')}
</div>
</div>
{/* 24h Errors */}
<div className="bg-orange-50 border border-orange-100 rounded-sm p-3">
<div className="flex items-center gap-1">
<Clock className="h-3 w-3 text-orange-500" />
<span className="text-2xl font-semibold text-orange-700">
{stats.error_count_24h}
</span>
</div>
<div className="text-xs text-orange-600 mt-1">
{t('errors24h')}
</div>
</div>
{/* Critical Count */}
<div className="bg-purple-50 border border-purple-100 rounded-sm p-3">
<div className="flex items-center gap-1">
<AlertTriangle className="h-3 w-3 text-purple-500" />
<span className="text-2xl font-semibold text-purple-700">
{stats.critical_count}
</span>
</div>
<div className="text-xs text-purple-600 mt-1">
{t('criticalErrors')}
</div>
</div>
{/* Total Issues */}
<div className="bg-gray-50 border border-gray-100 rounded-sm p-3">
<div className="text-2xl font-semibold text-gray-700">
{stats.total_issues}
</div>
<div className="text-xs text-gray-600 mt-1">
{t('totalIssues')}
</div>
</div>
</div>
{/* Projects */}
{stats.projects.length > 0 && (
<div className="mt-3 pt-3 border-t border-gray-100">
<div className="text-xs text-gray-500">
{t('projects')}: {stats.projects.join(', ')}
</div>
</div>
)}
</div>
)
}

View File

@@ -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 (
<svg width={width} height={height} className="text-gray-300">
<line x1="0" y1={height / 2} x2={width} y2={height / 2} stroke="currentColor" strokeDasharray="4" />
</svg>
)
}
return (
<svg width={width} height={height} className="overflow-visible">
{/* Area fill */}
<path
d={areaPath}
fill="url(#gradient)"
fillOpacity="0.3"
/>
{/* Line */}
<path
d={path}
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="text-red-500"
/>
{/* Gradient definition */}
<defs>
<linearGradient id="gradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="rgb(239 68 68)" stopOpacity="0.4" />
<stop offset="100%" stopColor="rgb(239 68 68)" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
)
}
// =============================================================================
// 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 (
<div className="flex items-center gap-1 bg-gray-100 rounded p-0.5">
{periods.map(({ value, label }) => (
<button
key={value}
onClick={() => onChange?.(value)}
className={cn(
'px-2 py-0.5 text-xs rounded transition-colors',
activePeriod === value
? 'bg-white text-gray-900 shadow-sm'
: 'text-gray-500 hover:text-gray-700',
)}
>
{label}
</button>
))}
</div>
)
}
// =============================================================================
// Main Component
// =============================================================================
export function ErrorTrendChart({
data,
loading = false,
error = null,
onPeriodChange,
activePeriod = '24h',
className,
}: ErrorTrendChartProps) {
const t = useTranslations('errors')
// Loading state
if (loading) {
return (
<div className={cn(
'bg-white border border-gray-200 rounded-sm p-4',
className
)}>
<div className="animate-pulse space-y-3">
<div className="flex items-center justify-between">
<div className="h-4 bg-gray-200 rounded w-1/4" />
<div className="h-5 bg-gray-200 rounded w-16" />
</div>
<div className="h-16 bg-gray-100 rounded" />
<div className="flex items-center justify-between">
<div className="h-6 bg-gray-200 rounded w-16" />
<div className="h-4 bg-gray-200 rounded w-24" />
</div>
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className={cn(
'bg-white border border-red-200 rounded-sm p-4',
className
)}>
<div className="flex items-center gap-2 text-red-600">
<Activity className="h-4 w-4" />
<span className="text-sm">{error}</span>
</div>
</div>
)
}
// Empty state
if (!data) {
return (
<div className={cn(
'bg-white border border-gray-200 rounded-sm p-4',
className
)}>
<div className="text-center text-gray-500 py-4">
<Activity className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t('noTrendData')}</p>
</div>
</div>
)
}
// 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 (
<div className={cn(
'bg-white border border-gray-200 rounded-sm p-4',
className
)}>
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Activity className="h-4 w-4 text-gray-600" />
<h3 className="text-sm font-medium text-gray-900">{t('errorTrend')}</h3>
</div>
<PeriodSelector
activePeriod={activePeriod}
onChange={onPeriodChange}
/>
</div>
{/* Chart */}
<div className="py-2">
<Sparkline data={data.data} width={280} height={60} />
</div>
{/* Footer */}
<div className="flex items-center justify-between mt-2 pt-2 border-t border-gray-100">
{/* Total Count */}
<div>
<div className="text-xl font-semibold text-gray-900">
{data.total_count.toLocaleString()}
</div>
<div className="text-xs text-gray-500">
{t('totalErrors', { period: data.period })}
</div>
</div>
{/* Change Percent */}
<div className={cn(
'flex items-center gap-1 px-2 py-1 rounded',
trendBg,
trendColor,
)}>
<TrendIcon className="h-4 w-4" />
<span className="text-sm font-medium">
{data.change_percent > 0 ? '+' : ''}{data.change_percent.toFixed(1)}%
</span>
</div>
</div>
</div>
)
}

View File

@@ -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'

View File

@@ -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<ErrorAnalysisResponse | null>(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 (
<div
onClick={onClick}
className={cn(
'border-b border-gray-100 last:border-b-0 p-3 hover:bg-gray-50 cursor-pointer transition-colors',
)}
>
{/* Main Row */}
<div className="flex items-start gap-3">
{/* Level Badge */}
<div className={cn(
'px-1.5 py-0.5 rounded text-xs font-medium',
levelConfig.bgColor,
levelConfig.color,
)}>
<LevelIcon className="h-3 w-3 inline mr-1" />
{issue.level}
</div>
{/* Content */}
<div className="flex-1 min-w-0">
{/* Title */}
<div className="text-sm font-medium text-gray-900 truncate">
{issue.title}
</div>
{/* Culprit */}
{issue.culprit && (
<div className="text-xs text-gray-500 truncate mt-0.5">
{issue.culprit}
</div>
)}
{/* Meta */}
<div className="flex items-center gap-3 mt-1.5 text-xs text-gray-500">
<span className="flex items-center gap-1">
<Bug className="h-3 w-3" />
{issue.count.toLocaleString()}
</span>
<span className="flex items-center gap-1">
<Users className="h-3 w-3" />
{issue.user_count}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{timeAgo}
</span>
<span className="text-gray-400">
{issue.short_id}
</span>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{/* AI Analyze Button */}
<button
onClick={handleAnalyze}
disabled={analyzing}
className={cn(
'flex items-center gap-1 px-2 py-1 text-xs rounded',
'bg-purple-50 text-purple-700 hover:bg-purple-100',
'border border-purple-200 transition-colors',
analyzing && 'opacity-50 cursor-not-allowed',
)}
>
<Sparkles className={cn('h-3 w-3', analyzing && 'animate-pulse')} />
{analyzing ? t('analyzing') : t('aiAnalyze')}
</button>
{/* External Link */}
{issue.permalink && (
<a
href={issue.permalink}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
className="text-gray-400 hover:text-gray-600"
>
<ExternalLink className="h-4 w-4" />
</a>
)}
<ChevronRight className="h-4 w-4 text-gray-400" />
</div>
</div>
{/* AI Analysis Result */}
{analysis && analysis.status === 'completed' && analysis.analysis && (
<div className="mt-3 p-3 bg-purple-50 border border-purple-100 rounded-sm">
<div className="flex items-center gap-2 mb-2">
<Sparkles className="h-4 w-4 text-purple-600" />
<span className="text-sm font-medium text-purple-800">{t('aiAnalysis')}</span>
<span className={cn(
'text-xs px-1.5 py-0.5 rounded',
analysis.analysis.severity === 'CRITICAL' ? 'bg-red-100 text-red-700' :
analysis.analysis.severity === 'HIGH' ? 'bg-orange-100 text-orange-700' :
analysis.analysis.severity === 'MEDIUM' ? 'bg-amber-100 text-amber-700' :
'bg-gray-100 text-gray-700'
)}>
{analysis.analysis.severity}
</span>
</div>
<div className="text-sm text-purple-900 mb-2">
<strong>{t('rootCause')}:</strong> {analysis.analysis.root_cause}
</div>
<div className="text-sm text-purple-800">
<strong>{t('fixSummary')}:</strong> {analysis.analysis.fix_recommendation.summary}
</div>
<div className="mt-2 text-xs text-purple-600">
{t('category')}: {analysis.analysis.category} | {t('confidence')}: {(analysis.analysis.confidence * 100).toFixed(0)}%
</div>
</div>
)}
</div>
)
}
// =============================================================================
// Main Component
// =============================================================================
export function RecentIssuesList({
issues,
loading = false,
error = null,
onIssueClick,
className,
}: RecentIssuesListProps) {
const t = useTranslations('errors')
// Loading state
if (loading) {
return (
<div className={cn(
'bg-white border border-gray-200 rounded-sm',
className
)}>
<div className="p-3 border-b border-gray-100">
<div className="h-4 bg-gray-200 rounded w-1/4 animate-pulse" />
</div>
<div className="divide-y divide-gray-100">
{[1, 2, 3].map((i) => (
<div key={i} className="p-3 animate-pulse">
<div className="flex items-start gap-3">
<div className="h-5 w-12 bg-gray-200 rounded" />
<div className="flex-1 space-y-2">
<div className="h-4 bg-gray-200 rounded w-3/4" />
<div className="h-3 bg-gray-100 rounded w-1/2" />
</div>
</div>
</div>
))}
</div>
</div>
)
}
// Error state
if (error) {
return (
<div className={cn(
'bg-white border border-red-200 rounded-sm p-4',
className
)}>
<div className="flex items-center gap-2 text-red-600">
<AlertTriangle className="h-4 w-4" />
<span className="text-sm">{error}</span>
</div>
</div>
)
}
// Empty state
if (issues.length === 0) {
return (
<div className={cn(
'bg-white border border-gray-200 rounded-sm p-6',
className
)}>
<div className="text-center text-gray-500">
<Bug className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{t('noIssues')}</p>
</div>
</div>
)
}
return (
<div className={cn(
'bg-white border border-gray-200 rounded-sm',
className
)}>
{/* Header */}
<div className="px-3 py-2 border-b border-gray-100 flex items-center justify-between">
<div className="flex items-center gap-2">
<Bug className="h-4 w-4 text-gray-600" />
<h3 className="text-sm font-medium text-gray-900">{t('recentIssues')}</h3>
<span className="text-xs text-gray-500">({issues.length})</span>
</div>
</div>
{/* Issues List */}
<div className="max-h-96 overflow-y-auto">
{issues.map((issue) => (
<IssueItem
key={issue.id}
issue={issue}
onClick={() => onIssueClick?.(issue)}
/>
))}
</div>
</div>
)
}

View File

@@ -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<ApprovalCardProps> = ({ 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 (
<div className="w-full max-w-2xl bg-white border-2 border-nothing-black shadow-lg rounded-sm overflow-hidden mt-4 mb-2">
<div className="flex border-b-2 border-nothing-black bg-nothing-gray-50 p-3 items-center justify-between">
<div className="flex items-center gap-2">
<ShieldAlert className={isCritical ? 'text-red-500' : 'text-blue-500'} size={20} />
<span className="font-['VT323'] text-xl font-bold uppercase tracking-wider text-nothing-black">
Approval Required // {data.approvalId}
</span>
</div>
<div className={`px-2 py-1 text-xs font-mono font-bold border-2 ${isCritical ? 'border-red-500 text-red-500' : 'border-blue-500 text-blue-500'}`}>
{data.riskLevel}
</div>
</div>
<div className="p-4 space-y-4">
<div>
<Label>Target Action</Label>
<div className="mt-1 bg-nothing-gray-100 p-3 rounded-sm border border-gray-200 shadow-inner">
<code className="font-['VT323'] text-2xl text-[#4A90D9] break-all">
{data.kubectl}
</code>
</div>
</div>
<div className="pt-2">
{!authorized ? (
<button
onClick={handleAuthorize}
disabled={isAuthorizing}
className={`w-full py-3 font-mono font-bold text-sm tracking-widest uppercase transition-all flex justify-center items-center gap-2
${isAuthorizing
? 'bg-nothing-gray-200 text-gray-500 border-2 border-gray-300 cursor-not-allowed'
: isCritical
? 'bg-white text-red-600 border-2 border-red-600 hover:bg-red-50'
: 'bg-white text-nothing-black border-2 border-nothing-black hover:bg-nothing-gray-50'
}`}
>
{isAuthorizing ? '[ AUTHORIZING... ]' : '[ AUTHORIZE EXECUTION ]'}
</button>
) : (
<div className="w-full py-3 bg-green-50 text-green-700 border-2 border-green-500 font-mono font-bold text-sm uppercase flex justify-center items-center gap-2">
<CheckCircle2 size={18} />
[ EXECUTION AUTHORIZED ]
</div>
)}
</div>
{isCritical && !authorized && (
<p className="text-xs font-mono text-red-500 text-center mt-2 animate-pulse">
WARNING: This action has a HIGH blast radius. Multi-Sig required.
</p>
)}
</div>
</div>
)
}
const Label = ({ children }: { children: React.ReactNode }) => (
<span className="text-xs uppercase font-mono font-bold text-gray-500 tracking-wider">
{children}
</span>
)

View File

@@ -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 },

View File

@@ -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<HTMLInputElement>(null)
const scrollRef = useRef<HTMLDivElement>(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 (
<button
onClick={openTerminal}
className="fixed bottom-4 right-4 z-50 px-4 py-2 bg-white/70 backdrop-blur-[20px] border border-gray-200 rounded-md shadow-sm text-nothing-black hover:bg-white transition-all flex items-center gap-2 group"
>
<span className="w-2 h-2 rounded-full bg-[#4A90D9] animate-pulse"></span>
<span className="font-['VT323'] text-lg">Omni-Terminal [K]</span>
</button>
)
}
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 <ApprovalCard data={msg.payload.args} />
}
return <div className="text-gray-500 italic">[GenUI Tool Render Failed: Unknown Tool {msg.payload.tool}]</div>
}
return (
<span className={msg.role === 'assistant' ? 'text-[#4A90D9]' : 'text-nothing-black'}>
{msg.content}
</span>
)
}
return (
<div className="fixed bottom-0 left-0 w-full z-50 flex justify-center pb-4 px-4 pointer-events-none">
<div className="w-full max-w-4xl bg-white/70 backdrop-blur-[20px] border border-gray-200 rounded-lg shadow-xl overflow-hidden pointer-events-auto flex flex-col transition-all" style={{ maxHeight: '60vh', minHeight: '30vh' }}>
{/* Terminal Header */}
<div className="flex justify-between items-center px-4 py-2 border-b border-gray-200 bg-white/40">
<div className="flex items-center gap-3">
<span className="font-['VT323'] text-lg text-[#4A90D9]">AWOOOI // OMNI-TERMINAL</span>
<div className="flex items-center gap-1 opacity-60">
<span className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-500' : 'bg-gray-400'}`}></span>
<span className="text-xs uppercase font-mono">{isConnected ? 'SSE Live' : 'Offline'}</span>
</div>
</div>
<button onClick={toggleTerminal} className="text-gray-500 hover:text-black font-mono">
[x]
</button>
</div>
{/* Messages / GenUI Canvas */}
<div
ref={scrollRef}
className="flex-1 overflow-y-auto p-4 font-['VT323'] text-xl space-y-4"
>
{messages.map((msg, i) => (
<div key={`${msg.id}-${i}`} className="flex flex-col">
<div className="flex gap-2">
<span className="opacity-50 select-none">
{msg.role === 'system' ? '[SYS]' : msg.role === 'user' ? 'root@awoooi:~#' : '[AGENT]'}
</span>
<div className="flex-1 break-words whitespace-pre-wrap">
{renderMessageContent(msg)}
</div>
</div>
</div>
))}
</div>
{/* Input Bar */}
<div className="p-4 border-t border-gray-200 bg-white/40">
<form onSubmit={handleSubmit} className="flex gap-2 items-center">
<span className="text-[#4A90D9] font-['VT323'] text-2xl select-none">{'>'}</span>
<input
ref={inputRef}
type="text"
value={inputValue}
onChange={(e) => 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"
/>
</form>
</div>
</div>
</div>
)
}

View File

@@ -5,3 +5,4 @@ export * from './useApprovalSSE'
export * from './useIncidents'
export * from './useGlobalPulseMetrics'
export * from './useKeyboardShortcuts'
export * from './useErrors'

View File

@@ -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<void>
setPeriod: (period: '24h' | '7d' | '30d') => void
}
// =============================================================================
// Hook
// =============================================================================
export function useErrors(): UseErrorsReturn {
const [state, setState] = useState<UseErrorsState>({
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,
}
}

View File

@@ -180,6 +180,42 @@ export const apiClient = {
const res = await fetch(`${API_BASE_URL}/approvals/pending`)
return handleResponse<PendingApprovalsResponse>(res)
},
// =========================================================================
// Phase 10: Sentry Errors API (#40 BFF)
// =========================================================================
async getErrorStats() {
const res = await fetch(`${API_BASE_URL}/errors/stats`)
return handleResponse<ErrorStatsResponse>(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<ErrorListResponse>(res)
},
async getErrorDetail(issueId: string) {
const res = await fetch(`${API_BASE_URL}/errors/issues/${issueId}`)
return handleResponse<ErrorDetailResponse>(res)
},
async getErrorTrends(period: '24h' | '7d' | '30d' = '24h') {
const res = await fetch(`${API_BASE_URL}/errors/trends?period=${period}`)
return handleResponse<ErrorTrendResponse>(res)
},
async analyzeError(issueId: string) {
const res = await fetch(`${API_BASE_URL}/errors/issues/${issueId}/analyze`, {
method: 'POST',
})
return handleResponse<ErrorAnalysisResponse>(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<string, unknown>
latest_event: Record<string, unknown> | 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
}

View File

@@ -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<string, any>
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<TerminalMessage, 'id' | 'timestamp'>) => void
updateLastMessage: (contentChunk: string) => void
clearMessages: () => void
// Intent
sendIntent: (text: string) => void
}
export const useTerminalStore = create<TerminalState>((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)
}
}))