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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} 天前"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
153
apps/web/src/app/[locale]/errors/page.tsx
Normal file
153
apps/web/src/app/[locale]/errors/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
182
apps/web/src/components/errors/error-overview-card.tsx
Normal file
182
apps/web/src/components/errors/error-overview-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
275
apps/web/src/components/errors/error-trend-chart.tsx
Normal file
275
apps/web/src/components/errors/error-trend-chart.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
apps/web/src/components/errors/index.ts
Normal file
17
apps/web/src/components/errors/index.ts
Normal 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'
|
||||
333
apps/web/src/components/errors/recent-issues-list.tsx
Normal file
333
apps/web/src/components/errors/recent-issues-list.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
apps/web/src/components/genui/ApprovalCard.tsx
Normal file
88
apps/web/src/components/genui/ApprovalCard.tsx
Normal 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>
|
||||
)
|
||||
@@ -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 },
|
||||
|
||||
144
apps/web/src/components/terminal/OmniTerminal.tsx
Normal file
144
apps/web/src/components/terminal/OmniTerminal.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export * from './useApprovalSSE'
|
||||
export * from './useIncidents'
|
||||
export * from './useGlobalPulseMetrics'
|
||||
export * from './useKeyboardShortcuts'
|
||||
export * from './useErrors'
|
||||
|
||||
111
apps/web/src/hooks/useErrors.ts
Normal file
111
apps/web/src/hooks/useErrors.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
135
apps/web/src/stores/terminal.store.ts
Normal file
135
apps/web/src/stores/terminal.store.ts
Normal 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)
|
||||
}
|
||||
}))
|
||||
Reference in New Issue
Block a user