feat(ui): UI/UX sweep for all P2 panels (Option A - Light Mode Glassmorphism)
This commit is contained in:
@@ -4,15 +4,7 @@
|
||||
* 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)
|
||||
* 2026-07-04 Codex: UI/UX 全面升級 (Option A 明亮模式)
|
||||
*/
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
@@ -20,10 +12,6 @@ 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
|
||||
@@ -32,10 +20,6 @@ interface ErrorOverviewCardProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Component
|
||||
// =============================================================================
|
||||
|
||||
export function ErrorOverviewCard({
|
||||
stats,
|
||||
loading = false,
|
||||
@@ -45,135 +29,116 @@ export function ErrorOverviewCard({
|
||||
}: 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={cn('overflow-hidden rounded-xl border border-[#e0ddd4] bg-white p-5', className)}>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="h-4 w-1/3 rounded bg-[#e0ddd4]" />
|
||||
<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 className="h-16 rounded bg-[#faf9f3]" />
|
||||
<div className="h-16 rounded bg-[#faf9f3]" />
|
||||
</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 className="h-16 rounded bg-[#faf9f3]" />
|
||||
<div className="h-16 rounded bg-[#faf9f3]" />
|
||||
</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" />
|
||||
<div className={cn('overflow-hidden rounded-xl border border-[#e2a29b] bg-[#fff1f0] p-5 text-[#9f2f25]', className)}>
|
||||
<div className="flex items-start gap-2 font-semibold">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<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 className={cn('overflow-hidden rounded-xl border border-[#e0ddd4] bg-white p-5', className)}>
|
||||
<div className="py-8 text-center text-[#77736a]">
|
||||
<Bug className="mx-auto mb-3 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm font-semibold">{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'
|
||||
const trendColor = changePercent > 0 ? 'text-[#9f2f25]' : changePercent < 0 ? 'text-[#17602a]' : 'text-[#77736a]'
|
||||
|
||||
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={cn('overflow-hidden rounded-xl border border-[#e0ddd4] bg-white p-5 shadow-sm', className)}>
|
||||
<div className="mb-5 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('overview')}</h3>
|
||||
<Bug className="h-4 w-4 text-[#77736a]" />
|
||||
<h3 className="font-semibold text-[#141413]">{t('overview')}</h3>
|
||||
</div>
|
||||
{changePercent !== 0 && (
|
||||
<div className={cn('flex items-center gap-1 text-xs', trendColor)}>
|
||||
<TrendIcon className="h-3 w-3" />
|
||||
<div className={cn('flex items-center gap-1 font-mono text-xs font-bold tracking-widest', trendColor)}>
|
||||
<TrendIcon className="h-3.5 w-3.5" />
|
||||
<span>{Math.abs(changePercent).toFixed(1)}%</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="grid grid-cols-2 gap-px overflow-hidden rounded-lg border border-[#e0ddd4] bg-[#e0ddd4]">
|
||||
{/* Unresolved Issues */}
|
||||
<div className="bg-red-50 border border-red-100 rounded-sm p-3">
|
||||
<div className="text-2xl font-semibold text-red-700">
|
||||
<div className="bg-white p-4 transition-colors hover:bg-[#fff1f0]">
|
||||
<div className="font-mono text-2xl font-bold tabular-nums text-[#9f2f25]">
|
||||
{stats.unresolved_issues}
|
||||
</div>
|
||||
<div className="text-xs text-red-600 mt-1">
|
||||
<div className="mt-1 text-[10px] font-semibold uppercase tracking-wider text-[#9f2f25]">
|
||||
{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">
|
||||
<div className="bg-white p-4 transition-colors hover:bg-[#fff7e8]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Clock className="h-4 w-4 text-[#8a5a08]" />
|
||||
<span className="font-mono text-2xl font-bold tabular-nums text-[#8a5a08]">
|
||||
{stats.error_count_24h}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-orange-600 mt-1">
|
||||
<div className="mt-1 text-[10px] font-semibold uppercase tracking-wider text-[#8a5a08]">
|
||||
{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">
|
||||
<div className="bg-white p-4 transition-colors hover:bg-[#f4f1ff]">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AlertTriangle className="h-4 w-4 text-[#6d28d9]" />
|
||||
<span className="font-mono text-2xl font-bold tabular-nums text-[#6d28d9]">
|
||||
{stats.critical_count}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-purple-600 mt-1">
|
||||
<div className="mt-1 text-[10px] font-semibold uppercase tracking-wider text-[#6d28d9]">
|
||||
{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">
|
||||
<div className="bg-white p-4 transition-colors hover:bg-[#faf9f3]">
|
||||
<div className="font-mono text-2xl font-bold tabular-nums text-[#141413]">
|
||||
{stats.total_issues}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-1">
|
||||
<div className="mt-1 text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">
|
||||
{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 className="mt-4 border-t border-[#e0ddd4] pt-4">
|
||||
<div className="font-mono text-[10px] font-bold uppercase tracking-wider text-[#77736a]">
|
||||
{t('projects')}: <span className="text-[#141413]">{stats.projects.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -4,15 +4,7 @@
|
||||
* 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)
|
||||
* 2026-07-04 Codex: UI/UX 全面升級 (Option A 明亮模式)
|
||||
*/
|
||||
|
||||
import { useMemo } from 'react'
|
||||
@@ -21,10 +13,6 @@ 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
|
||||
@@ -34,13 +22,9 @@ interface ErrorTrendChartProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Sparkline Component
|
||||
// =============================================================================
|
||||
|
||||
function Sparkline({
|
||||
data,
|
||||
width = 200,
|
||||
width = 280,
|
||||
height = 60,
|
||||
}: {
|
||||
data: ErrorTrendPoint[]
|
||||
@@ -81,21 +65,19 @@ function Sparkline({
|
||||
|
||||
if (data.length === 0) {
|
||||
return (
|
||||
<svg width={width} height={height} className="text-gray-300">
|
||||
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} className="text-[#e0ddd4] preserve-aspect-ratio-none">
|
||||
<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 */}
|
||||
<svg width="100%" height={height} viewBox={`0 0 ${width} ${height}`} className="overflow-visible preserve-aspect-ratio-none">
|
||||
<path
|
||||
d={areaPath}
|
||||
fill="url(#gradient)"
|
||||
fillOpacity="0.3"
|
||||
/>
|
||||
{/* Line */}
|
||||
<path
|
||||
d={path}
|
||||
fill="none"
|
||||
@@ -103,23 +85,18 @@ function Sparkline({
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className="text-red-500"
|
||||
className="text-[#d97757]"
|
||||
/>
|
||||
{/* 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" />
|
||||
<stop offset="0%" stopColor="#d97757" stopOpacity="0.4" />
|
||||
<stop offset="100%" stopColor="#d97757" stopOpacity="0" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Period Selector Component
|
||||
// =============================================================================
|
||||
|
||||
function PeriodSelector({
|
||||
activePeriod,
|
||||
onChange,
|
||||
@@ -134,16 +111,16 @@ function PeriodSelector({
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1 bg-gray-100 rounded p-0.5">
|
||||
<div className="flex items-center gap-1 rounded-lg border border-[#e0ddd4] bg-[#faf9f3] 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',
|
||||
'px-2.5 py-1 font-mono text-[10px] font-bold tracking-widest transition-colors rounded-md',
|
||||
activePeriod === value
|
||||
? 'bg-white text-gray-900 shadow-sm'
|
||||
: 'text-gray-500 hover:text-gray-700',
|
||||
? 'bg-white text-[#141413] shadow-sm border border-[#e0ddd4]'
|
||||
: 'text-[#77736a] hover:text-[#141413]'
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
@@ -153,10 +130,6 @@ function PeriodSelector({
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export function ErrorTrendChart({
|
||||
data,
|
||||
loading = false,
|
||||
@@ -167,36 +140,28 @@ export function ErrorTrendChart({
|
||||
}: 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={cn('overflow-hidden rounded-xl border border-[#e0ddd4] bg-white p-5', className)}>
|
||||
<div className="animate-pulse space-y-4">
|
||||
<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 className="h-4 w-1/4 rounded bg-[#e0ddd4]" />
|
||||
<div className="h-6 w-24 rounded bg-[#faf9f3]" />
|
||||
</div>
|
||||
<div className="h-16 bg-gray-100 rounded" />
|
||||
<div className="h-16 rounded bg-[#faf9f3]" />
|
||||
<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 className="h-6 w-16 rounded bg-[#e0ddd4]" />
|
||||
<div className="h-4 w-24 rounded bg-[#faf9f3]" />
|
||||
</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">
|
||||
<div className={cn('overflow-hidden rounded-xl border border-[#e2a29b] bg-[#fff1f0] p-5 text-[#9f2f25]', className)}>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<Activity className="h-4 w-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
@@ -204,68 +169,49 @@ export function ErrorTrendChart({
|
||||
)
|
||||
}
|
||||
|
||||
// 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 className={cn('overflow-hidden rounded-xl border border-[#e0ddd4] bg-white p-5', className)}>
|
||||
<div className="py-8 text-center text-[#77736a]">
|
||||
<Activity className="mx-auto mb-3 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm font-semibold">{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'
|
||||
const trendColor = data.change_percent > 0 ? 'text-[#9f2f25]' : data.change_percent < 0 ? 'text-[#17602a]' : 'text-[#77736a]'
|
||||
const trendBg = data.change_percent > 0 ? 'bg-[#fff1f0]' : data.change_percent < 0 ? 'bg-[#f0faf2]' : 'bg-[#faf9f3]'
|
||||
const trendBorder = data.change_percent > 0 ? 'border-[#e2a29b]' : data.change_percent < 0 ? 'border-[#8fc29a]' : 'border-[#e0ddd4]'
|
||||
|
||||
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={cn('overflow-hidden rounded-xl border border-[#e0ddd4] bg-white p-5 shadow-sm', className)}>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<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>
|
||||
<Activity className="h-4 w-4 text-[#77736a]" />
|
||||
<h3 className="font-semibold text-[#141413]">{t('errorTrend')}</h3>
|
||||
</div>
|
||||
<PeriodSelector
|
||||
activePeriod={activePeriod}
|
||||
onChange={onPeriodChange}
|
||||
/>
|
||||
<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 className="mt-4 flex items-center justify-between border-t border-[#e0ddd4] pt-4">
|
||||
<div>
|
||||
<div className="text-xl font-semibold text-gray-900">
|
||||
<div className="font-mono text-2xl font-bold tabular-nums text-[#141413]">
|
||||
{data.total_count.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">
|
||||
{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">
|
||||
<div className={cn('flex items-center gap-1.5 rounded border px-2.5 py-1', trendBg, trendColor, trendBorder)}>
|
||||
<TrendIcon className="h-3.5 w-3.5" />
|
||||
<span className="font-mono text-[11px] font-bold tracking-widest">
|
||||
{data.change_percent > 0 ? '+' : ''}{data.change_percent.toFixed(1)}%
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4,15 +4,7 @@
|
||||
* 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)
|
||||
* 2026-07-04 Codex: UI/UX 全面升級 (Option A 明亮模式)
|
||||
*/
|
||||
|
||||
import { useState } from 'react'
|
||||
@@ -22,41 +14,33 @@ import { AlertTriangle, Bug, Clock, Users, ChevronRight, Sparkles, ExternalLink
|
||||
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',
|
||||
color: 'text-[#9f2f25]',
|
||||
bgColor: 'bg-[#fff1f0]',
|
||||
borderColor: 'border-[#e2a29b]',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
error: {
|
||||
color: 'text-orange-700',
|
||||
bgColor: 'bg-orange-50',
|
||||
borderColor: 'border-orange-200',
|
||||
color: 'text-[#8a5a08]',
|
||||
bgColor: 'bg-[#fff7e8]',
|
||||
borderColor: 'border-[#d9b36f]',
|
||||
icon: Bug,
|
||||
},
|
||||
warning: {
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
borderColor: 'border-amber-200',
|
||||
color: 'text-[#8a5a08]',
|
||||
bgColor: 'bg-[#faf9f3]',
|
||||
borderColor: 'border-[#e0ddd4]',
|
||||
icon: AlertTriangle,
|
||||
},
|
||||
info: {
|
||||
color: 'text-blue-700',
|
||||
bgColor: 'bg-blue-50',
|
||||
borderColor: 'border-blue-200',
|
||||
color: 'text-[#4A90D9]',
|
||||
bgColor: 'bg-[#f0f6ff]',
|
||||
borderColor: 'border-[#c7dcf7]',
|
||||
icon: Bug,
|
||||
},
|
||||
} as const
|
||||
|
||||
// =============================================================================
|
||||
// Component Props
|
||||
// =============================================================================
|
||||
|
||||
interface RecentIssuesListProps {
|
||||
issues: SentryIssue[]
|
||||
loading?: boolean
|
||||
@@ -65,10 +49,6 @@ interface RecentIssuesListProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Issue Item Component
|
||||
// =============================================================================
|
||||
|
||||
function IssueItem({
|
||||
issue,
|
||||
onClick,
|
||||
@@ -83,7 +63,6 @@ function IssueItem({
|
||||
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()
|
||||
@@ -116,116 +95,104 @@ function IssueItem({
|
||||
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',
|
||||
)}
|
||||
className="group cursor-pointer border-b border-[#e0ddd4] p-4 transition-colors last:border-b-0 hover:bg-[#faf9f3]"
|
||||
>
|
||||
{/* Main Row */}
|
||||
<div className="flex items-start gap-3">
|
||||
{/* Level Badge */}
|
||||
<div className="flex items-start gap-4">
|
||||
<div className={cn(
|
||||
'px-1.5 py-0.5 rounded text-xs font-medium',
|
||||
'inline-flex items-center gap-1.5 rounded border px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-widest',
|
||||
levelConfig.bgColor,
|
||||
levelConfig.color,
|
||||
levelConfig.borderColor
|
||||
)}>
|
||||
<LevelIcon className="h-3 w-3 inline mr-1" />
|
||||
<LevelIcon className="h-3 w-3" />
|
||||
{issue.level}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Title */}
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-semibold text-[#141413]">
|
||||
{issue.title}
|
||||
</div>
|
||||
|
||||
{/* Culprit */}
|
||||
{issue.culprit && (
|
||||
<div className="text-xs text-gray-500 truncate mt-0.5">
|
||||
<div className="mt-1 truncate font-mono text-[11px] text-[#77736a]">
|
||||
{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" />
|
||||
<div className="mt-3 flex flex-wrap items-center gap-4 text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Bug className="h-3.5 w-3.5" />
|
||||
{issue.count.toLocaleString()}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Users className="h-3 w-3" />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Users className="h-3.5 w-3.5" />
|
||||
{issue.user_count}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span className="flex items-center gap-1.5">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{timeAgo}
|
||||
</span>
|
||||
<span className="text-gray-400">
|
||||
<span className="font-mono text-[#b0ad9f]">
|
||||
{issue.short_id}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* AI Analyze Button */}
|
||||
<div className="flex items-center gap-3">
|
||||
<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',
|
||||
'flex items-center gap-1.5 rounded-lg border border-[#e0ddd4] bg-white px-2.5 py-1.5 text-xs font-semibold text-[#141413] transition-colors',
|
||||
'hover:border-[#6d28d9] hover:bg-[#f4f1ff] hover:text-[#6d28d9]',
|
||||
analyzing && 'cursor-not-allowed opacity-50'
|
||||
)}
|
||||
>
|
||||
<Sparkles className={cn('h-3 w-3', analyzing && 'animate-pulse')} />
|
||||
{analyzing ? t('analyzing') : t('aiAnalyze')}
|
||||
<Sparkles className={cn('h-3.5 w-3.5', analyzing && 'animate-pulse')} />
|
||||
<span className="hidden sm:inline">{analyzing ? t('analyzing') : t('aiAnalyze')}</span>
|
||||
</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"
|
||||
className="text-[#77736a] transition-colors hover:text-[#4A90D9]"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
|
||||
<ChevronRight className="h-4 w-4 text-gray-400" />
|
||||
<ChevronRight className="h-4 w-4 text-[#b0ad9f] transition-transform group-hover:translate-x-0.5 group-hover:text-[#141413]" />
|
||||
</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>
|
||||
<div className="mt-4 rounded-lg border border-[#d4c3ff] bg-[#f4f1ff] p-4">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-[#6d28d9]" />
|
||||
<span className="text-sm font-bold text-[#4c1d95]">{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'
|
||||
'rounded border px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-widest',
|
||||
analysis.analysis.severity === 'CRITICAL' ? 'border-[#e2a29b] bg-[#fff1f0] text-[#9f2f25]' :
|
||||
analysis.analysis.severity === 'HIGH' ? 'border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]' :
|
||||
analysis.analysis.severity === 'MEDIUM' ? 'border-[#d9b36f] bg-[#faf9f3] text-[#8a5a08]' :
|
||||
'border-[#e0ddd4] bg-white text-[#5f5b52]'
|
||||
)}>
|
||||
{analysis.analysis.severity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-purple-900 mb-2">
|
||||
<strong>{t('rootCause')}:</strong> {analysis.analysis.root_cause}
|
||||
<div className="mb-2 text-sm text-[#4c1d95]">
|
||||
<strong className="font-semibold">{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 className="text-sm text-[#5b21b6]">
|
||||
<strong className="font-semibold">{t('fixSummary')}:</strong> {analysis.analysis.fix_recommendation.summary}
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-xs text-purple-600">
|
||||
<div className="mt-3 font-mono text-[10px] font-bold uppercase tracking-wider text-[#7c3aed]">
|
||||
{t('category')}: {analysis.analysis.category} | {t('confidence')}: {(analysis.analysis.confidence * 100).toFixed(0)}%
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,10 +201,6 @@ function IssueItem({
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export function RecentIssuesList({
|
||||
issues,
|
||||
loading = false,
|
||||
@@ -247,24 +210,20 @@ export function RecentIssuesList({
|
||||
}: 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 className={cn('overflow-hidden rounded-xl border border-[#e0ddd4] bg-white', className)}>
|
||||
<div className="border-b border-[#e0ddd4] p-4">
|
||||
<div className="h-4 w-1/4 animate-pulse rounded bg-[#e0ddd4]" />
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
<div className="divide-y divide-[#e0ddd4]">
|
||||
{[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 key={i} className="p-4">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="h-6 w-16 animate-pulse rounded bg-[#e0ddd4]" />
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-[#faf9f3]" />
|
||||
<div className="h-3 w-1/2 animate-pulse rounded bg-[#faf9f3]" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -274,14 +233,10 @@ export function RecentIssuesList({
|
||||
)
|
||||
}
|
||||
|
||||
// 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">
|
||||
<div className={cn('overflow-hidden rounded-xl border border-[#e2a29b] bg-[#fff1f0] p-5 text-[#9f2f25]', className)}>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
@@ -289,37 +244,28 @@ export function RecentIssuesList({
|
||||
)
|
||||
}
|
||||
|
||||
// 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 className={cn('overflow-hidden rounded-xl border border-[#e0ddd4] bg-white p-5', className)}>
|
||||
<div className="py-12 text-center text-[#77736a]">
|
||||
<Bug className="mx-auto mb-3 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm font-semibold">{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={cn('overflow-hidden rounded-xl border border-[#e0ddd4] bg-white shadow-sm', className)}>
|
||||
<div className="flex items-center justify-between border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-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('recentIssues')}</h3>
|
||||
<span className="text-xs text-gray-500">({issues.length})</span>
|
||||
<Bug className="h-4 w-4 text-[#77736a]" />
|
||||
<h3 className="font-semibold text-[#141413]">{t('recentIssues')}</h3>
|
||||
<span className="font-mono text-[11px] font-bold text-[#77736a]">({issues.length})</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Issues List */}
|
||||
<div className="max-h-96 overflow-y-auto">
|
||||
<div className="max-h-[500px] overflow-y-auto">
|
||||
{issues.map((issue) => (
|
||||
<IssueItem
|
||||
key={issue.id}
|
||||
|
||||
@@ -4,20 +4,7 @@
|
||||
* UXAuditCard - #126 Session Replay / UX Audit 卡片
|
||||
* ==================================================
|
||||
* Phase 19: Sentry Session Replay 前端整合
|
||||
*
|
||||
* 功能:
|
||||
* - 顯示 UX 健康度評分
|
||||
* - 列出有錯誤的 Replay 連結
|
||||
* - 顯示憤怒/死亡點擊統計
|
||||
*
|
||||
* Nothing.tech 視覺規範:
|
||||
* - 純白底色 (bg-white)
|
||||
* - 極細淺灰邊框 (border border-gray-200)
|
||||
* - 無圓角或微圓角 (rounded-sm)
|
||||
* - 嚴禁陰影 (shadow-none)
|
||||
*
|
||||
* 建立: 2026-03-31 (台北時區)
|
||||
* 建立者: Claude Code (#126 Replay UI)
|
||||
* 2026-07-04 Codex: UI/UX 全面升級 (Option A 明亮模式)
|
||||
*/
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
@@ -32,10 +19,6 @@ import {
|
||||
} from 'lucide-react'
|
||||
import type { UXAuditResponse, UXAuditDetail } from '@/lib/api-client'
|
||||
|
||||
// =============================================================================
|
||||
// Component Props
|
||||
// =============================================================================
|
||||
|
||||
interface UXAuditCardProps {
|
||||
data: UXAuditResponse | null
|
||||
loading?: boolean
|
||||
@@ -43,35 +26,27 @@ interface UXAuditCardProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Health Score Config
|
||||
// =============================================================================
|
||||
|
||||
const HEALTH_CONFIG = {
|
||||
good: {
|
||||
color: 'text-green-700',
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
color: 'text-[#17602a]',
|
||||
bgColor: 'bg-[#f0faf2]',
|
||||
borderColor: 'border-[#8fc29a]',
|
||||
label: 'good',
|
||||
},
|
||||
moderate: {
|
||||
color: 'text-amber-700',
|
||||
bgColor: 'bg-amber-50',
|
||||
borderColor: 'border-amber-200',
|
||||
color: 'text-[#8a5a08]',
|
||||
bgColor: 'bg-[#fff7e8]',
|
||||
borderColor: 'border-[#d9b36f]',
|
||||
label: 'moderate',
|
||||
},
|
||||
poor: {
|
||||
color: 'text-red-700',
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
color: 'text-[#9f2f25]',
|
||||
bgColor: 'bg-[#fff1f0]',
|
||||
borderColor: 'border-[#e2a29b]',
|
||||
label: 'poor',
|
||||
},
|
||||
} as const
|
||||
|
||||
// =============================================================================
|
||||
// Replay Item Component
|
||||
// =============================================================================
|
||||
|
||||
function ReplayItem({ detail }: { detail: UXAuditDetail }) {
|
||||
const t = useTranslations('errors.uxAudit')
|
||||
|
||||
@@ -81,54 +56,49 @@ function ReplayItem({ detail }: { detail: UXAuditDetail }) {
|
||||
href={detail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-sm transition-colors group"
|
||||
className="group flex items-center gap-3 p-3 transition-colors hover:bg-[#faf9f3]"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<Video className="h-4 w-4 text-purple-600" />
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[#d4c3ff] bg-[#f4f1ff] text-[#6d28d9]">
|
||||
<Video className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold text-[#141413]">
|
||||
{t('replayWithErrors', { count: detail.error_count || 0 })}
|
||||
</div>
|
||||
{detail.urls && detail.urls.length > 0 && (
|
||||
<div className="text-xs text-gray-500 truncate">
|
||||
<div className="mt-0.5 truncate font-mono text-[10px] text-[#77736a]">
|
||||
{detail.urls[0]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-gray-400 group-hover:text-purple-600 transition-colors" />
|
||||
<ExternalLink className="h-3.5 w-3.5 text-[#b0ad9f] transition-colors group-hover:text-[#6d28d9]" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// UI Error type
|
||||
return (
|
||||
<a
|
||||
href={detail.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-3 p-2 hover:bg-gray-50 rounded-sm transition-colors group"
|
||||
className="group flex items-center gap-3 p-3 transition-colors hover:bg-[#faf9f3]"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-600" />
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-lg border border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900 truncate">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-xs font-semibold text-[#141413]">
|
||||
{detail.title || 'UI Error'}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
<div className="mt-0.5 font-mono text-[10px] text-[#77736a]">
|
||||
{t('occurrences', { count: detail.count || 0 })}
|
||||
</div>
|
||||
</div>
|
||||
<ExternalLink className="h-3.5 w-3.5 text-gray-400 group-hover:text-orange-600 transition-colors" />
|
||||
<ExternalLink className="h-3.5 w-3.5 text-[#b0ad9f] transition-colors group-hover:text-[#8a5a08]" />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Main Component
|
||||
// =============================================================================
|
||||
|
||||
export function UXAuditCard({
|
||||
data,
|
||||
loading = false,
|
||||
@@ -137,27 +107,25 @@ export function UXAuditCard({
|
||||
}: UXAuditCardProps) {
|
||||
const t = useTranslations('errors.uxAudit')
|
||||
|
||||
// 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/3 animate-pulse" />
|
||||
<div className={cn('overflow-hidden rounded-xl border border-[#e0ddd4] bg-white', className)}>
|
||||
<div className="border-b border-[#e0ddd4] p-4">
|
||||
<div className="h-4 w-1/3 animate-pulse rounded bg-[#e0ddd4]" />
|
||||
</div>
|
||||
<div className="p-4 space-y-3 animate-pulse">
|
||||
<div className="h-8 bg-gray-100 rounded w-1/4" />
|
||||
<div className="h-4 bg-gray-100 rounded w-full" />
|
||||
<div className="h-4 bg-gray-100 rounded w-3/4" />
|
||||
<div className="space-y-4 p-5">
|
||||
<div className="h-10 w-1/4 animate-pulse rounded bg-[#faf9f3]" />
|
||||
<div className="h-4 w-full animate-pulse rounded bg-[#faf9f3]" />
|
||||
<div className="h-4 w-3/4 animate-pulse rounded bg-[#faf9f3]" />
|
||||
</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">
|
||||
<div className={cn('overflow-hidden rounded-xl border border-[#e2a29b] bg-[#fff1f0] p-5 text-[#9f2f25]', className)}>
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span className="text-sm">{error}</span>
|
||||
</div>
|
||||
@@ -165,13 +133,12 @@ export function UXAuditCard({
|
||||
)
|
||||
}
|
||||
|
||||
// No data state
|
||||
if (!data) {
|
||||
return (
|
||||
<div className={cn('bg-white border border-gray-200 rounded-sm p-6', className)}>
|
||||
<div className="text-center text-gray-500">
|
||||
<Video className="h-8 w-8 mx-auto mb-2 opacity-50" />
|
||||
<p className="text-sm">{t('noData')}</p>
|
||||
<div className={cn('overflow-hidden rounded-xl border border-[#e0ddd4] bg-white p-5', className)}>
|
||||
<div className="py-8 text-center text-[#77736a]">
|
||||
<Video className="mx-auto mb-3 h-8 w-8 opacity-40" />
|
||||
<p className="text-sm font-semibold">{t('noData')}</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -182,78 +149,72 @@ export function UXAuditCard({
|
||||
const errorDetails = data.details.filter((d) => d.type === 'ui_error')
|
||||
|
||||
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={cn('overflow-hidden rounded-xl border border-[#e0ddd4] bg-white shadow-sm', className)}>
|
||||
<div className="flex items-center justify-between border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="h-4 w-4 text-purple-600" />
|
||||
<h3 className="text-sm font-medium text-gray-900">{t('title')}</h3>
|
||||
<Video className="h-4 w-4 text-[#77736a]" />
|
||||
<h3 className="font-semibold text-[#141413]">{t('title')}</h3>
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
'px-2 py-0.5 text-xs font-medium rounded',
|
||||
'inline-flex rounded border px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-widest',
|
||||
healthConfig.bgColor,
|
||||
healthConfig.color,
|
||||
healthConfig.borderColor
|
||||
)}
|
||||
>
|
||||
{t(`health.${healthConfig.label}`)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
<div className="grid grid-cols-2 gap-px bg-gray-100 border-b border-gray-100">
|
||||
{/* Replays with Errors */}
|
||||
<div className="bg-white p-3">
|
||||
<div className="grid grid-cols-2 gap-px border-b border-[#e0ddd4] bg-[#e0ddd4]">
|
||||
<div className="bg-white p-4 transition-colors hover:bg-[#faf9f3]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Video className="h-4 w-4 text-purple-500" />
|
||||
<span className="text-xs text-gray-500">{t('replaysWithErrors')}</span>
|
||||
<Video className="h-4 w-4 text-[#6d28d9]" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('replaysWithErrors')}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold text-gray-900">
|
||||
<div className="mt-2 font-mono text-2xl font-bold tabular-nums text-[#141413]">
|
||||
{data.replays_with_errors}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* UI Errors */}
|
||||
<div className="bg-white p-3">
|
||||
<div className="bg-white p-4 transition-colors hover:bg-[#faf9f3]">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-orange-500" />
|
||||
<span className="text-xs text-gray-500">{t('uiErrors')}</span>
|
||||
<AlertTriangle className="h-4 w-4 text-[#8a5a08]" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('uiErrors')}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold text-gray-900">
|
||||
<div className="mt-2 font-mono text-2xl font-bold tabular-nums text-[#141413]">
|
||||
{data.ui_errors}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Rage Clicks */}
|
||||
<div className="bg-white p-3">
|
||||
<div className="bg-white p-4 transition-colors hover:bg-[#faf9f3]">
|
||||
<div className="flex items-center gap-2">
|
||||
<Zap className="h-4 w-4 text-red-500" />
|
||||
<span className="text-xs text-gray-500">{t('rageClicks')}</span>
|
||||
<Zap className="h-4 w-4 text-[#9f2f25]" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('rageClicks')}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold text-gray-900">
|
||||
<div className="mt-2 font-mono text-2xl font-bold tabular-nums text-[#141413]">
|
||||
{data.rage_clicks}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dead Clicks */}
|
||||
<div className="bg-white p-3">
|
||||
<div className="bg-white p-4 transition-colors hover:bg-[#faf9f3]">
|
||||
<div className="flex items-center gap-2">
|
||||
<MousePointerClick className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-xs text-gray-500">{t('deadClicks')}</span>
|
||||
<MousePointerClick className="h-4 w-4 text-[#b0ad9f]" />
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('deadClicks')}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xl font-semibold text-gray-900">
|
||||
<div className="mt-2 font-mono text-2xl font-bold tabular-nums text-[#141413]">
|
||||
{data.dead_clicks}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Replay List */}
|
||||
{replayDetails.length > 0 && (
|
||||
<div className="border-b border-gray-100">
|
||||
<div className="px-3 py-1.5 bg-gray-50 text-xs font-medium text-gray-600">
|
||||
<div className="border-b border-[#e0ddd4]">
|
||||
<div className="bg-[#faf9f3] px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider text-[#77736a]">
|
||||
{t('recentReplays')}
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
<div className="divide-y divide-[#e0ddd4]">
|
||||
{replayDetails.slice(0, 5).map((detail, idx) => (
|
||||
<ReplayItem key={detail.replay_id || idx} detail={detail} />
|
||||
))}
|
||||
@@ -261,13 +222,12 @@ export function UXAuditCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* UI Errors List */}
|
||||
{errorDetails.length > 0 && (
|
||||
<div className="border-b border-gray-100">
|
||||
<div className="px-3 py-1.5 bg-gray-50 text-xs font-medium text-gray-600">
|
||||
<div className="border-b border-[#e0ddd4]">
|
||||
<div className="bg-[#faf9f3] px-4 py-2 font-mono text-[10px] font-bold uppercase tracking-wider text-[#77736a]">
|
||||
{t('recentUIErrors')}
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
<div className="divide-y divide-[#e0ddd4]">
|
||||
{errorDetails.slice(0, 3).map((detail, idx) => (
|
||||
<ReplayItem key={detail.issue_id || idx} detail={detail} />
|
||||
))}
|
||||
@@ -275,17 +235,16 @@ export function UXAuditCard({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer: Dashboard Link */}
|
||||
<div className="px-3 py-2">
|
||||
<div className="bg-white p-4">
|
||||
<a
|
||||
href={data.replay_dashboard_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-center gap-1.5 text-xs text-purple-600 hover:text-purple-700 transition-colors"
|
||||
className="flex items-center justify-center gap-2 rounded-lg border border-[#e0ddd4] px-4 py-2 text-xs font-semibold text-[#141413] transition-colors hover:border-[#6d28d9] hover:bg-[#f4f1ff] hover:text-[#6d28d9]"
|
||||
>
|
||||
<Activity className="h-3.5 w-3.5" />
|
||||
<Activity className="h-4 w-4" />
|
||||
{t('viewDashboard')}
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -6,13 +6,12 @@
|
||||
* Sprint 5: 從 /action-logs/page.tsx 抽取
|
||||
* 供原始頁面和整合頁面 (/operations) 共用
|
||||
*
|
||||
* 版本: v1.1
|
||||
* 建立時間: 2026-04-09 (台北時區)
|
||||
* 版本: v1.2
|
||||
* 更新時間: 2026-07-04 — UI/UX 全面升級 (Option A 明亮模式)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import { useTranslations, useLocale } from 'next-intl'
|
||||
import { DataPincerPanel } from '@/components/cyber'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
FileText,
|
||||
@@ -81,67 +80,6 @@ const getApiBaseUrl = (): string => {
|
||||
return url
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// StatCard
|
||||
// =============================================================================
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
subValue,
|
||||
variant = 'default',
|
||||
}: {
|
||||
icon: typeof Activity
|
||||
label: string
|
||||
value: string | number
|
||||
subValue?: string
|
||||
variant?: 'default' | 'success' | 'warning'
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-lg border p-4',
|
||||
'bg-white/50 backdrop-blur-sm',
|
||||
variant === 'success' && 'border-status-healthy/30',
|
||||
variant === 'warning' && 'border-status-warning/30',
|
||||
variant === 'default' && 'border-nothing-gray-200'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center',
|
||||
variant === 'success' && 'bg-status-healthy/10',
|
||||
variant === 'warning' && 'bg-status-warning/10',
|
||||
variant === 'default' && 'bg-nothing-gray-100'
|
||||
)}
|
||||
>
|
||||
<Icon
|
||||
className={cn(
|
||||
'w-5 h-5',
|
||||
variant === 'success' && 'text-status-healthy',
|
||||
variant === 'warning' && 'text-status-warning',
|
||||
variant === 'default' && 'text-nothing-gray-600'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-nothing-gray-500 font-body uppercase">
|
||||
{label}
|
||||
</p>
|
||||
<p className="text-xl font-bold text-nothing-black">{value}</p>
|
||||
{subValue && (
|
||||
<p className="text-[10px] text-nothing-gray-400 font-body">
|
||||
{subValue}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ActionLogsPanel
|
||||
// =============================================================================
|
||||
@@ -265,186 +203,189 @@ export function ActionLogsPanel() {
|
||||
return `${(ms / 1000).toFixed(2)}s`
|
||||
}
|
||||
|
||||
const statCards = stats ? [
|
||||
{
|
||||
label: t('actionLog.stats.total'),
|
||||
value: stats.total_executions.toLocaleString(),
|
||||
icon: Activity,
|
||||
accent: 'text-[#4A90D9]',
|
||||
bg: 'bg-[#f0f6ff]',
|
||||
border: 'border-[#c7dcf7]',
|
||||
},
|
||||
{
|
||||
label: t('actionLog.stats.successRate'),
|
||||
value: `${stats.success_rate.toFixed(1)}%`,
|
||||
icon: TrendingUp,
|
||||
accent: 'text-[#17602a]',
|
||||
bg: 'bg-[#f0faf2]',
|
||||
border: 'border-[#8fc29a]',
|
||||
},
|
||||
{
|
||||
label: t('actionLog.stats.avgDuration'),
|
||||
value: stats.avg_duration_ms ? `${Math.round(stats.avg_duration_ms)}ms` : '-',
|
||||
icon: Clock,
|
||||
accent: 'text-[#8a5a08]',
|
||||
bg: 'bg-[#fff7e8]',
|
||||
border: 'border-[#d9b36f]',
|
||||
},
|
||||
{
|
||||
label: t('actionLog.stats.last24h'),
|
||||
value: stats.last_24h_count.toLocaleString(),
|
||||
icon: Zap,
|
||||
accent: 'text-[#d97757]',
|
||||
bg: 'bg-[#fff7f3]',
|
||||
border: 'border-[#f5cdb8]',
|
||||
},
|
||||
] : []
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Page Title */}
|
||||
<div className="mb-6">
|
||||
<h2 className="font-heading text-2xl font-bold text-nothing-black">
|
||||
{t('actionLog.title')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-nothing-gray-500">
|
||||
{t('actionLog.subtitle')}
|
||||
</p>
|
||||
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
|
||||
Audit Logs
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl font-bold text-[#141413]">
|
||||
{t('actionLog.title')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[#77736a]">
|
||||
{t('actionLog.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
fetchLogs(page)
|
||||
fetchStats()
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[#e0ddd4] bg-white text-[#77736a] transition hover:border-[#4A90D9] hover:text-[#4A90D9] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={t('common.refresh')}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Stats Overview */}
|
||||
{stats && (
|
||||
<div className="mb-6 grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<StatCard
|
||||
icon={Activity}
|
||||
label={t('actionLog.stats.total')}
|
||||
value={stats.total_executions}
|
||||
/>
|
||||
<StatCard
|
||||
icon={TrendingUp}
|
||||
label={t('actionLog.stats.successRate')}
|
||||
value={`${stats.success_rate}%`}
|
||||
variant="success"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label={t('actionLog.stats.avgDuration')}
|
||||
value={stats.avg_duration_ms ? `${Math.round(stats.avg_duration_ms)}ms` : '-'}
|
||||
/>
|
||||
<StatCard
|
||||
icon={Zap}
|
||||
label={t('actionLog.stats.last24h')}
|
||||
value={stats.last_24h_count}
|
||||
variant="warning"
|
||||
/>
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-6 flex items-start gap-3 rounded-xl border border-[#f5cdb8] bg-[#fff7f3] p-4">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-[#d97757]" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="font-semibold text-[#141413]">{t('actionLog.fetchError')}</p>
|
||||
<p className="mt-1 font-mono text-xs text-[#77736a]">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content */}
|
||||
<DataPincerPanel title={t('actionLog.title')} status="healthy">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center justify-between mb-4 px-1">
|
||||
<div className="text-sm text-nothing-gray-500 font-body">
|
||||
{totalCount > 0
|
||||
? `${totalCount} ${t('actionLog.columns.operation').toLowerCase()}s`
|
||||
: ''}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
fetchLogs(page)
|
||||
fetchStats()
|
||||
}}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-body',
|
||||
'bg-nothing-gray-100 text-nothing-gray-600',
|
||||
'hover:bg-nothing-gray-200 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<RefreshCw
|
||||
className={cn('w-3 h-3', isLoading && 'animate-spin')}
|
||||
/>
|
||||
{t('common.refresh')}
|
||||
</button>
|
||||
{/* Stats Overview */}
|
||||
{stats && (
|
||||
<div className="mb-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{statCards.map(({ label, value, icon: Icon, accent, bg, border }) => (
|
||||
<div
|
||||
key={label}
|
||||
className={cn('rounded-xl border p-5 transition hover:shadow-sm', bg, border)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
|
||||
{label}
|
||||
</p>
|
||||
<Icon className={cn('h-4 w-4 shrink-0', accent)} aria-hidden="true" />
|
||||
</div>
|
||||
<p className={cn('mt-3 font-mono text-3xl font-bold tabular-nums', accent)}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2 p-4 mb-4 rounded-lg bg-status-critical/10 border border-status-critical/20">
|
||||
<AlertCircle className="w-4 h-4 text-status-critical" />
|
||||
<span className="text-sm text-status-critical font-body">
|
||||
{t('actionLog.fetchError')}: {error}
|
||||
</span>
|
||||
{/* Loading Placeholder */}
|
||||
{isLoading && logs.length === 0 && (
|
||||
<div className="overflow-hidden rounded-xl border border-[#e0ddd4] bg-white p-5">
|
||||
<div className="flex animate-pulse flex-col items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-[#d8d3c7]" />
|
||||
<span className="mt-3 text-sm text-[#77736a]">{t('actionLog.loading')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && logs.length === 0 && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<RefreshCw className="w-6 h-6 text-nothing-gray-400 animate-spin" />
|
||||
<span className="ml-2 text-nothing-gray-400 font-body">
|
||||
{t('actionLog.loading')}
|
||||
</span>
|
||||
{/* Main Content Table */}
|
||||
{!isLoading && logs.length === 0 && !error ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-[#e0ddd4] bg-white py-16">
|
||||
<FileText className="h-10 w-10 text-[#e0ddd4]" aria-hidden="true" />
|
||||
<p className="mt-4 font-semibold text-[#141413]">{t('actionLog.noLogs')}</p>
|
||||
</div>
|
||||
) : logs.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-xl border border-[#e0ddd4] bg-white">
|
||||
<div className="flex items-center justify-between border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="h-4 w-4 text-[#77736a]" aria-hidden="true" />
|
||||
<h2 className="font-semibold text-[#141413]">
|
||||
{t('actionLog.title')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="font-mono text-[11px] text-[#77736a]">
|
||||
{totalCount > 0 ? `${totalCount} ${t('actionLog.columns.operation').toLowerCase()}s` : ''}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Empty State */}
|
||||
{!isLoading && logs.length === 0 && !error && (
|
||||
<div className="text-center py-12">
|
||||
<FileText className="w-12 h-12 mx-auto mb-3 text-nothing-gray-300" />
|
||||
<p className="text-nothing-gray-400 font-body">
|
||||
{t('actionLog.noLogs')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logs Table */}
|
||||
{logs.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-nothing-gray-200">
|
||||
<th className="px-3 py-2 text-left font-body text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.time')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-body text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.operation')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-body text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.target')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-body text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.namespace')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-body text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.status')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-right font-body text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.duration')}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-body text-[10px] uppercase text-nothing-gray-500">
|
||||
{t('actionLog.columns.executor')}
|
||||
</th>
|
||||
<tr className="border-b border-[#e0ddd4] bg-white">
|
||||
<th className="px-5 py-3 font-semibold uppercase tracking-wider text-[#77736a] text-[10px]">{t('actionLog.columns.time')}</th>
|
||||
<th className="px-5 py-3 font-semibold uppercase tracking-wider text-[#77736a] text-[10px]">{t('actionLog.columns.operation')}</th>
|
||||
<th className="px-5 py-3 font-semibold uppercase tracking-wider text-[#77736a] text-[10px]">{t('actionLog.columns.target')}</th>
|
||||
<th className="px-5 py-3 font-semibold uppercase tracking-wider text-[#77736a] text-[10px]">{t('actionLog.columns.namespace')}</th>
|
||||
<th className="px-5 py-3 text-center font-semibold uppercase tracking-wider text-[#77736a] text-[10px]">{t('actionLog.columns.status')}</th>
|
||||
<th className="px-5 py-3 text-right font-semibold uppercase tracking-wider text-[#77736a] text-[10px]">{t('actionLog.columns.duration')}</th>
|
||||
<th className="px-5 py-3 font-semibold uppercase tracking-wider text-[#77736a] text-[10px]">{t('actionLog.columns.executor')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="divide-y divide-[#e0ddd4] bg-white">
|
||||
{logs.map((log) => (
|
||||
<tr
|
||||
key={log.id}
|
||||
className="border-b border-nothing-gray-100 hover:bg-nothing-gray-50/50 transition-colors"
|
||||
>
|
||||
<td className="px-3 py-3 font-body text-xs text-nothing-gray-600">
|
||||
<tr key={log.id} className="transition-colors hover:bg-[#faf9f3]">
|
||||
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-[#5f5b52]">
|
||||
{formatDate(log.created_at)}
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<td className="px-5 py-3">
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-[10px] font-body font-bold uppercase',
|
||||
log.operation_type === 'DELETE_POD' &&
|
||||
'bg-status-critical/10 text-status-critical',
|
||||
log.operation_type === 'RESTART_DEPLOYMENT' &&
|
||||
'bg-status-warning/10 text-status-warning',
|
||||
log.operation_type === 'SCALE_DEPLOYMENT' &&
|
||||
'bg-claw-blue/10 text-claw-blue'
|
||||
'inline-flex items-center rounded-md border px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-widest',
|
||||
log.operation_type === 'DELETE_POD'
|
||||
? 'border-[#f5cdb8] bg-[#fff7f3] text-[#d97757]'
|
||||
: log.operation_type === 'RESTART_DEPLOYMENT'
|
||||
? 'border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]'
|
||||
: log.operation_type === 'SCALE_DEPLOYMENT'
|
||||
? 'border-[#c7dcf7] bg-[#f0f6ff] text-[#4A90D9]'
|
||||
: 'border-[#e0ddd4] bg-[#f5f4ed] text-[#5f5b52]'
|
||||
)}
|
||||
>
|
||||
{t(`actionLog.operations.${log.operation_type}` as never) ||
|
||||
log.operation_type}
|
||||
{t(`actionLog.operations.${log.operation_type}` as never) || log.operation_type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3 font-body text-xs text-nothing-black max-w-[200px] truncate">
|
||||
<td className="max-w-[200px] truncate px-5 py-3 font-mono text-xs font-semibold text-[#141413]">
|
||||
{log.target_resource}
|
||||
</td>
|
||||
<td className="px-3 py-3 font-body text-xs text-nothing-gray-600">
|
||||
<td className="px-5 py-3 font-mono text-xs text-[#5f5b52]">
|
||||
{log.namespace}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-center">
|
||||
<td className="px-5 py-3 text-center">
|
||||
{log.success ? (
|
||||
<span className="inline-flex items-center gap-1 text-status-healthy">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span className="text-[10px] font-body uppercase">
|
||||
{t('actionLog.status.success')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-[#8fc29a] bg-[#f0faf2] px-2.5 py-0.5 text-[#17602a]">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">{t('actionLog.status.success')}</span>
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 text-status-critical">
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span className="text-[10px] font-body uppercase">
|
||||
{t('actionLog.status.failure')}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 rounded-full border border-[#f5cdb8] bg-[#fff7f3] px-2.5 py-0.5 text-[#d97757]">
|
||||
<XCircle className="h-3 w-3" />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">{t('actionLog.status.failure')}</span>
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 text-right font-body text-xs text-nothing-gray-600">
|
||||
<td className="whitespace-nowrap px-5 py-3 text-right font-mono text-xs tabular-nums text-[#5f5b52]">
|
||||
{formatDuration(log.execution_duration_ms)}
|
||||
</td>
|
||||
<td className="px-3 py-3 font-body text-xs text-nothing-gray-600">
|
||||
<td className="whitespace-nowrap px-5 py-3 font-mono text-xs text-[#5f5b52]">
|
||||
{log.executed_by}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -452,60 +393,35 @@ export function ActionLogsPanel() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 px-1">
|
||||
<span className="text-xs text-nothing-gray-500 font-body">
|
||||
{t('actionLog.pagination.page', {
|
||||
current: page,
|
||||
total: totalPages,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={page <= 1 || isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-body',
|
||||
'border border-nothing-gray-200',
|
||||
'hover:bg-nothing-gray-50 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
<ChevronLeft className="w-3 h-3" />
|
||||
{t('actionLog.pagination.prev')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={page >= totalPages || isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1 px-3 py-1.5 rounded-lg text-xs font-body',
|
||||
'border border-nothing-gray-200',
|
||||
'hover:bg-nothing-gray-50 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{t('actionLog.pagination.next')}
|
||||
<ChevronRight className="w-3 h-3" />
|
||||
</button>
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between border-t border-[#e0ddd4] bg-[#faf9f3] px-5 py-3">
|
||||
<span className="font-mono text-[11px] text-[#77736a]">
|
||||
{t('actionLog.pagination.page', { current: page, total: totalPages })}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handlePrevPage}
|
||||
disabled={page <= 1 || isLoading}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-[#e0ddd4] bg-white px-3 py-1.5 text-xs font-semibold text-[#5f5b52] transition-colors hover:border-[#d8d3c7] hover:bg-[#f5f4ed] disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
<ChevronLeft className="h-3 w-3" />
|
||||
{t('actionLog.pagination.prev')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleNextPage}
|
||||
disabled={page >= totalPages || isLoading}
|
||||
className="inline-flex items-center gap-1 rounded-lg border border-[#e0ddd4] bg-white px-3 py-1.5 text-xs font-semibold text-[#5f5b52] transition-colors hover:border-[#d8d3c7] hover:bg-[#f5f4ed] disabled:pointer-events-none disabled:opacity-40"
|
||||
>
|
||||
{t('actionLog.pagination.next')}
|
||||
<ChevronRight className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DataPincerPanel>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-12 pt-6 border-t border-nothing-gray-200">
|
||||
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||
<p className="text-sm text-nothing-gray-500">
|
||||
{t('footer.copyright')}
|
||||
</p>
|
||||
<p className="text-sm text-nothing-gray-400 font-body">
|
||||
{t('footer.poweredBy')} v1.0.0
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 供原始頁面和整合頁面 (/automation) 共用
|
||||
*
|
||||
* 建立時間: 2026-04-09 (台北時區)
|
||||
* 更新時間: 2026-07-04 — UI/UX 全面升級 (Option A 明亮模式)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
Wrench, RefreshCw, AlertCircle,
|
||||
CheckCircle2, XCircle, ShieldAlert,
|
||||
Play, ChevronDown, ChevronUp, Zap,
|
||||
Activity, ArrowRight, TrendingUp, TrendingDown,
|
||||
} from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
@@ -73,31 +75,10 @@ const getApiBaseUrl = () => {
|
||||
}
|
||||
|
||||
const RISK_STYLE: Record<string, string> = {
|
||||
LOW: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20',
|
||||
MEDIUM: 'bg-status-warning/10 text-status-warning border-status-warning/20',
|
||||
HIGH: 'bg-status-critical/10 text-status-critical border-status-critical/20',
|
||||
CRITICAL: 'bg-status-critical/10 text-status-critical border-status-critical/20',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// StatCard
|
||||
// =============================================================================
|
||||
|
||||
function StatCard({
|
||||
label, value, sub, highlight,
|
||||
}: { label: string; value: string | number; sub?: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className={cn(
|
||||
'rounded-lg border p-4',
|
||||
highlight ? 'bg-claw-blue/5 border-claw-blue/20' : 'bg-white border-nothing-gray-200'
|
||||
)}>
|
||||
<p className="text-[11px] font-body text-nothing-gray-500 uppercase tracking-wider mb-1">{label}</p>
|
||||
<p className={cn('text-2xl font-bold font-body tabular-nums', highlight ? 'text-claw-blue' : 'text-nothing-black')}>
|
||||
{value}
|
||||
</p>
|
||||
{sub && <p className="text-[11px] font-body text-nothing-gray-400 mt-0.5">{sub}</p>}
|
||||
</div>
|
||||
)
|
||||
LOW: 'bg-[#f0faf2] text-[#17602a] border-[#8fc29a]',
|
||||
MEDIUM: 'bg-[#fff7e8] text-[#8a5a08] border-[#d9b36f]',
|
||||
HIGH: 'bg-[#fff7f3] text-[#d97757] border-[#f5cdb8]',
|
||||
CRITICAL: 'bg-[#fff1f0] text-[#9f2f25] border-[#e2a29b]',
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
@@ -137,7 +118,6 @@ function IncidentEvalRow({
|
||||
setExecuting(true)
|
||||
try {
|
||||
const base = getApiBaseUrl()
|
||||
// Phase 20: CSRF Protection — 先取得 token 再執行
|
||||
const csrfRes = await fetch(`${base}/api/v1/csrf/token`, { method: 'GET', credentials: 'include' })
|
||||
if (!csrfRes.ok) throw new Error(`CSRF fetch failed: ${csrfRes.status}`)
|
||||
const csrfData = await csrfRes.json()
|
||||
@@ -154,80 +134,80 @@ function IncidentEvalRow({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border border-nothing-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="overflow-hidden rounded-xl border border-[#e0ddd4] bg-white transition-colors hover:border-[#d8d3c7]">
|
||||
<div
|
||||
className="flex items-center gap-3 px-4 py-3 bg-nothing-gray-50 cursor-pointer hover:bg-nothing-gray-100 transition-colors"
|
||||
className="flex cursor-pointer items-center gap-3 bg-[#faf9f3] px-4 py-3 transition-colors hover:bg-[#f5f4ed]"
|
||||
onClick={() => setExpanded(v => !v)}
|
||||
>
|
||||
<span className={cn(
|
||||
'px-2 py-0.5 rounded font-body text-[11px] font-bold',
|
||||
severity === 'P0' ? 'bg-status-critical/10 text-status-critical' :
|
||||
severity === 'P1' ? 'bg-status-warning/10 text-status-warning' :
|
||||
'bg-nothing-gray-100 text-nothing-gray-600'
|
||||
'rounded px-2 py-0.5 font-mono text-[11px] font-bold tracking-widest',
|
||||
severity === 'P0' ? 'bg-[#fff1f0] text-[#9f2f25]' :
|
||||
severity === 'P1' ? 'bg-[#fff7e8] text-[#8a5a08]' :
|
||||
'bg-[#f5f4ed] text-[#5f5b52]'
|
||||
)}>{severity}</span>
|
||||
<span className="font-body text-sm text-nothing-black flex-1 truncate">{incidentId}</span>
|
||||
<span className="flex-1 truncate font-mono text-sm font-semibold text-[#141413]">{incidentId}</span>
|
||||
|
||||
{loading && <RefreshCw className="w-4 h-4 animate-spin text-nothing-gray-400" />}
|
||||
{loading && <RefreshCw className="h-4 w-4 animate-spin text-[#d8d3c7]" />}
|
||||
{!loading && eval_ && (
|
||||
eval_.can_auto_repair
|
||||
? <span className="flex items-center gap-1 text-[11px] font-body text-status-healthy"><CheckCircle2 className="w-3.5 h-3.5" />{t('canAutoRepair')}</span>
|
||||
: <span className="flex items-center gap-1 text-[11px] font-body text-nothing-gray-400"><XCircle className="w-3.5 h-3.5" />{t('notEligibleShort')}</span>
|
||||
? <span className="flex items-center gap-1 font-mono text-[11px] font-bold text-[#17602a]"><CheckCircle2 className="h-3.5 w-3.5" />{t('canAutoRepair')}</span>
|
||||
: <span className="flex items-center gap-1 font-mono text-[11px] font-bold text-[#77736a]"><XCircle className="h-3.5 w-3.5" />{t('notEligibleShort')}</span>
|
||||
)}
|
||||
{expanded ? <ChevronUp className="w-4 h-4 text-nothing-gray-400" /> : <ChevronDown className="w-4 h-4 text-nothing-gray-400" />}
|
||||
{expanded ? <ChevronUp className="h-4 w-4 text-[#77736a]" /> : <ChevronDown className="h-4 w-4 text-[#77736a]" />}
|
||||
</div>
|
||||
|
||||
{expanded && eval_ && (
|
||||
<div className="px-4 py-4 bg-white space-y-3">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<span className="text-[10px] font-body text-nothing-gray-500 uppercase">Playbook</span>
|
||||
<p className="font-body text-nothing-black mt-0.5">{eval_.playbook_name ?? '—'}</p>
|
||||
<div className="space-y-4 px-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm sm:grid-cols-4">
|
||||
<div className="rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">Playbook</span>
|
||||
<p className="mt-1 truncate font-mono text-[#141413] font-semibold">{eval_.playbook_name ?? '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] font-body text-nothing-gray-500 uppercase">{t('riskLevel')}</span>
|
||||
<p className="mt-0.5">
|
||||
<span className={cn('px-2 py-0.5 rounded border text-[11px] font-body font-bold', RISK_STYLE[eval_.risk_level] ?? RISK_STYLE.MEDIUM)}>
|
||||
<div className="rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('riskLevel')}</span>
|
||||
<p className="mt-1">
|
||||
<span className={cn('rounded border px-2 py-0.5 text-[11px] font-bold tracking-widest', RISK_STYLE[eval_.risk_level] ?? RISK_STYLE.MEDIUM)}>
|
||||
{eval_.risk_level}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
{eval_.success_rate != null && (
|
||||
<div>
|
||||
<span className="text-[10px] font-body text-nothing-gray-500 uppercase">{t('successRate')}</span>
|
||||
<p className="font-body text-status-healthy font-bold mt-0.5">{(eval_.success_rate * 100).toFixed(1)}%</p>
|
||||
<div className="rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('successRate')}</span>
|
||||
<p className="mt-1 font-mono font-bold text-[#17602a]">{(eval_.success_rate * 100).toFixed(1)}%</p>
|
||||
</div>
|
||||
)}
|
||||
{eval_.total_executions != null && (
|
||||
<div>
|
||||
<span className="text-[10px] font-body text-nothing-gray-500 uppercase">{t('execCount')}</span>
|
||||
<p className="font-body text-nothing-black mt-0.5">{eval_.total_executions}</p>
|
||||
<div className="rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-3">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('execCount')}</span>
|
||||
<p className="mt-1 font-mono font-bold text-[#141413]">{eval_.total_executions}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-3 bg-nothing-gray-50 rounded-lg">
|
||||
<span className="text-[10px] font-body text-nothing-gray-500 uppercase">{t('decisionReason')}</span>
|
||||
<p className="text-sm font-body text-nothing-gray-700 mt-1">{eval_.reason}</p>
|
||||
<div className="rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-4">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('decisionReason')}</span>
|
||||
<p className="mt-1.5 font-mono text-sm text-[#5f5b52] leading-relaxed">{eval_.reason}</p>
|
||||
</div>
|
||||
|
||||
{result && (
|
||||
<div className={cn(
|
||||
'p-3 rounded-lg border',
|
||||
result.success ? 'bg-status-healthy/5 border-status-healthy/20' : 'bg-status-critical/5 border-status-critical/20'
|
||||
'rounded-lg border p-4',
|
||||
result.success ? 'bg-[#f0faf2] border-[#8fc29a]' : 'bg-[#fff1f0] border-[#e2a29b]'
|
||||
)}>
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{result.success
|
||||
? <CheckCircle2 className="w-4 h-4 text-status-healthy" />
|
||||
: <XCircle className="w-4 h-4 text-status-critical" />}
|
||||
<span className={cn('text-sm font-body font-bold', result.success ? 'text-status-healthy' : 'text-status-critical')}>
|
||||
? <CheckCircle2 className="h-5 w-5 text-[#17602a]" />
|
||||
: <XCircle className="h-5 w-5 text-[#9f2f25]" />}
|
||||
<span className={cn('font-mono text-sm font-bold', result.success ? 'text-[#17602a]' : 'text-[#9f2f25]')}>
|
||||
{result.success ? t('execSuccess', { ms: result.execution_time_ms }) : t('execFailed', { error: result.error ?? '' })}
|
||||
</span>
|
||||
</div>
|
||||
{result.executed_steps.length > 0 && (
|
||||
<ul className="space-y-1">
|
||||
<ul className="space-y-1.5">
|
||||
{result.executed_steps.map((step, i) => (
|
||||
<li key={i} className="text-xs font-body text-nothing-gray-600 flex items-start gap-1.5">
|
||||
<Zap className="w-3 h-3 mt-0.5 text-claw-blue flex-shrink-0" />
|
||||
<li key={i} className="flex items-start gap-2 font-mono text-xs text-[#5f5b52]">
|
||||
<ArrowRight className="mt-0.5 h-3 w-3 shrink-0 text-[#4A90D9]" />
|
||||
{step}
|
||||
</li>
|
||||
))}
|
||||
@@ -237,20 +217,22 @@ function IncidentEvalRow({
|
||||
)}
|
||||
|
||||
{eval_.can_auto_repair && !result && (
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={executing}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg',
|
||||
'bg-claw-blue text-white font-body text-sm font-semibold',
|
||||
'hover:bg-claw-blue/90 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
>
|
||||
{executing
|
||||
? <><RefreshCw className="w-4 h-4 animate-spin" />{t('executing')}</>
|
||||
: <><Play className="w-4 h-4" />{t('execute')}</>}
|
||||
</button>
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
onClick={handleExecute}
|
||||
disabled={executing}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-2 rounded-lg px-5 py-2.5',
|
||||
'bg-[#4A90D9] font-mono text-sm font-bold text-white shadow-md',
|
||||
'transition-all hover:bg-[#3b7bbd] hover:shadow-lg focus:ring-4 focus:ring-[#4A90D9]/30',
|
||||
'disabled:pointer-events-none disabled:opacity-50'
|
||||
)}
|
||||
>
|
||||
{executing
|
||||
? <><RefreshCw className="h-4 w-4 animate-spin" />{t('executing')}</>
|
||||
: <><Play className="h-4 w-4" />{t('execute')}</>}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -264,7 +246,6 @@ function IncidentEvalRow({
|
||||
|
||||
export function AutoRepairPanel() {
|
||||
const t = useTranslations('autoRepair')
|
||||
const tNav = useTranslations('nav')
|
||||
const tCommon = useTranslations('common')
|
||||
|
||||
const [stats, setStats] = useState<AutoRepairStats | null>(null)
|
||||
@@ -314,70 +295,117 @@ export function AutoRepairPanel() {
|
||||
return () => { abortRef.current?.abort() }
|
||||
}, [fetchStats])
|
||||
|
||||
const statCards = stats ? [
|
||||
{
|
||||
label: t('approvedPlaybooks'),
|
||||
value: stats.approved_playbooks.toLocaleString(),
|
||||
icon: Activity,
|
||||
accent: 'text-[#4A90D9]',
|
||||
bg: 'bg-[#f0f6ff]',
|
||||
border: 'border-[#c7dcf7]',
|
||||
},
|
||||
{
|
||||
label: t('highQualityPlaybooks'),
|
||||
value: stats.high_quality_playbooks.toLocaleString(),
|
||||
icon: CheckCircle2,
|
||||
accent: 'text-[#17602a]',
|
||||
bg: 'bg-[#f0faf2]',
|
||||
border: 'border-[#8fc29a]',
|
||||
},
|
||||
{
|
||||
label: t('totalExecutions'),
|
||||
value: stats.total_executions.toLocaleString(),
|
||||
icon: Zap,
|
||||
accent: 'text-[#d97757]',
|
||||
bg: 'bg-[#fff7f3]',
|
||||
border: 'border-[#f5cdb8]',
|
||||
},
|
||||
{
|
||||
label: t('overallSuccessRate'),
|
||||
value: `${(stats.overall_success_rate * 100).toFixed(1)}%`,
|
||||
icon: TrendingUp,
|
||||
accent: 'text-[#8a5a08]',
|
||||
bg: 'bg-[#fff7e8]',
|
||||
border: 'border-[#d9b36f]',
|
||||
},
|
||||
] : []
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-bold text-nothing-black flex items-center gap-2">
|
||||
<Wrench className="w-6 h-6" />
|
||||
{tNav('autoRepair')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-nothing-gray-500 font-body">
|
||||
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
|
||||
Automation
|
||||
</p>
|
||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-bold text-[#141413]">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[#77736a]">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={fetchStats}
|
||||
disabled={statsLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg',
|
||||
'text-xs font-body bg-nothing-gray-100 text-nothing-gray-600',
|
||||
'hover:bg-nothing-gray-200 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[#e0ddd4] bg-white text-[#77736a] transition hover:border-[#4A90D9] hover:text-[#4A90D9] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={tCommon('refresh')}
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', statsLoading && 'animate-spin')} />
|
||||
{tCommon('refresh')}
|
||||
<RefreshCw className={cn('h-4 w-4', statsLoading && 'animate-spin')} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{statsError && (
|
||||
<div className="mb-4 p-4 rounded-lg bg-status-critical/10 border border-status-critical/20 flex items-center gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-status-critical" />
|
||||
<span className="text-sm font-body text-status-critical">{statsError}</span>
|
||||
<div className="mb-6 flex items-start gap-3 rounded-xl border border-[#e2a29b] bg-[#fff1f0] p-4">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-[#9f2f25]" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="font-semibold text-[#141413]">{t('error')}</p>
|
||||
<p className="mt-1 font-mono text-xs text-[#77736a]">{statsError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
{/* Stats Cards */}
|
||||
{stats && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 mb-6">
|
||||
<StatCard label={t('approvedPlaybooks')} value={stats.approved_playbooks} />
|
||||
<StatCard label={t('highQualityPlaybooks')} value={stats.high_quality_playbooks} sub={t('highQualitySub')} highlight />
|
||||
<StatCard label={t('totalExecutions')} value={stats.total_executions} />
|
||||
<StatCard label={t('overallSuccessRate')} value={`${(stats.overall_success_rate * 100).toFixed(1)}%`} sub={stats.auto_repair_eligible ? t('eligible') : t('notEligible')} />
|
||||
<div className="mb-6 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{statCards.map(({ label, value, icon: Icon, accent, bg, border }) => (
|
||||
<div
|
||||
key={label}
|
||||
className={cn('rounded-xl border p-5 transition hover:shadow-sm', bg, border)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
|
||||
{label}
|
||||
</p>
|
||||
<Icon className={cn('h-4 w-4 shrink-0', accent)} aria-hidden="true" />
|
||||
</div>
|
||||
<p className={cn('mt-3 font-mono text-3xl font-bold tabular-nums', accent)}>
|
||||
{value}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disposition summary */}
|
||||
{disposition && disposition.total > 0 && (
|
||||
<div className="grid grid-cols-4 gap-2 mb-6">
|
||||
<div className="rounded-lg border border-green-500/25 bg-green-500/5 p-3 text-center">
|
||||
<p className="text-[10px] font-bold text-green-500 uppercase tracking-wider">{t('dispositionAuto')}</p>
|
||||
<p className="text-xl font-bold text-green-500 tabular-nums mt-1">{disposition.auto_repair}</p>
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<div className="rounded-xl border border-[#8fc29a] bg-[#f0faf2] p-4 text-center">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-[#17602a]">{t('dispositionAuto')}</p>
|
||||
<p className="mt-2 font-mono text-2xl font-bold tabular-nums text-[#17602a]">{disposition.auto_repair}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-orange-500/25 bg-orange-500/5 p-3 text-center">
|
||||
<p className="text-[10px] font-bold text-orange-500 uppercase tracking-wider">{t('dispositionHuman')}</p>
|
||||
<p className="text-xl font-bold text-orange-500 tabular-nums mt-1">{disposition.human_approved}</p>
|
||||
<div className="rounded-xl border border-[#d9b36f] bg-[#fff7e8] p-4 text-center">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-[#8a5a08]">{t('dispositionHuman')}</p>
|
||||
<p className="mt-2 font-mono text-2xl font-bold tabular-nums text-[#8a5a08]">{disposition.human_approved}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-purple-500/25 bg-purple-500/5 p-3 text-center">
|
||||
<p className="text-[10px] font-bold text-purple-500 uppercase tracking-wider">{t('dispositionManual')}</p>
|
||||
<p className="text-xl font-bold text-purple-500 tabular-nums mt-1">{disposition.manual_resolved}</p>
|
||||
<div className="rounded-xl border border-[#a491db] bg-[#f7f5ff] p-4 text-center">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-[#543b99]">{t('dispositionManual')}</p>
|
||||
<p className="mt-2 font-mono text-2xl font-bold tabular-nums text-[#543b99]">{disposition.manual_resolved}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border border-blue-500/25 bg-blue-500/5 p-3 text-center">
|
||||
<p className="text-[10px] font-bold text-blue-500 uppercase tracking-wider">{t('dispositionCold')}</p>
|
||||
<p className="text-xl font-bold text-blue-500 tabular-nums mt-1">{disposition.cold_start_trust}</p>
|
||||
<div className="rounded-xl border border-[#c7dcf7] bg-[#f0f6ff] p-4 text-center">
|
||||
<p className="text-[10px] font-bold uppercase tracking-wider text-[#1f5b9b]">{t('dispositionCold')}</p>
|
||||
<p className="mt-2 font-mono text-2xl font-bold tabular-nums text-[#1f5b9b]">{disposition.cold_start_trust}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -385,19 +413,24 @@ export function AutoRepairPanel() {
|
||||
{/* Eligible indicator */}
|
||||
{stats && (
|
||||
<div className={cn(
|
||||
'flex items-center gap-3 p-4 rounded-lg border mb-6',
|
||||
'mb-6 flex items-center gap-4 rounded-xl border p-5 transition-shadow hover:shadow-sm',
|
||||
stats.auto_repair_eligible
|
||||
? 'bg-status-healthy/5 border-status-healthy/20'
|
||||
: 'bg-nothing-gray-50 border-nothing-gray-200'
|
||||
? 'border-[#8fc29a] bg-[#f0faf2]'
|
||||
: 'border-[#e0ddd4] bg-white'
|
||||
)}>
|
||||
{stats.auto_repair_eligible
|
||||
? <CheckCircle2 className="w-5 h-5 text-status-healthy" />
|
||||
: <ShieldAlert className="w-5 h-5 text-nothing-gray-400" />}
|
||||
<div className={cn(
|
||||
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full',
|
||||
stats.auto_repair_eligible ? 'bg-[#8fc29a]/20 text-[#17602a]' : 'bg-[#f5f4ed] text-[#77736a]'
|
||||
)}>
|
||||
{stats.auto_repair_eligible
|
||||
? <CheckCircle2 className="h-5 w-5" aria-hidden="true" />
|
||||
: <ShieldAlert className="h-5 w-5" aria-hidden="true" />}
|
||||
</div>
|
||||
<div>
|
||||
<p className={cn('text-sm font-body font-semibold', stats.auto_repair_eligible ? 'text-status-healthy' : 'text-nothing-gray-600')}>
|
||||
<p className={cn('font-semibold', stats.auto_repair_eligible ? 'text-[#17602a]' : 'text-[#141413]')}>
|
||||
{stats.auto_repair_eligible ? t('ready') : t('notReady')}
|
||||
</p>
|
||||
<p className="text-xs font-body text-nothing-gray-400">
|
||||
<p className={cn('mt-0.5 text-sm', stats.auto_repair_eligible ? 'text-[#17602a]/80' : 'text-[#77736a]')}>
|
||||
{stats.auto_repair_eligible
|
||||
? t('readyDesc', { count: stats.high_quality_playbooks })
|
||||
: t('notReadyDesc')}
|
||||
@@ -407,34 +440,37 @@ export function AutoRepairPanel() {
|
||||
)}
|
||||
|
||||
{/* Incident evaluation list */}
|
||||
<div>
|
||||
<h3 className="text-[11px] font-body text-nothing-gray-500 uppercase tracking-widest mb-3">
|
||||
{t('incidentEval')}
|
||||
</h3>
|
||||
|
||||
{incidentsLoading && (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<RefreshCw className="w-5 h-5 animate-spin text-nothing-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!incidentsLoading && eligibleIncidents.length === 0 && (
|
||||
<div className="text-center py-10 border border-dashed border-nothing-gray-200 rounded-lg">
|
||||
<CheckCircle2 className="w-8 h-8 text-status-healthy mx-auto mb-2" />
|
||||
<p className="font-body text-sm text-nothing-gray-500">{t('noEligible')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{eligibleIncidents.map(incident => (
|
||||
<IncidentEvalRow
|
||||
key={incident.incident_id}
|
||||
incidentId={incident.incident_id}
|
||||
severity={incident.severity}
|
||||
/>
|
||||
))}
|
||||
<div className="overflow-hidden rounded-xl border border-[#e0ddd4] bg-white">
|
||||
<div className="flex items-center gap-2 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-3">
|
||||
<Wrench className="h-4 w-4 text-[#77736a]" aria-hidden="true" />
|
||||
<h2 className="font-semibold text-[#141413]">
|
||||
{t('incidentEval')}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{incidentsLoading ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-[#d8d3c7]" />
|
||||
<span className="mt-3 text-sm text-[#77736a]">{tCommon('loading')}</span>
|
||||
</div>
|
||||
) : eligibleIncidents.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-[#d8d3c7] bg-[#f5f4ed] py-12 text-[#77736a]">
|
||||
<CheckCircle2 className="mb-3 h-8 w-8 opacity-50" />
|
||||
<span className="text-sm font-medium">{t('noEligible')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{eligibleIncidents.map(incident => (
|
||||
<IncidentEvalRow
|
||||
key={incident.incident_id}
|
||||
incidentId={incident.incident_id}
|
||||
severity={incident.severity}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 供原始頁面和整合頁面 (/automation) 共用
|
||||
*
|
||||
* 建立時間: 2026-04-09 (台北時區)
|
||||
* 更新時間: 2026-07-04 — UI/UX 全面升級 (Option A 明亮模式)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
@@ -142,8 +143,8 @@ function DriftLevelBadge({ high, medium, info, t }: {
|
||||
}) {
|
||||
if (high === 0 && medium === 0 && info === 0) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium bg-status-healthy/10 text-status-healthy border border-status-healthy/20">
|
||||
<CheckCircle2 size={10} />
|
||||
<span className="inline-flex items-center gap-1 rounded bg-[#f0faf2] px-2 py-0.5 text-[11px] font-bold tracking-widest text-[#17602a]">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
{t('noDrift')}
|
||||
</span>
|
||||
)
|
||||
@@ -151,19 +152,19 @@ function DriftLevelBadge({ high, medium, info, t }: {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5">
|
||||
{high > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium bg-status-critical/10 text-status-critical border border-status-critical/20">
|
||||
<AlertTriangle size={10} />
|
||||
<span className="inline-flex items-center gap-1 rounded bg-[#fff1f0] px-2 py-0.5 text-[11px] font-bold tracking-widest text-[#9f2f25]">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
{t('highCount')} {high}
|
||||
</span>
|
||||
)}
|
||||
{medium > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium bg-status-warning/10 text-status-warning border border-status-warning/20">
|
||||
<Info size={10} />
|
||||
<span className="inline-flex items-center gap-1 rounded bg-[#fff7e8] px-2 py-0.5 text-[11px] font-bold tracking-widest text-[#8a5a08]">
|
||||
<Info className="h-3 w-3" />
|
||||
{t('mediumCount')} {medium}
|
||||
</span>
|
||||
)}
|
||||
{info > 0 && (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-medium bg-neutral-100 text-neutral-500 border border-neutral-200">
|
||||
<span className="inline-flex items-center gap-1 rounded bg-[#f5f4ed] px-2 py-0.5 text-[11px] font-bold tracking-widest text-[#5f5b52]">
|
||||
{t('infoCount')} {info}
|
||||
</span>
|
||||
)}
|
||||
@@ -173,16 +174,16 @@ function DriftLevelBadge({ high, medium, info, t }: {
|
||||
|
||||
function StatusBadge({ status, t }: { status: DriftReport['status']; t: (k: string) => string }) {
|
||||
const styles: Record<string, string> = {
|
||||
pending: 'bg-status-warning/10 text-status-warning border-status-warning/20',
|
||||
resolved: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20',
|
||||
acknowledged: 'bg-neutral-100 text-neutral-500 border-neutral-200',
|
||||
rolled_back: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20',
|
||||
adopted: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20',
|
||||
ignored: 'bg-neutral-100 text-neutral-400 border-neutral-200',
|
||||
pending: 'bg-[#fff7e8] text-[#8a5a08] border-[#d9b36f]',
|
||||
resolved: 'bg-[#f0faf2] text-[#17602a] border-[#8fc29a]',
|
||||
acknowledged: 'bg-[#f5f4ed] text-[#5f5b52] border-[#e0ddd4]',
|
||||
rolled_back: 'bg-[#f0faf2] text-[#17602a] border-[#8fc29a]',
|
||||
adopted: 'bg-[#f0faf2] text-[#17602a] border-[#8fc29a]',
|
||||
ignored: 'bg-[#faf9f3] text-[#77736a] border-[#e0ddd4]',
|
||||
}
|
||||
return (
|
||||
<span className={cn(
|
||||
'inline-flex items-center px-2 py-0.5 rounded text-[11px] font-medium border',
|
||||
'inline-flex items-center rounded-md border px-2 py-0.5 font-mono text-[10px] font-bold uppercase tracking-widest',
|
||||
styles[status] ?? styles.ignored
|
||||
)}>
|
||||
{t(status)}
|
||||
@@ -199,75 +200,77 @@ function DriftFingerprintStateCard({
|
||||
}) {
|
||||
if (!state) return null
|
||||
return (
|
||||
<div className="mx-6 mt-4 border border-neutral-200 bg-neutral-50 px-4 py-3">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="mb-6 overflow-hidden rounded-xl border border-[#e0ddd4] bg-white">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-4">
|
||||
<div className="flex min-w-0 items-start gap-2">
|
||||
<Fingerprint size={15} className="mt-0.5 shrink-0 text-neutral-500" />
|
||||
<Fingerprint className="mt-0.5 h-4 w-4 shrink-0 text-[#77736a]" />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-semibold text-neutral-800">
|
||||
<p className="font-semibold text-[#141413]">
|
||||
{t('fingerprintState.title')}
|
||||
</p>
|
||||
<p className="mt-1 truncate font-mono text-[11px] text-neutral-500">
|
||||
<p className="mt-1 truncate font-mono text-[11px] text-[#77736a]">
|
||||
{state.fingerprint ?? '--'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="border border-status-warning/20 bg-status-warning/10 px-2 py-0.5 text-[11px] font-medium text-status-warning">
|
||||
<span className="rounded bg-[#fff7e8] px-2 py-0.5 font-mono text-[11px] font-bold tracking-widest text-[#8a5a08]">
|
||||
{t('fingerprintState.occurrences', { count: state.occurrences_12h ?? 0 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-2 text-[11px] text-neutral-500 md:grid-cols-2">
|
||||
<p>{t('fingerprintState.report', { report: state.latest_report_id ?? '--' })}</p>
|
||||
<p>{t('fingerprintState.state', { state: state.fsm_state ?? '--' })}</p>
|
||||
<p>{t('fingerprintState.next', { step: state.next_step ?? '--' })}</p>
|
||||
<p>
|
||||
{t('fingerprintState.writes', {
|
||||
drift: String(state.writes_drift_status ?? false),
|
||||
incident: String(state.writes_incident_state ?? false),
|
||||
repair: String(state.writes_auto_repair_result ?? false),
|
||||
ticket: String(state.writes_ticket ?? false),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap items-center gap-2 text-[11px] text-neutral-500">
|
||||
<span className="inline-flex items-center gap-1 border border-neutral-200 bg-white px-2 py-0.5">
|
||||
<GitPullRequest size={11} />
|
||||
{t('fingerprintState.pr', {
|
||||
pr: state.open_pr?.number ?? '--',
|
||||
zeroDiff: String(state.open_pr?.is_zero_diff ?? false),
|
||||
})}
|
||||
</span>
|
||||
<span className="border border-neutral-200 bg-white px-2 py-0.5">
|
||||
{t('fingerprintState.p0Dedup', {
|
||||
hours: state.p0_escalation?.dedup_window_hours ?? 24,
|
||||
})}
|
||||
</span>
|
||||
<span className="border border-status-healthy/20 bg-status-healthy/10 px-2 py-0.5 text-status-healthy">
|
||||
{t('fingerprintState.remediation', {
|
||||
status: state.latest_remediation?.remediation_status ?? '--',
|
||||
report: state.latest_remediation?.verification_report_id ?? '--',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{state.latest_remediation ? (
|
||||
<div className="mt-3 border border-neutral-200 bg-white px-3 py-2 text-[11px] leading-5 text-neutral-500">
|
||||
<p>
|
||||
{t('fingerprintState.remediationKind', {
|
||||
kind: state.latest_remediation.remediation_kind ?? '--',
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t('fingerprintState.remediationVerification', {
|
||||
summary: state.latest_remediation.verification_summary?.summary ?? '--',
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t('fingerprintState.remediationNote', {
|
||||
note: state.latest_remediation.note ?? '--',
|
||||
<div className="px-5 py-4">
|
||||
<div className="grid gap-3 text-sm text-[#5f5b52] sm:grid-cols-2">
|
||||
<p className="flex items-center gap-2"><span className="font-semibold text-[#141413]">Report:</span> <code className="rounded bg-[#f5f4ed] px-1.5 py-0.5 font-mono text-xs">{state.latest_report_id ?? '--'}</code></p>
|
||||
<p className="flex items-center gap-2"><span className="font-semibold text-[#141413]">State:</span> <span className="font-mono text-xs text-[#d97757]">{state.fsm_state ?? '--'}</span></p>
|
||||
<p className="flex items-center gap-2"><span className="font-semibold text-[#141413]">Next:</span> <span className="font-mono text-xs text-[#4A90D9]">{state.next_step ?? '--'}</span></p>
|
||||
<p className="flex items-center gap-2 font-mono text-[11px]">
|
||||
{t('fingerprintState.writes', {
|
||||
drift: String(state.writes_drift_status ?? false),
|
||||
incident: String(state.writes_incident_state ?? false),
|
||||
repair: String(state.writes_auto_repair_result ?? false),
|
||||
ticket: String(state.writes_ticket ?? false),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-lg border border-[#e0ddd4] bg-[#faf9f3] px-2.5 py-1 font-mono text-[11px] text-[#5f5b52]">
|
||||
<GitPullRequest className="h-3 w-3" />
|
||||
{t('fingerprintState.pr', {
|
||||
pr: state.open_pr?.number ?? '--',
|
||||
zeroDiff: String(state.open_pr?.is_zero_diff ?? false),
|
||||
})}
|
||||
</span>
|
||||
<span className="rounded-lg border border-[#e0ddd4] bg-[#faf9f3] px-2.5 py-1 font-mono text-[11px] text-[#5f5b52]">
|
||||
{t('fingerprintState.p0Dedup', {
|
||||
hours: state.p0_escalation?.dedup_window_hours ?? 24,
|
||||
})}
|
||||
</span>
|
||||
<span className="rounded-lg border border-[#8fc29a] bg-[#f0faf2] px-2.5 py-1 font-mono text-[11px] text-[#17602a]">
|
||||
{t('fingerprintState.remediation', {
|
||||
status: state.latest_remediation?.remediation_status ?? '--',
|
||||
report: state.latest_remediation?.verification_report_id ?? '--',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{state.latest_remediation ? (
|
||||
<div className="mt-4 rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-3 text-[11px] leading-5 text-[#5f5b52]">
|
||||
<p>
|
||||
<strong className="text-[#141413] font-semibold">{t('fingerprintState.remediationKind', {
|
||||
kind: '',
|
||||
})}</strong> {state.latest_remediation.remediation_kind ?? '--'}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<strong className="text-[#141413] font-semibold">{t('fingerprintState.remediationVerification', {
|
||||
summary: '',
|
||||
})}</strong> {state.latest_remediation.verification_summary?.summary ?? '--'}
|
||||
</p>
|
||||
<p className="mt-1">
|
||||
<strong className="text-[#141413] font-semibold">{t('fingerprintState.remediationNote', {
|
||||
note: '',
|
||||
})}</strong> {state.latest_remediation.note ?? '--'}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -333,46 +336,46 @@ export function DriftPanel() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto bg-white">
|
||||
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-neutral-100">
|
||||
<div className="flex items-center gap-3">
|
||||
<Diff size={18} className="text-neutral-400" />
|
||||
<div>
|
||||
<h1 className="text-[13px] font-semibold text-neutral-800 leading-tight">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="text-[11px] text-neutral-400 leading-tight mt-0.5">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
|
||||
Infrastructure
|
||||
</p>
|
||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-bold text-[#141413]">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[#77736a]">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={fetchReports}
|
||||
disabled={loading}
|
||||
className="p-1.5 rounded text-neutral-400 hover:text-neutral-600 hover:bg-neutral-50 transition-colors disabled:opacity-40"
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[#e0ddd4] bg-white text-[#77736a] transition hover:border-[#4A90D9] hover:text-[#4A90D9] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
onClick={handleScan}
|
||||
disabled={scanning}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded text-[11px] font-medium transition-colors',
|
||||
'inline-flex h-9 items-center gap-2 rounded-lg px-4 font-mono text-sm font-bold shadow-sm transition-all',
|
||||
scanning
|
||||
? 'bg-neutral-100 text-neutral-400 cursor-not-allowed'
|
||||
: 'bg-neutral-900 text-white hover:bg-neutral-700'
|
||||
? 'pointer-events-none bg-[#e0ddd4] text-[#77736a]'
|
||||
: 'bg-[#141413] text-white hover:bg-[#141413]/80 hover:shadow-md'
|
||||
)}
|
||||
>
|
||||
{scanning ? (
|
||||
<>
|
||||
<RefreshCw size={11} className="animate-spin" />
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
{t('scanning')}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<GitMerge size={11} />
|
||||
<GitMerge className="h-4 w-4" />
|
||||
{t('scan')}
|
||||
</>
|
||||
)}
|
||||
@@ -383,25 +386,27 @@ export function DriftPanel() {
|
||||
{/* Scan Result Banner */}
|
||||
{scanResult && (
|
||||
<div className={cn(
|
||||
'mx-6 mt-4 px-4 py-3 rounded border text-[12px]',
|
||||
'mb-6 overflow-hidden rounded-xl border p-4 shadow-sm',
|
||||
scanResult.has_critical_drift
|
||||
? 'bg-status-critical/5 border-status-critical/20 text-status-critical'
|
||||
: 'bg-status-healthy/5 border-status-healthy/20 text-status-healthy'
|
||||
? 'border-[#e2a29b] bg-[#fff1f0]'
|
||||
: 'border-[#8fc29a] bg-[#f0faf2]'
|
||||
)}>
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<div className="flex items-center gap-2 font-semibold">
|
||||
{scanResult.has_critical_drift
|
||||
? <AlertTriangle size={13} />
|
||||
: <CheckCircle2 size={13} />
|
||||
? <AlertTriangle className="h-5 w-5 text-[#9f2f25]" />
|
||||
: <CheckCircle2 className="h-5 w-5 text-[#17602a]" />
|
||||
}
|
||||
{scanResult.summary}
|
||||
<span className={scanResult.has_critical_drift ? 'text-[#9f2f25]' : 'text-[#17602a]'}>
|
||||
{scanResult.summary}
|
||||
</span>
|
||||
{(scanResult.high_count > 0 || scanResult.medium_count > 0) && (
|
||||
<span className="ml-2 text-neutral-500 font-normal">
|
||||
<span className={cn('ml-2 font-mono text-sm', scanResult.has_critical_drift ? 'text-[#9f2f25]/80' : 'text-[#17602a]/80')}>
|
||||
— {t('highCount')} {scanResult.high_count}, {t('mediumCount')} {scanResult.medium_count}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{formatInterpretation(scanResult.interpretation) && (
|
||||
<p className="mt-1.5 text-neutral-600 font-normal pl-5">
|
||||
<p className={cn('mt-2 pl-7 font-mono text-sm', scanResult.has_critical_drift ? 'text-[#9f2f25]/90' : 'text-[#17602a]/90')}>
|
||||
{formatInterpretation(scanResult.interpretation)}
|
||||
</p>
|
||||
)}
|
||||
@@ -410,66 +415,78 @@ export function DriftPanel() {
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mx-6 mt-4 px-4 py-3 rounded border border-status-critical/20 bg-status-critical/5 text-status-critical text-[12px]">
|
||||
{error}
|
||||
<div className="mb-6 flex items-start gap-3 rounded-xl border border-[#e2a29b] bg-[#fff1f0] p-4">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-[#9f2f25]" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="font-semibold text-[#141413]">Error</p>
|
||||
<p className="mt-1 font-mono text-xs text-[#9f2f25]">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DriftFingerprintStateCard state={fingerprintState} t={t} />
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 px-6 py-4">
|
||||
{loading && reports.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-32 text-neutral-400">
|
||||
<RefreshCw size={16} className="animate-spin mr-2" />
|
||||
<span className="text-[12px]">{t('loading')}</span>
|
||||
</div>
|
||||
) : reports.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 text-neutral-400">
|
||||
<Terminal size={32} className="mb-3 opacity-30" />
|
||||
<p className="text-[13px] font-medium text-neutral-500">{t('noReports')}</p>
|
||||
<p className="text-[11px] text-neutral-400 mt-1 text-center max-w-xs">{t('noReportsHint')}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{reports.map((report) => (
|
||||
<div
|
||||
key={report.report_id}
|
||||
className="border border-neutral-100 rounded-lg px-4 py-3 hover:border-neutral-200 transition-colors"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<code className="text-[11px] font-mono text-neutral-500 bg-neutral-50 px-1.5 py-0.5 rounded shrink-0">
|
||||
{report.report_id.slice(0, 8)}
|
||||
</code>
|
||||
<DriftLevelBadge
|
||||
high={report.high_count}
|
||||
medium={report.medium_count}
|
||||
info={report.info_count}
|
||||
t={t}
|
||||
/>
|
||||
{/* Reports List */}
|
||||
<div className="overflow-hidden rounded-xl border border-[#e0ddd4] bg-white">
|
||||
<div className="flex items-center gap-2 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-3">
|
||||
<Diff className="h-4 w-4 text-[#77736a]" aria-hidden="true" />
|
||||
<h2 className="font-semibold text-[#141413]">
|
||||
Scan Reports
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
{loading && reports.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-[#d8d3c7]" />
|
||||
<span className="mt-3 text-sm text-[#77736a]">{t('loading')}</span>
|
||||
</div>
|
||||
) : reports.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-[#d8d3c7] bg-[#f5f4ed] py-12 text-[#77736a]">
|
||||
<Terminal className="mb-3 h-8 w-8 opacity-50" />
|
||||
<span className="font-medium text-[#141413]">{t('noReports')}</span>
|
||||
<span className="mt-1 text-sm">{t('noReportsHint')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{reports.map((report) => (
|
||||
<div
|
||||
key={report.report_id}
|
||||
className="rounded-xl border border-[#e0ddd4] p-4 transition-colors hover:border-[#d8d3c7] hover:bg-[#faf9f3]"
|
||||
>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<code className="rounded bg-[#f5f4ed] px-2 py-1 font-mono text-xs font-bold text-[#5f5b52]">
|
||||
{report.report_id.slice(0, 8)}
|
||||
</code>
|
||||
<DriftLevelBadge
|
||||
high={report.high_count}
|
||||
medium={report.medium_count}
|
||||
info={report.info_count}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 shrink-0">
|
||||
<StatusBadge status={report.status} t={t} />
|
||||
<span className="flex items-center gap-1 font-mono text-xs text-[#77736a]">
|
||||
<Clock className="h-3 w-3" />
|
||||
{fmtTime(report.scanned_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<StatusBadge status={report.status} t={t} />
|
||||
<span className="text-[11px] text-neutral-400 flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{fmtTime(report.scanned_at)}
|
||||
</span>
|
||||
{formatInterpretation(report.interpretation) && (
|
||||
<p className="mt-3 border-l-2 border-[#e0ddd4] pl-3 font-mono text-sm text-[#5f5b52]">
|
||||
{formatInterpretation(report.interpretation)}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-4 flex flex-wrap items-center gap-x-6 gap-y-2 font-mono text-xs text-[#77736a]">
|
||||
<span><strong className="text-[#141413] font-semibold">{t('namespace')}:</strong> {report.namespace}</span>
|
||||
<span><strong className="text-[#141413] font-semibold">{t('triggeredBy')}:</strong> {report.triggered_by}</span>
|
||||
</div>
|
||||
</div>
|
||||
{formatInterpretation(report.interpretation) && (
|
||||
<p className="mt-2 text-[11px] text-neutral-500 pl-1 border-l-2 border-neutral-100">
|
||||
{formatInterpretation(report.interpretation)}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex items-center gap-4 mt-2 text-[10px] text-neutral-400">
|
||||
<span>{t('namespace')}: <span className="font-mono">{report.namespace}</span></span>
|
||||
<span>{t('triggeredBy')}: {report.triggered_by}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
/**
|
||||
* ErrorsPanel — 錯誤追蹤面板 (不含 AppLayout)
|
||||
* =====================================
|
||||
* Sprint 5: 從 /errors/page.tsx 抽取
|
||||
* 2026-07-04 Codex: UI/UX 全面升級 (Option A 明亮模式)
|
||||
*/
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
@@ -15,8 +17,9 @@ import {
|
||||
ErrorTrendChart,
|
||||
UXAuditCard,
|
||||
} from '@/components/errors'
|
||||
import { Bug, RefreshCw } from 'lucide-react'
|
||||
import { Bug, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import type { SentryIssue } from '@/lib/api-client'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function ErrorsPanel() {
|
||||
const t = useTranslations('errors')
|
||||
@@ -30,32 +33,34 @@ export function ErrorsPanel() {
|
||||
return (
|
||||
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6" style={{ maxWidth: 1280, margin: '0 auto' }}>
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-xl border border-[#e0ddd4] bg-white">
|
||||
<Bug className="h-5 w-5 text-[#cc2200]" aria-hidden="true" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">Sentry</p>
|
||||
<h1 className="text-2xl font-bold text-[#141413]">{t('title')}</h1>
|
||||
<p className="mt-0.5 text-sm text-[#77736a]">{t('subtitle')}</p>
|
||||
</div>
|
||||
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
|
||||
Sentry
|
||||
</p>
|
||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-bold text-[#141413]">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[#77736a]">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={refetch}
|
||||
disabled={loading}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-[#e0ddd4] bg-white px-3 py-2 text-sm font-semibold text-[#77736a] transition hover:border-[#d97757] hover:text-[#d97757] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
className="inline-flex h-9 shrink-0 items-center justify-center gap-2 rounded-lg border border-[#e0ddd4] bg-white px-3 text-xs font-semibold text-[#77736a] transition hover:border-[#4A90D9] hover:text-[#4A90D9] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden="true" />
|
||||
{loading ? t('loading') : t('refresh')}
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} aria-hidden="true" />
|
||||
<span className="hidden sm:inline">{loading ? t('loading') : t('refresh')}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<IwoooSReadOnlyBridge />
|
||||
|
||||
{error && (
|
||||
<div className="mb-6 flex items-start gap-3 rounded-xl border border-[#f5b8b8] bg-[#fff0ed] p-4">
|
||||
<p className="text-sm text-[#cc2200]">{error}</p>
|
||||
<div className="mb-6 flex items-start gap-3 rounded-xl border border-[#e2a29b] bg-[#fff1f0] p-4 font-semibold text-[#9f2f25]">
|
||||
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<p className="text-sm">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -71,7 +76,9 @@ export function ErrorsPanel() {
|
||||
</div>
|
||||
|
||||
<div className="mt-6 border-t border-[#e0ddd4] pt-4">
|
||||
<p className="text-center font-mono text-[11px] text-[#b0ad9f]">{t('footerInfo')}</p>
|
||||
<p className="text-center font-mono text-[11px] font-semibold tracking-wider text-[#b0ad9f]">
|
||||
{t('footerInfo')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* 零假數據: 串接真實 /api/v1/dashboard API
|
||||
*
|
||||
* 建立時間: 2026-04-09 (台北時區)
|
||||
* 更新時間: 2026-07-04 — UI/UX 全面升級 (Option A 明亮模式)
|
||||
*/
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
@@ -42,6 +43,8 @@ import {
|
||||
Wrench,
|
||||
AlertCircle,
|
||||
XCircle,
|
||||
Server,
|
||||
Zap,
|
||||
} from 'lucide-react'
|
||||
|
||||
// =============================================================================
|
||||
@@ -114,10 +117,10 @@ function compactPath(value: string | null | undefined, emptyLabel: string) {
|
||||
}
|
||||
|
||||
function evidenceToneClass(tone: EvidenceTone) {
|
||||
if (tone === 'success') return 'border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]'
|
||||
if (tone === 'blocked') return 'border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]'
|
||||
if (tone === 'success') return 'border-[#8fc29a] bg-[#f0faf2] text-[#17602a]'
|
||||
if (tone === 'blocked') return 'border-[#e2a29b] bg-[#fff1f0] text-[#9f2f25]'
|
||||
if (tone === 'warning') return 'border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]'
|
||||
return 'border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]'
|
||||
return 'border-[#e0ddd4] bg-[#faf9f3] text-[#5f5b52]'
|
||||
}
|
||||
|
||||
function providerLabel(provider: string) {
|
||||
@@ -128,17 +131,17 @@ function providerLabel(provider: string) {
|
||||
}
|
||||
|
||||
const STATUS_ICON = {
|
||||
healthy: <CheckCircle2 className="w-4 h-4 text-status-healthy" />,
|
||||
warning: <AlertCircle className="w-4 h-4 text-status-warning" />,
|
||||
critical: <XCircle className="w-4 h-4 text-status-critical" />,
|
||||
unknown: <Minus className="w-4 h-4 text-nothing-gray-400" />,
|
||||
healthy: <CheckCircle2 className="h-3 w-3" />,
|
||||
warning: <AlertCircle className="h-3 w-3" />,
|
||||
critical: <XCircle className="h-3 w-3" />,
|
||||
unknown: <Minus className="h-3 w-3" />,
|
||||
}
|
||||
|
||||
const STATUS_BADGE = {
|
||||
healthy: 'bg-status-healthy/10 text-status-healthy border-status-healthy/20',
|
||||
warning: 'bg-status-warning/10 text-status-warning border-status-warning/20',
|
||||
critical: 'bg-status-critical/10 text-status-critical border-status-critical/20',
|
||||
unknown: 'bg-nothing-gray-100 text-nothing-gray-500 border-nothing-gray-200',
|
||||
healthy: 'bg-[#f0faf2] text-[#17602a] border-[#8fc29a]',
|
||||
warning: 'bg-[#fff7e8] text-[#8a5a08] border-[#d9b36f]',
|
||||
critical: 'bg-[#fff1f0] text-[#9f2f25] border-[#e2a29b]',
|
||||
unknown: 'bg-[#f5f4ed] text-[#5f5b52] border-[#e0ddd4]',
|
||||
}
|
||||
|
||||
function HealthSummary({ data, t }: { data: DashboardResponse; t: (key: string) => string }) {
|
||||
@@ -146,18 +149,21 @@ function HealthSummary({ data, t }: { data: DashboardResponse; t: (key: string)
|
||||
const pct = (count: number) => total > 0 ? `${Math.round(count / total * 100)}%` : '0%'
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-3 gap-3 mb-6">
|
||||
<div className="mb-6 grid grid-cols-3 gap-3">
|
||||
{[
|
||||
{ label: t('healthy'), count: data.healthy_count, color: 'text-status-healthy', bg: 'bg-status-healthy/10' },
|
||||
{ label: t('warning'), count: data.warning_count, color: 'text-status-warning', bg: 'bg-status-warning/10' },
|
||||
{ label: t('critical'), count: data.critical_count, color: 'text-status-critical', bg: 'bg-status-critical/10' },
|
||||
{ label: t('healthy'), count: data.healthy_count, color: 'text-[#17602a]', bg: 'bg-[#f0faf2]', border: 'border-[#8fc29a]' },
|
||||
{ label: t('warning'), count: data.warning_count, color: 'text-[#8a5a08]', bg: 'bg-[#fff7e8]', border: 'border-[#d9b36f]' },
|
||||
{ label: t('critical'), count: data.critical_count, color: 'text-[#9f2f25]', bg: 'bg-[#fff1f0]', border: 'border-[#e2a29b]' },
|
||||
].map(item => (
|
||||
<div key={item.label} className={cn('rounded-lg border p-4 text-center', item.bg, 'border-transparent')}>
|
||||
<div className={cn('text-3xl font-bold font-body tabular-nums', item.color)}>
|
||||
{item.count}
|
||||
</div>
|
||||
<div className="text-[11px] font-body text-nothing-gray-500 uppercase tracking-wider mt-1">
|
||||
{item.label} · {pct(item.count)}
|
||||
<div key={item.label} className={cn('rounded-xl border p-5 transition hover:shadow-sm', item.bg, item.border)}>
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">{item.label}</p>
|
||||
<div className="mt-3 flex items-baseline gap-2">
|
||||
<span className={cn('font-mono text-3xl font-bold tabular-nums', item.color)}>
|
||||
{item.count}
|
||||
</span>
|
||||
<span className="font-mono text-xs font-semibold text-[#77736a]">
|
||||
({pct(item.count)})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -179,15 +185,15 @@ function EvidenceTile({
|
||||
tone?: EvidenceTone
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('min-w-0 overflow-hidden border bg-white p-4', evidenceToneClass(tone))}>
|
||||
<div className={cn('min-w-0 overflow-hidden border p-4 transition hover:bg-white', evidenceToneClass(tone))}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Icon className="h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
<p className="truncate text-xs font-semibold uppercase tracking-wider">{label}</p>
|
||||
<p className="truncate text-[11px] font-semibold uppercase tracking-wider">{label}</p>
|
||||
</div>
|
||||
<p className="mt-3 truncate font-mono text-base font-semibold text-[#141413]" title={String(value)}>
|
||||
<p className="mt-3 truncate font-mono text-base font-bold text-[#141413]" title={String(value)}>
|
||||
{value}
|
||||
</p>
|
||||
<p className="mt-2 line-clamp-2 text-xs leading-5 text-[#5f5b52]" title={detail}>
|
||||
<p className="mt-2 line-clamp-2 font-mono text-[10px] leading-relaxed text-[#77736a]" title={detail}>
|
||||
{detail}
|
||||
</p>
|
||||
</div>
|
||||
@@ -280,23 +286,23 @@ function IncidentObservabilityFocus({
|
||||
return (
|
||||
<section
|
||||
data-testid="monitoring-incident-focus"
|
||||
className="mb-6 overflow-hidden border border-[#e0ddd4] bg-white"
|
||||
className="mb-6 overflow-hidden rounded-xl border border-[#e0ddd4] bg-white shadow-sm"
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn('inline-flex h-8 w-8 items-center justify-center border', evidenceToneClass(handoffTone))}>
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className={cn('inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border', evidenceToneClass(handoffTone))}>
|
||||
{chain?.needs_human ? (
|
||||
<TriangleAlert className="h-4 w-4" aria-hidden="true" />
|
||||
<TriangleAlert className="h-5 w-5" aria-hidden="true" />
|
||||
) : (
|
||||
<Monitor className="h-4 w-4" aria-hidden="true" />
|
||||
<Monitor className="h-5 w-5" aria-hidden="true" />
|
||||
)}
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h3 className="truncate text-base font-semibold text-[#141413]">
|
||||
<h3 className="truncate font-semibold text-[#141413]">
|
||||
{t('title')}
|
||||
</h3>
|
||||
<p className="mt-1 text-xs leading-5 text-[#77736a]">
|
||||
<p className="mt-1 font-mono text-xs text-[#77736a]">
|
||||
{t('subtitle', {
|
||||
incidentId,
|
||||
incidentTitle: timeline?.title ?? valueOrEmpty(chain?.source_id, emptyLabel),
|
||||
@@ -310,24 +316,25 @@ function IncidentObservabilityFocus({
|
||||
<Link
|
||||
key={label}
|
||||
href={href}
|
||||
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2.5 py-1.5 text-xs font-semibold text-[#3f3a32] hover:bg-[#f5f1e8]"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[#e0ddd4] bg-white px-3 py-1.5 text-xs font-semibold text-[#5f5b52] transition-colors hover:border-[#d8d3c7] hover:bg-[#f5f4ed]"
|
||||
>
|
||||
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{label}
|
||||
<ExternalLink className="h-3 w-3" aria-hidden="true" />
|
||||
<ExternalLink className="h-3 w-3 text-[#d8d3c7]" aria-hidden="true" />
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{state.error && (
|
||||
<div className="border-b border-[#e0ddd4] bg-[#fff0ef] px-4 py-3 text-xs font-semibold text-[#9f2f25]" role="alert">
|
||||
<div className="border-b border-[#e2a29b] bg-[#fff1f0] px-5 py-3 text-xs font-semibold text-[#9f2f25]" role="alert">
|
||||
{t('loadFailed', { error: state.error })}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{state.loading && !chain && !timeline && (
|
||||
<div className="border-b border-[#e0ddd4] px-4 py-3 text-xs text-[#77736a]">
|
||||
<div className="border-b border-[#e0ddd4] px-5 py-4 text-sm text-[#77736a] flex items-center gap-2">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" />
|
||||
{t('loading')}
|
||||
</div>
|
||||
)}
|
||||
@@ -402,31 +409,31 @@ function IncidentObservabilityFocus({
|
||||
</div>
|
||||
|
||||
<div className="grid min-w-0 gap-px bg-[#e0ddd4] lg:grid-cols-2">
|
||||
<div className="min-w-0 bg-white px-4 py-4">
|
||||
<div className="min-w-0 bg-white px-5 py-5">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h4 className="text-sm font-semibold text-[#141413]">{t('providerEvidence.title')}</h4>
|
||||
<span className="text-xs font-semibold text-[#77736a]">
|
||||
<span className="rounded bg-[#f5f4ed] px-2 py-0.5 font-mono text-[10px] font-bold tracking-widest text-[#77736a]">
|
||||
{t('providerEvidence.rawIdsHidden')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">
|
||||
<p className="mt-3 font-mono text-xs leading-relaxed text-[#77736a]">
|
||||
{t('providerEvidence.summary', {
|
||||
status: sourceStatusLabel,
|
||||
reason: sourceReasonLabel,
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{(providerEntries.length > 0 ? providerEntries : [['sentry', null], ['signoz', null]] as const).map(([provider, stats]) => (
|
||||
<div key={provider} className="border border-[#e0ddd4] bg-[#faf9f3] p-3">
|
||||
<p className="text-xs font-semibold text-[#141413]">{providerLabel(provider)}</p>
|
||||
<p className="mt-2 font-mono text-xs text-[#3f3a32]">
|
||||
<div key={provider} className="rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-4 transition-colors hover:border-[#d8d3c7]">
|
||||
<p className="font-semibold text-[#141413]">{providerLabel(provider)}</p>
|
||||
<p className="mt-2 font-mono text-xs text-[#5f5b52]">
|
||||
{t('providerEvidence.counts', {
|
||||
direct: stats?.direct_ref_total ?? 0,
|
||||
candidate: stats?.candidate_total ?? 0,
|
||||
applied: stats?.applied_link_total ?? 0,
|
||||
})}
|
||||
</p>
|
||||
<p className="mt-2 text-xs leading-5 text-[#77736a]">
|
||||
<p className="mt-2 font-mono text-[10px] leading-relaxed text-[#77736a]">
|
||||
{t('providerEvidence.latest', {
|
||||
event: formatDateTime(stats?.latest_event_at, emptyLabel),
|
||||
heartbeat: formatDateTime(stats?.latest_heartbeat_at, emptyLabel),
|
||||
@@ -437,31 +444,31 @@ function IncidentObservabilityFocus({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 bg-white px-4 py-4">
|
||||
<div className="min-w-0 bg-white px-5 py-5">
|
||||
<h4 className="text-sm font-semibold text-[#141413]">{t('timeline.title')}</h4>
|
||||
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">
|
||||
<p className="mt-3 font-mono text-xs leading-relaxed text-[#77736a]">
|
||||
{t('timeline.summary', {
|
||||
status: valueOrEmpty(timeline?.status, emptyLabel),
|
||||
severity: valueOrEmpty(timeline?.severity, emptyLabel),
|
||||
stages: timeline?.timeline?.length ?? 0,
|
||||
})}
|
||||
</p>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2">
|
||||
{timelineStages.map((stage) => (
|
||||
<div key={`${stage.stage}-${stage.status}-${stage.timestamp ?? ''}`} className="border border-[#e0ddd4] bg-[#faf9f3] p-3">
|
||||
<div key={`${stage.stage}-${stage.status}-${stage.timestamp ?? ''}`} className="rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-3 transition-colors hover:border-[#d8d3c7]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="truncate text-xs font-semibold text-[#141413]" title={stage.label || stage.stage}>
|
||||
{stage.label || stage.stage}
|
||||
</p>
|
||||
<span className="shrink-0 font-mono text-[11px] text-[#77736a]">{stage.status}</span>
|
||||
<span className="shrink-0 rounded bg-white px-1.5 py-0.5 font-mono text-[10px] font-bold tracking-widest text-[#77736a] border border-[#e0ddd4]">{stage.status}</span>
|
||||
</div>
|
||||
<p className="mt-2 truncate text-xs text-[#5f5b52]" title={valueOrEmpty(stage.source_table, emptyLabel)}>
|
||||
<p className="mt-2 truncate font-mono text-[10px] text-[#77736a]" title={valueOrEmpty(stage.source_table, emptyLabel)}>
|
||||
{t('timeline.sourceTable', { table: valueOrEmpty(stage.source_table, emptyLabel) })}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
{timelineStages.length === 0 && (
|
||||
<div className="border border-[#e0ddd4] bg-[#faf9f3] p-3 text-xs text-[#77736a]">
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed border-[#d8d3c7] bg-[#f5f4ed] p-6 text-sm text-[#77736a]">
|
||||
{t('timeline.empty')}
|
||||
</div>
|
||||
)}
|
||||
@@ -469,15 +476,15 @@ function IncidentObservabilityFocus({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-[#e0ddd4] bg-[#faf9f3] px-4 py-3 text-xs leading-5 text-[#5f5b52]">
|
||||
<div className="border-t border-[#e0ddd4] bg-[#faf9f3] px-5 py-3 font-mono text-xs leading-relaxed text-[#5f5b52]">
|
||||
{t('boundary', {
|
||||
playbook: compactPath(ansible?.latest_playbook_path, emptyLabel),
|
||||
executor: valueOrEmpty(chain?.execution?.latest_executor, emptyLabel),
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden">
|
||||
<AwoooPStatusChainPanel chain={chain} compact className="border-x-0 border-b-0" />
|
||||
<div className="overflow-hidden bg-white">
|
||||
<AwoooPStatusChainPanel chain={chain} compact className="border-x-0 border-b-0 border-t border-[#e0ddd4]" />
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
@@ -607,30 +614,27 @@ export function MonitoringPanel() {
|
||||
}, [fetchDashboard, fetchIncidentFocus])
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h2 className="font-heading text-2xl font-bold text-nothing-black flex items-center gap-2">
|
||||
<Monitor className="w-6 h-6" />
|
||||
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
|
||||
Observability
|
||||
</p>
|
||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-bold text-[#141413]">
|
||||
{tNav('monitoring')}
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-nothing-gray-500 font-body">
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[#77736a]">
|
||||
{tAlerts('autoRefresh', { seconds: 30 })}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
className={cn(
|
||||
'flex items-center gap-1.5 px-3 py-1.5 rounded-lg',
|
||||
'text-xs font-body bg-nothing-gray-100 text-nothing-gray-600',
|
||||
'hover:bg-nothing-gray-200 transition-colors',
|
||||
'disabled:opacity-50 disabled:cursor-not-allowed'
|
||||
)}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[#e0ddd4] bg-white text-[#77736a] transition hover:border-[#4A90D9] hover:text-[#4A90D9] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
aria-label={tCommon('refresh')}
|
||||
>
|
||||
<RefreshCw className={cn('w-3.5 h-3.5', isLoading && 'animate-spin')} />
|
||||
{tCommon('refresh')}
|
||||
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -643,49 +647,72 @@ export function MonitoringPanel() {
|
||||
)}
|
||||
|
||||
{/* Gold Metrics */}
|
||||
{metrics && <GlobalPulseChart metrics={metrics} />}
|
||||
{metrics && (
|
||||
<div className="mb-6">
|
||||
<GlobalPulseChart metrics={metrics} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error */}
|
||||
{dashError && (
|
||||
<div className="mb-6 flex items-start gap-3 rounded-xl border border-[#e2a29b] bg-[#fff1f0] p-4">
|
||||
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-[#9f2f25]" aria-hidden="true" />
|
||||
<div>
|
||||
<p className="font-semibold text-[#141413]">Dashboard Error</p>
|
||||
<p className="mt-1 font-mono text-xs text-[#9f2f25]">{dashError}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Health Summary */}
|
||||
{dashboard && <HealthSummary data={dashboard} t={(key) => t(key)} />}
|
||||
|
||||
{/* Host Grid */}
|
||||
{hosts.length > 0 && (
|
||||
<div className="mb-6">
|
||||
<h3 className="text-sm font-bold font-body text-nothing-gray-700 mb-3 uppercase tracking-wider">
|
||||
{t('hostStatus')}
|
||||
</h3>
|
||||
<HostGrid hosts={hosts as any} />
|
||||
<div className="mb-6 overflow-hidden rounded-xl border border-[#e0ddd4] bg-white">
|
||||
<div className="flex items-center gap-2 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-3">
|
||||
<Server className="h-4 w-4 text-[#77736a]" aria-hidden="true" />
|
||||
<h3 className="font-semibold text-[#141413]">
|
||||
{t('hostStatus')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="p-5">
|
||||
<HostGrid hosts={hosts as any} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service Table */}
|
||||
{dashboard?.services && dashboard.services.length > 0 && (
|
||||
<div>
|
||||
<h3 className="text-sm font-bold font-body text-nothing-gray-700 mb-3 uppercase tracking-wider">
|
||||
{t('serviceHealth')}
|
||||
</h3>
|
||||
<div className="overflow-hidden rounded-xl border border-[#e0ddd4] bg-white">
|
||||
<div className="flex items-center gap-2 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-3">
|
||||
<Activity className="h-4 w-4 text-[#77736a]" aria-hidden="true" />
|
||||
<h3 className="font-semibold text-[#141413]">
|
||||
{t('serviceHealth')}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm font-body">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-nothing-gray-200">
|
||||
<th className="text-left py-2 text-nothing-gray-500 font-medium">{t('service')}</th>
|
||||
<th className="text-left py-2 text-nothing-gray-500 font-medium">{t('status')}</th>
|
||||
<th className="text-right py-2 text-nothing-gray-500 font-medium">{t('latency')}</th>
|
||||
<th className="text-right py-2 text-nothing-gray-500 font-medium">{t('uptime')}</th>
|
||||
<tr className="border-b border-[#e0ddd4] bg-white">
|
||||
<th className="px-5 py-3 text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('service')}</th>
|
||||
<th className="px-5 py-3 text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('status')}</th>
|
||||
<th className="px-5 py-3 text-right text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('latency')}</th>
|
||||
<th className="px-5 py-3 text-right text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{t('uptime')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="divide-y divide-[#e0ddd4] bg-white">
|
||||
{dashboard.services.map((svc) => (
|
||||
<tr key={svc.name} className="border-b border-nothing-gray-100">
|
||||
<td className="py-2 font-medium">{svc.name}</td>
|
||||
<td className="py-2">
|
||||
<span className={cn('inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs border', STATUS_BADGE[svc.status])}>
|
||||
<tr key={svc.name} className="transition-colors hover:bg-[#faf9f3]">
|
||||
<td className="px-5 py-3 font-mono text-xs font-semibold text-[#141413]">{svc.name}</td>
|
||||
<td className="px-5 py-3">
|
||||
<span className={cn('inline-flex items-center gap-1.5 rounded-md border px-2.5 py-0.5 font-mono text-[10px] font-bold uppercase tracking-widest', STATUS_BADGE[svc.status])}>
|
||||
{STATUS_ICON[svc.status]}
|
||||
{t(svc.status)}
|
||||
</span>
|
||||
</td>
|
||||
<td className="py-2 text-right tabular-nums">{svc.latency_ms != null ? `${svc.latency_ms}ms` : '--'}</td>
|
||||
<td className="py-2 text-right tabular-nums">{svc.uptime_pct != null ? `${svc.uptime_pct}%` : '--'}</td>
|
||||
<td className="px-5 py-3 text-right font-mono text-xs tabular-nums text-[#5f5b52]">{svc.latency_ms != null ? `${svc.latency_ms}ms` : '--'}</td>
|
||||
<td className="px-5 py-3 text-right font-mono text-xs tabular-nums text-[#5f5b52]">{svc.uptime_pct != null ? `${svc.uptime_pct}%` : '--'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
@@ -693,7 +720,7 @@ export function MonitoringPanel() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
* 供原始頁面和整合頁面 (/operations) 共用
|
||||
*
|
||||
* 2026-05-31 Codex: 接上 Incident status-chain / timeline,讓 Tickets 不再只是清單。
|
||||
* 2026-07-04 Codex: UI/UX 全面升級 (Option A 明亮模式)
|
||||
*/
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -20,6 +21,7 @@ import {
|
||||
SearchCheck,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
Ticket,
|
||||
} from 'lucide-react'
|
||||
|
||||
import { Link } from '@/i18n/routing'
|
||||
@@ -83,20 +85,20 @@ interface IncidentTimelineResponse {
|
||||
ascii_timeline: string
|
||||
}
|
||||
|
||||
const SEV_COLOR: Record<string, string> = {
|
||||
P0: '#cc2200',
|
||||
P1: '#F59E0B',
|
||||
P2: '#4A90D9',
|
||||
P3: '#22C55E',
|
||||
const SEV_STYLE: Record<string, string> = {
|
||||
P0: 'bg-[#fff1f0] text-[#9f2f25] border-[#e2a29b]',
|
||||
P1: 'bg-[#fff7e8] text-[#8a5a08] border-[#d9b36f]',
|
||||
P2: 'bg-[#f0f6ff] text-[#4A90D9] border-[#c7dcf7]',
|
||||
P3: 'bg-[#f0faf2] text-[#17602a] border-[#8fc29a]',
|
||||
}
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
open: '#cc2200',
|
||||
investigating: '#F59E0B',
|
||||
in_progress: '#F59E0B',
|
||||
mitigating: '#4A90D9',
|
||||
resolved: '#22C55E',
|
||||
closed: '#87867f',
|
||||
const STATUS_STYLE: Record<string, string> = {
|
||||
open: 'text-[#9f2f25]',
|
||||
investigating: 'text-[#8a5a08]',
|
||||
in_progress: 'text-[#8a5a08]',
|
||||
mitigating: 'text-[#4A90D9]',
|
||||
resolved: 'text-[#17602a]',
|
||||
closed: 'text-[#77736a]',
|
||||
}
|
||||
|
||||
async function fetchJson<T>(url: string, timeoutMs = 10_000): Promise<T | null> {
|
||||
@@ -142,15 +144,15 @@ function formatLocalTime(value?: string | null) {
|
||||
function timelineStatusClass(status?: string | null) {
|
||||
const normalized = String(status ?? '').toLowerCase()
|
||||
if (normalized.includes('success') || normalized.includes('ok') || normalized.includes('resolved')) {
|
||||
return 'border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]'
|
||||
return 'border-[#8fc29a] bg-[#f0faf2] text-[#17602a]'
|
||||
}
|
||||
if (normalized.includes('fail') || normalized.includes('block') || normalized.includes('error')) {
|
||||
return 'border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]'
|
||||
return 'border-[#e2a29b] bg-[#fff1f0] text-[#9f2f25]'
|
||||
}
|
||||
if (normalized.includes('warn') || normalized.includes('pending') || normalized.includes('investigating')) {
|
||||
return 'border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]'
|
||||
}
|
||||
return 'border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]'
|
||||
return 'border-[#e0ddd4] bg-[#faf9f3] text-[#5f5b52]'
|
||||
}
|
||||
|
||||
function FocusedIncidentTruthPanel({
|
||||
@@ -193,14 +195,14 @@ function FocusedIncidentTruthPanel({
|
||||
const encodedIncidentId = selectedIncidentId ? encodeURIComponent(selectedIncidentId) : ''
|
||||
|
||||
return (
|
||||
<section className="border border-[#e0ddd4] bg-white" aria-busy={loading}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||
<section className="mb-6 overflow-hidden rounded-xl border border-[#e0ddd4] bg-white shadow-sm" aria-busy={loading}>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-4">
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<span className="flex h-9 w-9 shrink-0 items-center justify-center border border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]">
|
||||
<SearchCheck className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg border border-[#c7dcf7] bg-[#f0f6ff] text-[#4A90D9]">
|
||||
<SearchCheck className="h-5 w-5" aria-hidden="true" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-sm font-semibold text-[#141413]">{t('title')}</h2>
|
||||
<h2 className="font-semibold text-[#141413]">{t('title')}</h2>
|
||||
<p className="mt-1 truncate font-mono text-xs text-[#77736a]">
|
||||
{selectedIncidentId || t('emptyIncident')}
|
||||
</p>
|
||||
@@ -211,8 +213,8 @@ function FocusedIncidentTruthPanel({
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{loading ? (
|
||||
<span className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#5f5b52]">
|
||||
<RefreshCw className="h-3.5 w-3.5 animate-spin" aria-hidden="true" />
|
||||
<span className="inline-flex items-center gap-1.5 rounded-lg border border-[#e0ddd4] bg-white px-3 py-1.5 text-xs font-semibold text-[#77736a]">
|
||||
<RefreshCw className="h-4 w-4 animate-spin" aria-hidden="true" />
|
||||
{t('loading')}
|
||||
</span>
|
||||
) : null}
|
||||
@@ -220,14 +222,14 @@ function FocusedIncidentTruthPanel({
|
||||
<>
|
||||
<Link
|
||||
href={`/awooop/work-items?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never}
|
||||
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2.5 py-1 text-xs font-semibold text-[#141413] hover:border-[#d97757]"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[#e0ddd4] bg-white px-3 py-1.5 text-xs font-semibold text-[#141413] transition-colors hover:border-[#d97757] hover:bg-[#fff7f3] hover:text-[#d97757]"
|
||||
>
|
||||
{t('openWorkItems')}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
</Link>
|
||||
<Link
|
||||
href={`/awooop/runs?project_id=${encodedProjectId}&incident_id=${encodedIncidentId}` as never}
|
||||
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2.5 py-1 text-xs font-semibold text-[#141413] hover:border-[#1f6feb]"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[#e0ddd4] bg-white px-3 py-1.5 text-xs font-semibold text-[#141413] transition-colors hover:border-[#4A90D9] hover:bg-[#f0f6ff] hover:text-[#4A90D9]"
|
||||
>
|
||||
{t('openRuns')}
|
||||
<ArrowRight className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
@@ -238,36 +240,36 @@ function FocusedIncidentTruthPanel({
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="flex items-start gap-2 border-b border-[#ead9b4] bg-[#fff7e8] px-4 py-3 text-xs leading-5 text-[#8a5a08]">
|
||||
<div className="flex items-start gap-3 border-b border-[#e2a29b] bg-[#fff1f0] px-5 py-4 text-xs font-semibold leading-5 text-[#9f2f25]">
|
||||
<TriangleAlert className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-px bg-[#e0ddd4] md:grid-cols-4">
|
||||
<div className="min-w-0 bg-white px-4 py-3">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t('metrics.stages')}</p>
|
||||
<p className="mt-2 font-mono text-xl font-semibold text-[#141413]">
|
||||
<div className="min-w-0 bg-white px-5 py-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">{t('metrics.stages')}</p>
|
||||
<p className="mt-2 font-mono text-2xl font-bold tabular-nums text-[#141413]">
|
||||
{timeline ? stages.length : '--'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0 bg-white px-4 py-3">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t('metrics.events')}</p>
|
||||
<p className="mt-2 font-mono text-xl font-semibold text-[#141413]">
|
||||
<div className="min-w-0 bg-white px-5 py-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">{t('metrics.events')}</p>
|
||||
<p className="mt-2 font-mono text-2xl font-bold tabular-nums text-[#141413]">
|
||||
{timeline ? timeline.events.length : '--'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0 bg-white px-4 py-3">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t('metrics.source')}</p>
|
||||
<p className="mt-2 truncate font-mono text-sm font-semibold text-[#141413]">
|
||||
<div className="min-w-0 bg-white px-5 py-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">{t('metrics.source')}</p>
|
||||
<p className="mt-2 truncate font-mono text-xl font-bold tabular-nums text-[#141413]">
|
||||
{sourceCorrelation
|
||||
? `${sourceCorrelation.direct_ref_total ?? 0}/${sourceCorrelation.candidate_total ?? 0}/${sourceCorrelation.applied_link_total ?? 0}`
|
||||
: '--'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="min-w-0 bg-white px-4 py-3">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t('metrics.verification')}</p>
|
||||
<span className={cn('mt-2 inline-flex border px-2 py-0.5 text-xs font-semibold', timelineStatusClass(verifier?.status ?? chain?.verification))}>
|
||||
<div className="min-w-0 bg-white px-5 py-4">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">{t('metrics.verification')}</p>
|
||||
<span className={cn('mt-2 inline-flex rounded border px-2.5 py-0.5 font-mono text-[11px] font-bold tracking-widest', timelineStatusClass(verifier?.status ?? chain?.verification))}>
|
||||
{verifier?.status ?? chain?.verification ?? '--'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -276,31 +278,31 @@ function FocusedIncidentTruthPanel({
|
||||
<AwoooPStatusChainPanel chain={chain} className="border-x-0 border-t-0" />
|
||||
|
||||
<div className="grid gap-px bg-[#e0ddd4] lg:grid-cols-[1.15fr_0.85fr]">
|
||||
<div className="min-w-0 bg-white p-4">
|
||||
<div className="min-w-0 bg-white p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<GitBranch className="h-4 w-4 text-brand-accent" aria-hidden="true" />
|
||||
<h3 className="text-sm font-semibold text-[#141413]">{t('flowTitle')}</h3>
|
||||
<GitBranch className="h-4 w-4 text-[#4A90D9]" aria-hidden="true" />
|
||||
<h3 className="font-semibold text-[#141413]">{t('flowTitle')}</h3>
|
||||
</div>
|
||||
{timeline?.ascii_timeline ? (
|
||||
<p className="mt-3 break-words border border-[#eee9dd] bg-[#faf9f3] px-3 py-2 font-mono text-xs leading-6 text-[#5f5b52]">
|
||||
<p className="mt-4 break-words rounded-lg border border-[#e0ddd4] bg-[#faf9f3] p-4 font-mono text-xs leading-relaxed text-[#5f5b52]">
|
||||
{timeline.ascii_timeline}
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-[#77736a]">
|
||||
<p className="mt-4 text-sm text-[#77736a]">
|
||||
{loading ? t('loading') : t('timelineEmpty')}
|
||||
</p>
|
||||
)}
|
||||
{stages.length > 0 ? (
|
||||
<div className="mt-3 grid gap-2 md:grid-cols-2">
|
||||
<div className="mt-4 grid gap-3 md:grid-cols-2">
|
||||
{stages.slice(0, 6).map((stage) => (
|
||||
<div key={stage.stage} className="min-w-0 border border-[#eee9dd] bg-white px-3 py-2">
|
||||
<div key={stage.stage} className="min-w-0 rounded-lg border border-[#e0ddd4] bg-white px-4 py-3 transition-colors hover:border-[#d8d3c7] hover:bg-[#faf9f3]">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-xs font-semibold text-[#77736a]">{stage.label}</span>
|
||||
<span className={cn('shrink-0 border px-2 py-0.5 text-[11px] font-semibold', timelineStatusClass(stage.status))}>
|
||||
<span className={cn('shrink-0 rounded border px-1.5 py-0.5 font-mono text-[10px] font-bold tracking-widest', timelineStatusClass(stage.status))}>
|
||||
{stage.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs font-semibold text-[#141413]" title={stage.title}>
|
||||
<p className="mt-2 truncate font-mono text-xs font-semibold text-[#141413]" title={stage.title}>
|
||||
{stage.title}
|
||||
</p>
|
||||
</div>
|
||||
@@ -309,20 +311,20 @@ function FocusedIncidentTruthPanel({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 bg-white p-4">
|
||||
<div className="min-w-0 bg-white p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<ListChecks className="h-4 w-4 text-brand-accent" aria-hidden="true" />
|
||||
<h3 className="text-sm font-semibold text-[#141413]">{t('evidenceTitle')}</h3>
|
||||
<ListChecks className="h-4 w-4 text-[#4A90D9]" aria-hidden="true" />
|
||||
<h3 className="font-semibold text-[#141413]">{t('evidenceTitle')}</h3>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-px border border-[#e0ddd4] bg-[#e0ddd4]">
|
||||
<div className="mt-4 grid gap-px overflow-hidden rounded-lg border border-[#e0ddd4] bg-[#e0ddd4]">
|
||||
{[
|
||||
[t('executor'), timeline?.timeline?.find((stage) => stage.stage === 'executor')?.title ?? chain?.execution?.latest_operation_type ?? '--'],
|
||||
[t('ansible'), chain?.execution?.ansible?.latest_playbook_path ?? chain?.execution?.ansible?.latest_catalog_id ?? '--'],
|
||||
[t('mcp'), timeline?.timeline?.find((stage) => stage.stage === 'investigator')?.title ?? '--'],
|
||||
[t('km'), timeline?.timeline?.find((stage) => stage.stage === 'km')?.title ?? '--'],
|
||||
].map(([label, value]) => (
|
||||
<div key={label} className="min-w-0 bg-white px-3 py-2">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{label}</p>
|
||||
<div key={label} className="min-w-0 bg-white px-4 py-3 transition-colors hover:bg-[#faf9f3]">
|
||||
<p className="text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">{label}</p>
|
||||
<p className="mt-1 truncate font-mono text-xs text-[#141413]" title={value}>
|
||||
{value}
|
||||
</p>
|
||||
@@ -330,16 +332,16 @@ function FocusedIncidentTruthPanel({
|
||||
))}
|
||||
</div>
|
||||
{importantEvents.length > 0 ? (
|
||||
<div className="mt-3 divide-y divide-[#eee9dd] border border-[#eee9dd]">
|
||||
<div className="mt-4 divide-y divide-[#e0ddd4] overflow-hidden rounded-lg border border-[#e0ddd4]">
|
||||
{importantEvents.map((event, index) => (
|
||||
<div key={`${event.stage}-${event.timestamp}-${index}`} className="px-3 py-2">
|
||||
<div key={`${event.stage}-${event.timestamp}-${index}`} className="bg-white px-4 py-3 transition-colors hover:bg-[#faf9f3]">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className={cn('border px-2 py-0.5 text-[11px] font-semibold', timelineStatusClass(event.status))}>
|
||||
<span className={cn('rounded border px-1.5 py-0.5 font-mono text-[10px] font-bold tracking-widest', timelineStatusClass(event.status))}>
|
||||
{event.status}
|
||||
</span>
|
||||
<span className="font-mono text-[11px] text-[#77736a]">{event.source_table ?? '--'}</span>
|
||||
</div>
|
||||
<p className="mt-1 truncate text-xs font-semibold text-[#141413]" title={event.title}>
|
||||
<p className="mt-2 truncate font-mono text-xs font-semibold text-[#141413]" title={event.title}>
|
||||
{event.title}
|
||||
</p>
|
||||
</div>
|
||||
@@ -448,10 +450,27 @@ export function TicketsPanel() {
|
||||
}, [projectId, selectedIncidentId, t])
|
||||
|
||||
return (
|
||||
<div className="min-h-full space-y-5 bg-[#f5f4ed] p-6">
|
||||
<div>
|
||||
<h1 className="m-0 text-lg font-bold text-[#141413]">{t('title')}</h1>
|
||||
<p className="mt-1 text-xs text-[#87867f]">{t('subtitle')}</p>
|
||||
<div className="min-h-full bg-[#f5f4ed] p-5 lg:p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex min-w-0 items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[11px] font-semibold uppercase tracking-wider text-[#77736a]">
|
||||
Operations
|
||||
</p>
|
||||
<h1 className="mt-1 flex items-center gap-3 text-2xl font-bold text-[#141413]">
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-[#77736a]">
|
||||
{t('subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
disabled={loading}
|
||||
className="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-[#e0ddd4] bg-white text-[#77736a] transition hover:border-[#4A90D9] hover:text-[#4A90D9] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', loading && 'animate-spin')} aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<FocusedIncidentTruthPanel
|
||||
@@ -464,36 +483,42 @@ export function TicketsPanel() {
|
||||
error={detailError}
|
||||
/>
|
||||
|
||||
<section className="overflow-hidden border border-[#e0ddd4] bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||
<div className="flex items-center gap-2 text-sm font-bold text-[#141413]">
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-[#d97757]" />
|
||||
<section className="overflow-hidden rounded-xl border border-[#e0ddd4] bg-white">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-5 py-4">
|
||||
<div className="flex items-center gap-2 font-semibold text-[#141413]">
|
||||
<Ticket className="h-4 w-4 text-[#77736a]" />
|
||||
{t('title')} ({loading ? '...' : total})
|
||||
</div>
|
||||
<span className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#5f5b52]">
|
||||
<span className="inline-flex items-center gap-1.5 rounded-lg border border-[#e0ddd4] bg-white px-3 py-1.5 font-mono text-[10px] font-bold tracking-widest text-[#77736a]">
|
||||
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t('readOnly')}
|
||||
</span>
|
||||
</div>
|
||||
{loading ? (
|
||||
<div className="px-8 py-10 text-center text-sm text-[#87867f]">{t('loading')}</div>
|
||||
<div className="flex flex-col items-center justify-center py-12">
|
||||
<RefreshCw className="h-6 w-6 animate-spin text-[#d8d3c7]" />
|
||||
<span className="mt-3 text-sm text-[#77736a]">{t('loading')}</span>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="px-8 py-10 text-center text-sm text-[#cc2200]">{error}</div>
|
||||
<div className="p-8 text-center text-sm font-semibold text-[#9f2f25]">{error}</div>
|
||||
) : incidents.length === 0 ? (
|
||||
<div className="px-8 py-10 text-center text-sm text-[#87867f]">{t('noTickets')}</div>
|
||||
<div className="flex flex-col items-center justify-center rounded-b-xl border-t border-dashed border-[#d8d3c7] bg-[#f5f4ed] py-12 text-[#77736a]">
|
||||
<SearchCheck className="mb-3 h-8 w-8 opacity-50" />
|
||||
<span className="text-sm font-medium">{t('noTickets')}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead>
|
||||
<tr className="bg-[#faf9f3]">
|
||||
<tr className="border-b border-[#e0ddd4] bg-white">
|
||||
{[t('id'), t('title_col'), t('priority'), t('status'), t('signals'), t('createdAt'), t('actions')].map((col) => (
|
||||
<th key={col} className="border-b border-[#e0ddd4] px-4 py-3 text-left text-xs font-semibold text-[#87867f]">
|
||||
<th key={col} className="px-5 py-3 text-[10px] font-semibold uppercase tracking-wider text-[#77736a]">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tbody className="divide-y divide-[#e0ddd4] bg-white">
|
||||
{incidents.map((incident) => {
|
||||
const rowIncidentId = incidentId(incident)
|
||||
const isSelected = rowIncidentId === selectedIncidentId
|
||||
@@ -503,60 +528,60 @@ export function TicketsPanel() {
|
||||
<tr
|
||||
key={rowIncidentId || `${incident.created_at}-${title}`}
|
||||
className={cn(
|
||||
'border-b border-[#f0ede4]',
|
||||
isSelected && 'bg-[#fff7e8]'
|
||||
'transition-colors hover:bg-[#faf9f3]',
|
||||
isSelected && 'bg-[#fff7e8] hover:bg-[#fff7e8]'
|
||||
)}
|
||||
>
|
||||
<td className="px-4 py-3 align-top font-mono text-xs text-[#5f5b52]">
|
||||
<td className="px-5 py-3 align-top font-mono text-xs text-[#5f5b52]">
|
||||
<Link
|
||||
href={`/tickets?project_id=${encodeURIComponent(projectId)}&incident_id=${encodeURIComponent(rowIncidentId)}` as never}
|
||||
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 font-semibold text-[#141413] hover:border-[#d97757]"
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-lg border border-[#e0ddd4] bg-white px-2.5 py-1.5 font-semibold text-[#141413] transition-colors',
|
||||
isSelected ? 'border-[#d9b36f] bg-[#fff7e8]' : 'hover:border-[#d97757] hover:bg-[#fff7f3]'
|
||||
)}
|
||||
>
|
||||
{rowIncidentId.slice(0, 16)}
|
||||
<ArrowRight className="h-3 w-3" aria-hidden="true" />
|
||||
</Link>
|
||||
</td>
|
||||
<td className="max-w-[360px] px-4 py-3 align-top font-medium text-[#141413]">
|
||||
<td className="max-w-[360px] px-5 py-3 align-top font-semibold text-[#141413]">
|
||||
<div className="truncate" title={title}>{title}</div>
|
||||
{services.length > 1 ? (
|
||||
<div className="mt-1 truncate text-xs text-[#87867f]">
|
||||
<div className="mt-1 truncate font-mono text-[10px] text-[#77736a]">
|
||||
{t('serviceCount', { count: services.length })}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<td className="px-5 py-3 align-top">
|
||||
<span
|
||||
style={{
|
||||
color: SEV_COLOR[incident.severity] ?? '#87867f',
|
||||
background: `${SEV_COLOR[incident.severity] ?? '#87867f'}18`,
|
||||
borderColor: `${SEV_COLOR[incident.severity] ?? '#87867f'}40`,
|
||||
}}
|
||||
className="inline-flex border px-2 py-0.5 text-xs font-bold"
|
||||
className={cn(
|
||||
'inline-flex rounded border px-2 py-0.5 font-mono text-[10px] font-bold tracking-widest',
|
||||
SEV_STYLE[incident.severity] ?? 'bg-[#f5f4ed] text-[#5f5b52] border-[#e0ddd4]'
|
||||
)}
|
||||
>
|
||||
{incident.severity}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<td className="px-5 py-3 align-top">
|
||||
<span
|
||||
style={{ color: STATUS_COLOR[incident.status] ?? '#87867f' }}
|
||||
className="text-xs font-semibold"
|
||||
className={cn('font-mono text-xs font-bold uppercase tracking-wider', STATUS_STYLE[incident.status] ?? 'text-[#77736a]')}
|
||||
>
|
||||
{incident.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top font-mono text-xs text-[#5f5b52]">
|
||||
<td className="px-5 py-3 align-top font-mono text-xs text-[#5f5b52]">
|
||||
{t('signalProposal', {
|
||||
signals: incident.signal_count ?? 0,
|
||||
proposals: incident.proposal_count ?? 0,
|
||||
})}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top text-xs text-[#87867f]">
|
||||
<td className="px-5 py-3 align-top font-mono text-xs text-[#77736a]">
|
||||
{formatLocalTime(incident.created_at)}
|
||||
</td>
|
||||
<td className="px-4 py-3 align-top">
|
||||
<td className="px-5 py-3 align-top">
|
||||
<Link
|
||||
href={`/awooop/work-items?project_id=${encodeURIComponent(projectId)}&incident_id=${encodeURIComponent(rowIncidentId)}` as never}
|
||||
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8]"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-[#e0ddd4] bg-white px-2.5 py-1.5 text-xs font-semibold text-[#5f5b52] transition-colors hover:border-[#4A90D9] hover:bg-[#f0f6ff] hover:text-[#4A90D9]"
|
||||
>
|
||||
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{t('openTruth')}
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user