新增功能: - 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>
334 lines
10 KiB
TypeScript
334 lines
10 KiB
TypeScript
'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>
|
|
)
|
|
}
|