352 lines
12 KiB
TypeScript
352 lines
12 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import Card from '../components/ui/Card'
|
|
import Skeleton from '../components/ui/Skeleton'
|
|
import Timeline, { type TimelineEvent } from '../components/ui/Timeline'
|
|
import toast from '../components/ui/Toast'
|
|
|
|
import {
|
|
getDashboardSummary,
|
|
getHealthDistribution,
|
|
getGatewayTrend,
|
|
getKanbanSummary,
|
|
getCronRecent,
|
|
type SummaryData,
|
|
type HealthDistribution,
|
|
type GatewayTrendItem,
|
|
type KanbanSummaryItem,
|
|
type CronRecentItem,
|
|
} from '../api/dashboard'
|
|
|
|
/* ---------- helpers ---------- */
|
|
|
|
const hourLabel = (iso: string) => {
|
|
const m = iso.match(/T(\d{2}):/)
|
|
return m ? `${m[1]}:00` : iso
|
|
}
|
|
|
|
const statusIcon = (s: string) => {
|
|
if (s === 'success') return '✅'
|
|
if (s === 'failure') return '❌'
|
|
if (s === 'paused') return '⏸'
|
|
return '❓'
|
|
}
|
|
|
|
const trendColor = (item: GatewayTrendItem): string => {
|
|
if (item.heartbeat_timeouts > 0) return 'var(--red)'
|
|
if (item.feishu_disconnects > 0) return 'var(--orange)'
|
|
if (item.restarts > 0) return 'var(--yellow)'
|
|
return 'var(--green)'
|
|
}
|
|
|
|
/* ---------- bar-chart sub-component ---------- */
|
|
|
|
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 className="space-y-3 mt-2">
|
|
{GRADE_META.map(({ key, color }) => {
|
|
const val = dist[key as keyof typeof dist] as number
|
|
const pct = (val / maxVal) * 100
|
|
return (
|
|
<div
|
|
key={key}
|
|
className="flex items-center gap-3 cursor-pointer group"
|
|
onClick={() => onBarClick(key)}
|
|
>
|
|
<span
|
|
className="w-5 text-center text-[13px] font-bold shrink-0"
|
|
style={{ color }}
|
|
>
|
|
{key}
|
|
</span>
|
|
<div className="flex-1 h-6 rounded-[var(--radius-sm)] bg-[var(--bg-hover)] overflow-hidden relative">
|
|
<div
|
|
className="h-full rounded-[var(--radius-sm)] transition-all duration-500 group-hover:opacity-80"
|
|
style={{
|
|
width: `${Math.max(pct, 4)}%`,
|
|
backgroundColor: color,
|
|
}}
|
|
/>
|
|
</div>
|
|
<span className="w-8 text-right text-[13px] shrink-0" style={{ color: 'var(--text-dim)' }}>
|
|
{val}
|
|
</span>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ---------- main page ---------- */
|
|
|
|
export default function DashboardPage() {
|
|
const navigate = useNavigate()
|
|
|
|
const [summary, setSummary] = useState<SummaryData | null>(null)
|
|
const [health, setHealth] = useState<HealthDistribution | null>(null)
|
|
const [trend, setTrend] = useState<GatewayTrendItem[]>([])
|
|
const [kanban, setKanban] = useState<KanbanSummaryItem[]>([])
|
|
const [cron, setCron] = useState<CronRecentItem[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
const fetchAll = useCallback(async () => {
|
|
setLoading(true)
|
|
setError(null)
|
|
try {
|
|
const [s, h, t, k, c] = await Promise.all([
|
|
getDashboardSummary(),
|
|
getHealthDistribution(),
|
|
getGatewayTrend(),
|
|
getKanbanSummary(),
|
|
getCronRecent(),
|
|
])
|
|
setSummary(s.data)
|
|
setHealth(h.data)
|
|
setTrend(t.data)
|
|
setKanban(k.data)
|
|
setCron(c.data)
|
|
} catch {
|
|
setError('加载仪表盘数据失败')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => { fetchAll() }, [fetchAll])
|
|
|
|
/* ---------- loading ---------- */
|
|
if (loading) {
|
|
return (
|
|
<div>
|
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2>
|
|
<Skeleton variant="card" count={4} className="mt-4" />
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
|
|
<Skeleton variant="card" count={1} />
|
|
<Skeleton variant="card" count={1} />
|
|
</div>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
|
|
<Skeleton variant="card" count={1} />
|
|
<Skeleton variant="card" count={1} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ---------- error ---------- */
|
|
if (error) {
|
|
return (
|
|
<div>
|
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2>
|
|
<div
|
|
className="mt-4 p-5 rounded-[var(--radius)] text-center"
|
|
style={{ background: 'var(--bg-card)' }}
|
|
>
|
|
<p style={{ color: 'var(--red)', fontSize: 14, marginBottom: 12 }}>{error}</p>
|
|
<button
|
|
onClick={fetchAll}
|
|
className="px-4 py-2 text-[13px] rounded-[var(--radius-sm)] cursor-pointer border-none transition-colors"
|
|
style={{ background: 'var(--honey)', color: '#000' }}
|
|
>
|
|
重试
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ---------- empty ---------- */
|
|
if (!summary) {
|
|
return (
|
|
<div>
|
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2>
|
|
<p className="mt-4" style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
/* ---------- trend -> timeline events ---------- */
|
|
const timelineEvents: TimelineEvent[] = trend.map((item) => ({
|
|
time: hourLabel(item.hour),
|
|
type: item.heartbeat_timeouts > 0 ? 'red' : item.feishu_disconnects > 0 ? 'orange' : item.restarts > 0 ? 'orange' : 'green',
|
|
label: (
|
|
<span className="flex gap-2 text-[11px]">
|
|
{item.restarts > 0 && <span style={{ color: 'var(--yellow)' }}>R:{item.restarts}</span>}
|
|
{item.feishu_disconnects > 0 && <span style={{ color: 'var(--orange)' }}>F:{item.feishu_disconnects}</span>}
|
|
{item.heartbeat_timeouts > 0 && <span style={{ color: 'var(--red)' }}>H:{item.heartbeat_timeouts}</span>}
|
|
{item.restarts === 0 && item.feishu_disconnects === 0 && item.heartbeat_timeouts === 0 && (
|
|
<span style={{ color: 'var(--green)' }}>OK</span>
|
|
)}
|
|
</span>
|
|
),
|
|
color: trendColor(item),
|
|
}))
|
|
|
|
return (
|
|
<div>
|
|
<h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2>
|
|
|
|
{/* ---- summary cards ---- */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mt-4">
|
|
<Card
|
|
label="在线 Gateway"
|
|
value={summary.online_count}
|
|
valueColor="green"
|
|
change="2 vs 昨天"
|
|
changeDirection="up"
|
|
/>
|
|
<Card
|
|
label="离线 Gateway"
|
|
value={summary.offline_count}
|
|
valueColor="red"
|
|
change="1 vs 昨天"
|
|
changeDirection="down"
|
|
/>
|
|
<Card
|
|
label="运行中任务"
|
|
value={summary.running_tasks}
|
|
valueColor="blue"
|
|
/>
|
|
<Card
|
|
label="今日 Cron 失败"
|
|
value={summary.cron_failures_today}
|
|
valueColor={summary.cron_failures_today > 3 ? 'red' : 'yellow'}
|
|
change={summary.cron_failures_today > 3 ? '需关注' : undefined}
|
|
changeDirection={summary.cron_failures_today > 3 ? 'up' : undefined}
|
|
/>
|
|
</div>
|
|
|
|
{/* ---- health + trend ---- */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
|
|
{/* health distribution */}
|
|
<div
|
|
className="rounded-[var(--radius)] p-4"
|
|
style={{ background: 'var(--bg-card)' }}
|
|
>
|
|
<h3 className="text-[14px] font-semibold mb-1" style={{ color: 'var(--text)' }}>
|
|
蜂群健康分布
|
|
</h3>
|
|
<p className="text-[12px] mb-2" style={{ color: 'var(--text-dim)' }}>
|
|
共 {health?.total ?? 0} 个 Profile
|
|
</p>
|
|
{health ? (
|
|
<HealthBarChart dist={health} onBarClick={(g) => navigate(`/health?filter=${g}`)} />
|
|
) : (
|
|
<p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* gateway trend */}
|
|
<div
|
|
className="rounded-[var(--radius)] p-4"
|
|
style={{ background: 'var(--bg-card)' }}
|
|
>
|
|
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
|
|
Gateway 异常趋势 (24h)
|
|
</h3>
|
|
{trend.length > 0 ? (
|
|
<div className="overflow-x-auto pb-2">
|
|
<Timeline events={timelineEvents} className="min-w-[480px]" />
|
|
</div>
|
|
) : (
|
|
<p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
|
|
)}
|
|
<div className="flex gap-4 mt-3 text-[12px]" style={{ 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>
|
|
<span><span style={{ color: 'var(--yellow)' }}>●</span> 重启</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ---- kanban + cron ---- */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4">
|
|
{/* kanban overview */}
|
|
<div
|
|
className="rounded-[var(--radius)] p-4"
|
|
style={{ background: 'var(--bg-card)' }}
|
|
>
|
|
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
|
|
Kanban 概览
|
|
</h3>
|
|
{kanban.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{kanban.map((item) => (
|
|
<div
|
|
key={item.board}
|
|
className="flex items-center gap-3 py-2 px-3 rounded-[var(--radius-sm)] transition-colors cursor-pointer hover:bg-[var(--bg-hover)]"
|
|
onClick={() => navigate('/kanban')}
|
|
>
|
|
<span className="text-[13px] font-medium min-w-[120px]" style={{ color: 'var(--text)' }}>
|
|
{item.board}
|
|
</span>
|
|
<span className="flex items-center gap-1 text-[12px] px-2 py-0.5 rounded-[var(--radius-sm)]" style={{ background: 'var(--bg-hover)', color: 'var(--honey)' }}>
|
|
RUNNING: {item.running ?? '-'}
|
|
</span>
|
|
<span className="flex items-center gap-1 text-[12px] px-2 py-0.5 rounded-[var(--radius-sm)]" style={{ background: 'var(--bg-hover)', color: 'var(--green)' }}>
|
|
READY: {item.ready ?? '-'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* cron recent */}
|
|
<div
|
|
className="rounded-[var(--radius)] p-4"
|
|
style={{ background: 'var(--bg-card)' }}
|
|
>
|
|
<h3 className="text-[14px] font-semibold mb-3" style={{ color: 'var(--text)' }}>
|
|
Cron 最近执行
|
|
</h3>
|
|
{cron.length > 0 ? (
|
|
<div className="space-y-1">
|
|
{cron.map((item) => (
|
|
<div
|
|
key={item.job}
|
|
className="flex items-center gap-3 py-2 px-3 rounded-[var(--radius-sm)] transition-colors cursor-pointer hover:bg-[var(--bg-hover)]"
|
|
onClick={() => navigate('/cron')}
|
|
>
|
|
<span className="text-[13px]" style={{ color: 'var(--text)' }}>
|
|
{item.job}
|
|
</span>
|
|
<span className="text-[14px]">{statusIcon(item.status)}</span>
|
|
<span className="text-[12px]" style={{ color: 'var(--text-dim)' }}>
|
|
{item.time ?? '-'}
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p style={{ color: 'var(--text-dim)', fontSize: 13 }}>暂无数据</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|