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