Files
hive-manage-v3/frontend/src/pages/DashboardPage.tsx
T

406 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useState, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import Skeleton from '../components/ui/Skeleton'
import {
getDashboardSummary,
getHealthDistribution,
getGatewayTrend,
getKanbanSummary,
getCronRecent,
type SummaryData,
type HealthDistribution,
type GatewayTrendItem,
type KanbanSummaryItem,
type CronRecentItem,
} from '../api/dashboard'
/* ---------- helpers ---------- */
function timeAgo(iso: string): string {
const diff = Math.floor((Date.now() - new Date(iso).getTime()) / 1000)
if (diff < 5) return '刚刚'
if (diff < 60) return diff + ' 秒前'
const m = Math.floor(diff / 60)
if (m < 60) return m + ' 分钟前'
return Math.floor(m / 60) + ' 小时前'
}
const VAL_COLORS: Record<string, string> = {
green: 'var(--green)',
red: 'var(--red)',
yellow: 'var(--yellow)',
blue: 'var(--blue)',
}
interface RecentEvent {
time: string
profile: string
type: string
typeClass: string
summary: string
status: string
statusClass: string
}
const MOCK_EVENTS: RecentEvent[] = [
{ time: '21:13', profile: 'hindsight', type: 'Cron 失败', typeClass: 'badge-offline', summary: 'Hindsight index 更新超时', status: '待处理', statusClass: 'badge-warn' },
{ time: '18:45', profile: 'monitor', type: '心跳超时', typeClass: 'badge-warn', summary: 'Gateway 心跳 3 次无响应', status: '待处理', statusClass: 'badge-warn' },
{ time: '14:20', profile: 'feishu-gateway', type: '飞书断连', typeClass: 'badge-warn', summary: 'WebSocket 断开 8 分钟后恢复', status: '已恢复', statusClass: 'badge-a' },
]
/* ---------- Stat card ---------- */
function StatCard({
label, value, valueColor, change, changeDirection,
}: {
label: string; value: string | number; valueColor?: string
change?: string; changeDirection?: 'up' | 'down'
}) {
const dirIcon = changeDirection === 'up' ? '↑' : '↓'
const dirColor = changeDirection === 'up' ? 'var(--green)' : 'var(--red)'
return (
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: 16 }}>
<div style={{ fontSize: 12, color: 'var(--text-dim)', textTransform: 'uppercase', letterSpacing: 0.5, marginBottom: 8 }}>
{label}
</div>
<div style={{ fontSize: 28, fontWeight: 700, color: valueColor ? VAL_COLORS[valueColor] : 'var(--text)' }}>
{value}
</div>
{change && (
<div style={{ fontSize: 12, marginTop: 4, color: changeDirection ? dirColor : 'var(--text-dim)' }}>
{changeDirection && <span>{dirIcon} </span>}
{change}
</div>
)}
</div>
)
}
/* ---------- Health vertical bar chart ---------- */
const GRADE_META = [
{ key: 'A', color: 'var(--green)' },
{ key: 'B', color: 'var(--blue)' },
{ key: 'C', color: 'var(--yellow)' },
{ key: 'D', color: 'var(--red)' },
] as const
function HealthBarChart({ dist, onBarClick }: { dist: HealthDistribution; onBarClick: (g: string) => void }) {
if (!dist || dist.total === 0) {
return <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
}
const maxVal = Math.max(dist.A, dist.B, dist.C, dist.D, 1)
return (
<div style={{ display: 'flex', alignItems: 'flex-end', gap: 16, height: 120, padding: '0 8px' }}>
{GRADE_META.map(function(meta) {
var val = dist[meta.key];
var pct = (val / maxVal) * 100;
return (
<div key={meta.key} onClick={function() { onBarClick(meta.key) }}
style={{
flex: 1, borderRadius: '4px 4px 0 0', textAlign: 'center', position: 'relative',
minWidth: 40, cursor: 'pointer', height: Math.max(pct, 4) + '%', backgroundColor: meta.color,
transition: 'opacity .15s',
}}
onMouseEnter={function(e) { e.currentTarget.style.opacity = '0.8' }}
onMouseLeave={function(e) { e.currentTarget.style.opacity = '1' }}
>
<span style={{ position: 'absolute', top: -18, left: '50%', transform: 'translateX(-50%)', fontSize: 12, fontWeight: 700, color: 'var(--text)' }}>
{val}
</span>
<span style={{ position: 'absolute', bottom: -22, left: '50%', transform: 'translateX(-50%)', fontSize: 11, color: 'var(--text-dim)' }}>
{meta.key}级
</span>
</div>
)
})}
</div>
)
}
/* ---------- Gateway 48-bar timeline ---------- */
function barColor(item: GatewayTrendItem): string {
if (item.heartbeat_timeouts > 0) return 'var(--red)'
if (item.feishu_disconnects > 0) return 'var(--orange)'
return 'var(--green)'
}
function GatewayTimeline({ trend }: { trend: GatewayTrendItem[] }) {
var bars = [];
for (var i = 0; i < trend.length; i++) {
var item = trend[i];
var col = barColor(item);
var details = [];
if (item.heartbeat_timeouts > 0) details.push('心跳超时 ' + item.heartbeat_timeouts);
if (item.feishu_disconnects > 0) details.push('飞书断连 ' + item.feishu_disconnects);
if (item.restarts > 0) details.push('重启 ' + item.restarts);
var tip = details.length > 0 ? details.join(' / ') : '正常运行';
bars.push({ color: col, tooltip: item.hour + ': ' + tip });
bars.push({ color: col, tooltip: item.hour + ': ' + tip });
}
return (
<div style={{ display: 'flex', gap: 2, height: 40, alignItems: 'flex-end', marginBottom: 12 }}>
{bars.map(function(b, i) {
return (
<div key={i} title={b.tooltip}
style={{
flex: 1, borderRadius: '2px 2px 0 0', minWidth: 3, cursor: 'pointer', position: 'relative',
backgroundColor: b.color,
}}
onMouseEnter={function(e) { e.currentTarget.style.opacity = '0.7' }}
onMouseLeave={function(e) { e.currentTarget.style.opacity = '1' }}
/>
)
})}
</div>
)
}
/* ---------- Badge helpers for events table ---------- */
function BadgeSpan({ cls, children }: { cls: string; children: React.ReactNode }) {
var styleMap: Record<string, React.CSSProperties> = {
'badge-offline': { background: 'rgba(255,61,61,.15)', color: 'var(--red)' },
'badge-warn': { background: 'rgba(255,171,0,.15)', color: 'var(--yellow)' },
'badge-a': { background: 'rgba(0,200,83,.15)', color: 'var(--green)' },
}
var baseStyle: React.CSSProperties = styleMap[cls] || { background: 'var(--bg-hover)', color: 'var(--text-dim)' }
return (
<span style={Object.assign({ display: 'inline-block', padding: '2px 8px', borderRadius: 10, fontSize: 11, fontWeight: 600 }, baseStyle)}>
{children}
</span>
)
}
/* ---------- main page ---------- */
export default function DashboardPage() {
var navigate = useNavigate()
var _a = useState<SummaryData | null>(null), summary = _a[0], setSummary = _a[1]
var _b = useState<HealthDistribution | null>(null), health = _b[0], setHealth = _b[1]
var _c = useState<GatewayTrendItem[]>([]), trend = _c[0], setTrend = _c[1]
var _d = useState<KanbanSummaryItem[]>([]), kanban = _d[0], setKanban = _d[1]
var _e = useState<CronRecentItem[]>([]), cron = _e[0], setCron = _e[1]
var _f = useState(true), loading = _f[0], setLoading = _f[1]
var _g = useState<string | null>(null), error = _g[0], setError = _g[1]
var fetchAll = useCallback(async function() {
setLoading(true)
setError(null)
try {
var res = await Promise.all([
getDashboardSummary(),
getHealthDistribution(),
getGatewayTrend(),
getKanbanSummary(),
getCronRecent(),
])
setSummary(res[0].data)
setHealth(res[1].data)
setTrend(res[2].data)
setKanban(res[3].data)
setCron(res[4].data)
} catch (e) {
setError('加载仪表盘数据失败')
} finally {
setLoading(false)
}
}, [])
useEffect(function() { fetchAll() }, [fetchAll])
/* loading */
if (loading) {
return (
<div>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
<Skeleton variant='card' count={4} className='mt-4' />
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 20, marginTop: 20 }}>
<Skeleton variant='card' count={1} />
<Skeleton variant='card' count={1} />
</div>
</div>
)
}
/* error */
if (error) {
return (
<div>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: 20, textAlign: 'center', marginTop: 20 }}>
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
<button onClick={fetchAll} style={{ background: 'var(--honey)', color: '#000', border: 'none', borderRadius: 'var(--radius-sm)', padding: '6px 14px', fontSize: 13, cursor: 'pointer', fontWeight: 600 }}>
重试
</button>
</div>
</div>
)
}
/* empty */
if (!summary) {
return (
<div>
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
<p style={{ color: 'var(--text-dim)', fontSize: 13, marginTop: 20 }}>暂无数据</p>
</div>
)
}
/* subtitle */
var subtitle = summary.total_profiles + ' 个 Profile' + (summary.updated_at ? ' · 最后更新 ' + timeAgo(summary.updated_at) : '')
return (
<div>
{/* page header */}
<div style={{ fontSize: 20, fontWeight: 700, marginBottom: 4, color: 'var(--text)' }}>蜂群总览</div>
<div style={{ color: 'var(--text-dim)', fontSize: 13, marginBottom: 20 }}>{subtitle}</div>
{/* stat cards */}
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fill, minmax(260px, 1fr))', gap: 14, marginBottom: 20 }}>
<StatCard label='在线 Gateway' value={summary.online_count} valueColor='green' change={'↑ vs 昨天'} changeDirection='up' />
<StatCard label='离线 Gateway' value={summary.offline_count} valueColor='red' change={'↓ vs 昨天'} changeDirection='down' />
<StatCard label='运行中任务' value={summary.running_tasks} valueColor='blue' change={'Kanban · ' + kanban.length + ' 个看板活跃'} />
<StatCard label='今日 Cron 失败' value={summary.cron_failures_today} valueColor='red' />
</div>
{/* health + trend */}
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 20, marginBottom: 20 }}>
{/* health distribution */}
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
<span>蜂群健康分布</span>
<span onClick={function() { navigate('/health') }} style={{ color: 'var(--text-dim)', fontSize: 12, cursor: 'pointer' }}>
查看全部 →
</span>
</div>
<div style={{ padding: 16 }}>
{health ? (
<div>
<HealthBarChart dist={health} onBarClick={function(g) { navigate('/health?filter=' + g) }} />
{health.d_profiles && health.d_profiles.length > 0 ? (
<div style={{ marginTop: 16, fontSize: 12, color: 'var(--text-dim)' }}>
<span>⚠ {health.d_profiles.length} 个 D 级 Profile:</span>
<span style={{ color: 'var(--red)' }}>{health.d_profiles.join(' · ')}</span>
</div>
) : null}
</div>
) : (
<p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
)}
</div>
</div>
{/* gateway trend */}
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
Gateway 异常趋势 (24h)
</div>
<div style={{ padding: 16 }}>
{trend.length > 0 ? <GatewayTimeline trend={trend} /> : <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>}
<div style={{ display: 'flex', gap: 16, fontSize: 12, color: 'var(--text-dim)' }}>
<span><span style={{ color: 'var(--green)' }}>●</span> 正常运行</span>
<span><span style={{ color: 'var(--red)' }}>●</span> 心跳超时</span>
<span><span style={{ color: 'var(--orange)' }}>●</span> 飞书断连</span>
</div>
</div>
</div>
</div>
{/* kanban + cron */}
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 20, marginBottom: 20 }}>
{/* kanban overview */}
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
Kanban 任务概览
</div>
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-dim)', marginBottom: 12 }}>
<span>看板</span>
<span>RUNNING</span>
<span>READY</span>
</div>
{kanban.length > 0 ? kanban.map(function(item) {
return (
<div key={item.board} onClick={function() { navigate('/kanban') }}
style={{ fontSize: 12, color: 'var(--text-dim)', padding: '4px 0', borderBottom: '1px solid var(--border-light)', cursor: 'pointer', display: 'flex', justifyContent: 'space-between' }}>
<span style={{ color: 'var(--text-dim)' }}>{item.board}</span>
<span style={{ color: 'var(--green)' }}>{item.running || '—'}</span>
<span style={{ color: 'var(--blue)', marginLeft: 40 }}>{item.ready || '—'}</span>
</div>
)
}) : <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>}
</div>
</div>
{/* cron recent */}
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
Cron 最近执行
</div>
<div style={{ padding: 16 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, color: 'var(--text-dim)', marginBottom: 8 }}>
<span>Job</span>
<span>状态</span>
<span>时间</span>
</div>
{cron.length > 0 ? cron.map(function(item) {
return (
<div key={item.job} onClick={function() { navigate('/cron') }}
style={{ fontSize: 12, padding: '4px 0', borderBottom: '1px solid var(--border-light)', cursor: 'pointer' }}>
<span style={{ color: 'var(--text-dim)' }}>{item.job}</span>
<span style={{ float: 'right', marginRight: 80 }}>
{item.status === 'success' ? '✅' : item.status === 'failure' ? '❌' : item.status === 'paused' ? '⏸' : '❓'}
</span>
<span style={{ float: 'right', color: 'var(--text-muted)' }}>{item.time || '—'}</span>
</div>
)
}) : <p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>}
</div>
</div>
</div>
{/* recent events table */}
<div style={{ background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: 'var(--radius)' }}>
<div style={{ padding: '14px 16px', borderBottom: '1px solid var(--border)', fontSize: 14, fontWeight: 600, color: 'var(--text)' }}>
最近异常事件
</div>
<div style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>时间</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>Profile</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>类型</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>摘要</th>
<th style={{ textAlign: 'left', padding: '10px 14px', color: 'var(--text-dim)', fontWeight: 500, borderBottom: '1px solid var(--border)', fontSize: 12, textTransform: 'uppercase', letterSpacing: 0.3 }}>状态</th>
</tr>
</thead>
<tbody>
{MOCK_EVENTS.map(function(evt, i) {
return (
<tr key={i} style={evt.status === '待处理' ? { background: 'rgba(255,61,61,.06)' } : undefined}>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text)' }}>{evt.time}</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--blue)' }}>{evt.profile}</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
<BadgeSpan cls={evt.typeClass}>{evt.type}</BadgeSpan>
</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)', color: 'var(--text-dim)' }}>{evt.summary}</td>
<td style={{ padding: '10px 14px', borderBottom: '1px solid var(--border-light)' }}>
<BadgeSpan cls={evt.statusClass}>{evt.status}</BadgeSpan>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
</div>
)
}