feat(web): S4+S5 處置統計環形圖 + 最近活動時間線 — Sprint 5R
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
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:
@@ -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 Panel:AI + 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 */}
|
||||
|
||||
106
apps/web/src/components/shared/disposition-mini.tsx
Normal file
106
apps/web/src/components/shared/disposition-mini.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
apps/web/src/components/shared/recent-activity.tsx
Normal file
86
apps/web/src/components/shared/recent-activity.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user