feat(web): S4+S5 處置統計環形圖 + 最近活動時間線 — Sprint 5R
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled

- S4: DispositionMini 元件 (SVG 環形圖 + 四類列表)
- S5: RecentActivity 元件 (時間線 + 色點 + JetBrains Mono)
- 左欄改為 flex:6 可滾動多卡片列
- 右欄改為 flex:4 (60:40 比例)
- 左欄結構: 活躍事件 → 處置統計 → 最近活動

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-09 15:51:54 +08:00
parent 7a2e07f74f
commit b85a0e232e
3 changed files with 252 additions and 50 deletions

View File

@@ -28,6 +28,8 @@ import { PageTabs, type TabConfig } from '@/components/layout/page-tabs'
import { LobsterLoading } from '@/components/shared/lobster-loading'
import { ServiceTopology } from '@/components/topology'
import { BarChart3, Flame, Telescope, FlaskConical, Activity, GitBranch } from 'lucide-react'
import { DispositionMini } from '@/components/shared/disposition-mini'
import { RecentActivity } from '@/components/shared/recent-activity'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
@@ -766,70 +768,78 @@ export default function Home({ params }: { params: { locale: string } }) {
{/* ── 主體 2 欄 ─────────────────────────────────────────────────────── */}
<div style={{ display: 'flex', flex: 1, overflow: 'hidden', padding: '14px 20px', gap: 20, background: '#f5f4ed' }}>
{/* ── Feed活躍事件 (flex:1) ───────────────────────────────────── */}
{/* ── 左欄 (60%): 活躍事件 + 處置統計 + 最近活動 ─────────────── */}
<div style={{
flex: 1,
background: '#fff',
border: '0.5px solid #e0ddd4',
borderRadius: 12,
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
boxShadow: '0 1px 4px rgba(0,0,0,0.05)',
flex: 6, minWidth: 0, overflowY: 'auto',
display: 'flex', flexDirection: 'column', gap: 14,
paddingBottom: 40,
}}>
{/* 活躍事件 */}
<div style={{
padding: '10px 14px',
borderBottom: '0.5px solid #e0ddd4',
display: 'flex', alignItems: 'center', gap: 8,
flexShrink: 0, background: '#faf9f3',
background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10,
overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.04)', flexShrink: 0,
}}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', flexShrink: 0 }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413', letterSpacing: '0.5px' }}>
{tDashboard('activeIncidents')}
</span>
{(incidents?.length ?? 0) > 0 && (
<span style={{
fontSize: 12, background: 'rgba(217,119,87,0.1)', color: '#a04010',
padding: '2px 8px', fontWeight: 700,
border: '0.5px solid rgba(217,119,87,0.25)', borderRadius: 10,
}}>
{incidents?.length}
<div style={{
padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4',
display: 'flex', alignItems: 'center', gap: 8, background: '#faf9f3',
}}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757', flexShrink: 0 }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413', letterSpacing: '0.5px' }}>
{tDashboard('activeIncidents')}
</span>
)}
{(incidents?.length ?? 0) > 0 && (
<span style={{
fontSize: 12, background: 'rgba(217,119,87,0.1)', color: '#a04010',
padding: '2px 8px', fontWeight: 700,
border: '0.5px solid rgba(217,119,87,0.25)', borderRadius: 10,
}}>
{incidents?.length}
</span>
)}
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#4A90D9', cursor: 'pointer', fontWeight: 500 }}> </span>
</div>
<div style={{ padding: '14px' }}>
{isIncidentsLoading ? (
<LobsterLoading size="sm" />
) : incidentsError ? (
<div style={{ padding: 16, fontSize: 13, color: '#cc2200' }}>{incidentsError}</div>
) : (incidents?.length ?? 0) === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 48, gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#22C55E' }} />
<span style={{ fontSize: 13, color: '#87867f' }}>{tDashboard('stable')} · 0 {tDashboard('activeIncidents')}</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{incidents?.map((incident) => (
<IncidentCard
key={incident.incident_id}
incident={incident}
decision={incident.decision}
/>
))}
</div>
)}
</div>
</div>
<div style={{ flex: 1, overflowY: 'auto', padding: '14px' }}>
{isIncidentsLoading ? (
<LobsterLoading size="sm" />
) : incidentsError ? (
<div style={{ padding: 16, fontSize: 13, color: '#cc2200' }}>{incidentsError}</div>
) : (incidents?.length ?? 0) === 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 48, gap: 8 }}>
<div style={{ width: 8, height: 8, borderRadius: '50%', background: '#22C55E' }} />
<span style={{ fontSize: 13, color: '#87867f' }}>{tDashboard('stable')} · 0 {tDashboard('activeIncidents')}</span>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 14 }}>
{incidents?.map((incident) => (
<IncidentCard
key={incident.incident_id}
incident={incident}
decision={incident.decision}
/>
))}
</div>
)}
</div>
{/* 處置統計迷你版 (S4) */}
<DispositionMini />
{/* 最近活動 (S5) */}
<RecentActivity />
</div>
{/* ── Right PanelAI + Infra (width:530px) ────────────────────── */}
{/* ── 右欄 (40%): OpenClaw + 基礎架構 + 監控工具 ─────────────── */}
<div style={{
width: 530,
flexShrink: 0,
flex: 4, minWidth: 0,
overflowY: 'auto',
display: 'flex',
flexDirection: 'column',
gap: 14,
paddingBottom: 40,
}}>
{/* WoooClaw + Reasoning Stream */}

View File

@@ -0,0 +1,106 @@
'use client'
/**
* DispositionMini — 處置統計迷你版 (環形圖 + 四類列表)
* Sprint 5R S4: 設計稿 L386-414
* @created 2026-04-09 Claude Opus 4.6 Asia/Taipei
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
interface DispositionSummary {
total: number
auto_rate: number
by_type: { auto_repair: number; human_approved: number; manual_resolved: number; cold_start_trust: number }
}
export function DispositionMini() {
const t = useTranslations('dashboard')
const [data, setData] = useState<DispositionSummary | null>(null)
useEffect(() => {
fetch(`${API_BASE}/api/v1/stats/disposition`)
.then(r => r.ok ? r.json() : null)
.then(d => { if (d?.summary) setData(d.summary) })
.catch(() => {})
}, [])
if (!data || data.total === 0) return null
const { auto_repair = 0, human_approved = 0, manual_resolved = 0, cold_start_trust = 0 } = data.by_type ?? {}
const total = data.total || 1
const pct = Math.round(data.auto_rate * 100)
// SVG 環形圖計算 (circumference = 2 * PI * 22 ≈ 138.2)
const C = 2 * Math.PI * 22
const segments = [
{ value: auto_repair, color: '#22C55E' },
{ value: human_approved, color: '#F59E0B' },
{ value: manual_resolved, color: '#A855F7' },
{ value: cold_start_trust, color: '#4A90D9' },
]
let offset = 0
const arcs = segments.map(seg => {
const len = (seg.value / total) * C
const arc = { len, gap: C - len, offset: -offset, color: seg.color }
offset += len
return arc
})
const items = [
{ label: t('autoRepairLabel'), value: auto_repair, color: '#22C55E' },
{ label: t('humanApprovedLabel'), value: human_approved, color: '#F59E0B' },
{ label: t('manualResolvedLabel'), value: manual_resolved, color: '#A855F7' },
{ label: t('coldStartLabel'), value: cold_start_trust, color: '#4A90D9' },
]
return (
<div style={{
background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10,
overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.04)', flexShrink: 0,
}}>
<div style={{
padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4',
display: 'flex', alignItems: 'center', gap: 8, background: '#faf9f3',
}}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413', letterSpacing: '0.5px' }}>{t('dispositionBreakdown')}</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#4A90D9', cursor: 'pointer', fontWeight: 500 }}> </span>
</div>
<div style={{ padding: 14, display: 'flex', gap: 14, alignItems: 'center' }}>
{/* 環形圖 */}
<div style={{ position: 'relative', width: 56, height: 56, flexShrink: 0 }}>
<svg width="56" height="56" viewBox="0 0 56 56" style={{ transform: 'rotate(-90deg)' }}>
<circle cx="28" cy="28" r="22" fill="none" stroke="#ebe8df" strokeWidth="5" />
{arcs.map((arc, i) => (
<circle key={i} cx="28" cy="28" r="22" fill="none"
stroke={arc.color} strokeWidth="5" strokeLinecap="round"
strokeDasharray={`${arc.len} ${arc.gap}`}
strokeDashoffset={arc.offset}
/>
))}
</svg>
<div style={{
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 13, fontWeight: 700, color: '#22C55E',
}}>
{pct}%
</div>
</div>
{/* 列表 */}
<div style={{ flex: 1, display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2px 12px' }}>
{items.map(item => (
<div key={item.label} style={{ display: 'flex', alignItems: 'center', gap: 5, fontSize: 12, color: '#555550' }}>
<span style={{ width: 5, height: 5, borderRadius: '50%', background: item.color, flexShrink: 0 }} />
{item.label}
<span style={{ marginLeft: 'auto', fontWeight: 700, fontVariantNumeric: 'tabular-nums', color: item.color }}>{item.value}</span>
</div>
))}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,86 @@
'use client'
/**
* RecentActivity — 最近活動時間線
* Sprint 5R S5: 設計稿 L416-429
* @created 2026-04-09 Claude Opus 4.6 Asia/Taipei
*/
import { useState, useEffect } from 'react'
import { useTranslations } from 'next-intl'
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? ''
interface LogEntry {
id: string
event_type: string
action_detail: string | null
actor: string | null
created_at: string
}
const EVENT_COLOR: Record<string, string> = {
RESOLVED: '#22C55E',
EXECUTION_COMPLETED: '#22C55E',
ALERT_RECEIVED: '#cc2200',
AUTO_REPAIR_TRIGGERED: '#4A90D9',
TELEGRAM_SENT: '#4A90D9',
EXECUTION_STARTED: '#F59E0B',
}
export function RecentActivity() {
const t = useTranslations('dashboard')
const [logs, setLogs] = useState<LogEntry[]>([])
useEffect(() => {
fetch(`${API_BASE}/api/v1/alert-operation-logs?limit=5`)
.then(r => r.ok ? r.json() : { items: [] })
.then(d => setLogs(d.items ?? []))
.catch(() => {})
}, [])
if (logs.length === 0) return null
return (
<div style={{
background: '#fff', border: '0.5px solid #e0ddd4', borderRadius: 10,
overflow: 'hidden', boxShadow: '0 1px 3px rgba(0,0,0,0.04)', flexShrink: 0,
}}>
<div style={{
padding: '10px 14px', borderBottom: '0.5px solid #e0ddd4',
display: 'flex', alignItems: 'center', gap: 8, background: '#faf9f3',
}}>
<div style={{ width: 6, height: 6, borderRadius: '50%', background: '#d97757' }} />
<span style={{ fontSize: 14, fontWeight: 700, color: '#141413', letterSpacing: '0.5px' }}>{t('activityStream')}</span>
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#4A90D9', cursor: 'pointer', fontWeight: 500 }}> </span>
</div>
<div style={{ padding: '10px 14px' }}>
{logs.map((log, i) => {
const time = (() => {
try {
return new Date(log.created_at).toLocaleTimeString('zh-TW', { timeZone: 'Asia/Taipei', hour: '2-digit', minute: '2-digit' })
} catch { return '--' }
})()
const dotColor = EVENT_COLOR[log.event_type] ?? '#87867f'
const detail = log.action_detail || log.event_type.replace(/_/g, ' ').toLowerCase()
return (
<div key={log.id || i} style={{
display: 'flex', gap: 8, padding: '6px 0',
borderBottom: i < logs.length - 1 ? '0.5px solid #f0ede5' : 'none',
fontSize: 12,
}}>
<span style={{ fontSize: 10, color: '#87867f', fontFamily: "'JetBrains Mono', monospace", width: 40, flexShrink: 0 }}>{time}</span>
<span style={{ width: 4, height: 4, borderRadius: '50%', background: dotColor, marginTop: 5, flexShrink: 0 }} />
<span style={{ flex: 1, lineHeight: 1.4, color: '#555550' }}>
{log.actor && <b style={{ fontWeight: 600 }}>{log.actor}</b>}
{log.actor && ' · '}
{detail}
</span>
</div>
)
})}
</div>
</div>
)
}