feat: Dashboard with summary cards, health distribution, gateway trend, global search

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
enzo
2026-07-22 17:01:18 +08:00
co-authored by Claude
parent 10404be62d
commit 76045538bb
8 changed files with 621 additions and 6 deletions
+79 -2
View File
@@ -1,10 +1,13 @@
import { lazy, Suspense, type ReactNode } from 'react'
import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router-dom'
import { BrowserRouter, Routes, Route, Navigate, useLocation, useNavigate } from 'react-router-dom'
import { AuthProvider, useAuth } from './hooks/useAuth'
import Skeleton from './components/ui/Skeleton'
import AppLayout from './components/layout/AppLayout'
import LoginPage from './pages/LoginPage'
import NotFoundPage from './pages/NotFoundPage'
import { globalSearch } from './api/dashboard'
import type { SearchGroup } from './components/ui/SearchBox'
import Badge from './components/ui/Badge'
const DashboardPage = lazy(() => import('./pages/DashboardPage'))
const GatewayPage = lazy(() => import('./pages/GatewayPage'))
@@ -47,6 +50,80 @@ function RedirectIfAuth({ children }: { children: ReactNode }) {
function AppRoutes() {
const { user } = useAuth()
const navigate = useNavigate()
const handleSearch = async (query: string): Promise<SearchGroup[]> => {
if (!query.trim()) return []
try {
const res = await globalSearch(query)
const groups: SearchGroup[] = []
if (res.data.profiles.length > 0) {
groups.push({
label: 'Profiles',
items: res.data.profiles.map((p) => ({
value: '/gateway',
label: (
<span className="flex items-center gap-2">
{p.name}
<Badge variant={p.status === 'online' ? 'success' : 'danger'}>
{p.status}
</Badge>
</span>
),
})),
})
}
if (res.data.kanban_tasks.length > 0) {
groups.push({
label: 'Kanban 任务',
items: res.data.kanban_tasks.map((t) => ({
value: '/kanban',
label: (
<span className="flex items-center gap-2">
{t.title}
<Badge variant="honey">{t.column}</Badge>
</span>
),
})),
})
}
if (res.data.cron_jobs.length > 0) {
groups.push({
label: 'Cron 任务',
items: res.data.cron_jobs.map((j) => ({
value: '/cron',
label: (
<span className="flex items-center gap-2">
{j.job}
<Badge variant={j.status === 'active' ? 'success' : 'honey'}>{j.status}</Badge>
</span>
),
})),
})
}
if (res.data.events.length > 0) {
groups.push({
label: '事件',
items: res.data.events.map((e) => ({
value: '/events',
label: (
<span className="flex items-center gap-2">
{e.title}
<Badge variant="honey">{e.status}</Badge>
</span>
),
})),
})
}
return groups
} catch {
return []
}
}
const handleSearchNavigate = (value: string) => {
navigate(value)
}
return (
<Routes>
@@ -63,7 +140,7 @@ function AppRoutes() {
path="/"
element={
<RequireAuth>
<AppLayout>
<AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}>
<Suspense fallback={<Loading />}>
<Routes>
<Route index element={<Navigate to="/dashboard" replace />} />
+63
View File
@@ -0,0 +1,63 @@
import client from './client'
export interface SummaryData {
online_count: number
offline_count: number
total_profiles: number
running_tasks: number
cron_failures_today: number
updated_at: string
}
export interface HealthDistribution {
A: number
B: number
C: number
D: number
total: number
d_profiles: string[]
}
export interface GatewayTrendItem {
hour: string
restarts: number
feishu_disconnects: number
heartbeat_timeouts: number
}
export interface KanbanSummaryItem {
board: string
running: string | null
ready: string | null
}
export interface CronRecentItem {
job: string
status: string
time?: string
}
export interface SearchResult {
profiles: { name: string; status: string }[]
kanban_tasks: { title: string; column: string }[]
cron_jobs: { job: string; status: string }[]
events: { title: string; status: string }[]
}
export const getDashboardSummary = () =>
client.get<SummaryData>('/dashboard/summary')
export const getHealthDistribution = () =>
client.get<HealthDistribution>('/dashboard/health-distribution')
export const getGatewayTrend = () =>
client.get<GatewayTrendItem[]>('/dashboard/gateway-trend')
export const getKanbanSummary = () =>
client.get<KanbanSummaryItem[]>('/dashboard/kanban-summary')
export const getCronRecent = () =>
client.get<CronRecentItem[]>('/dashboard/cron-recent')
export const globalSearch = (q: string) =>
client.get<SearchResult>('/search', { params: { q } })
@@ -28,6 +28,7 @@ export interface AppLayoutProps {
onlineCount?: number
runningCount?: number
onSearch?: (query: string) => any
onSearchNavigate?: (value: string) => void
}
export default function AppLayout({
@@ -36,6 +37,7 @@ export default function AppLayout({
onlineCount = 0,
runningCount = 0,
onSearch,
onSearchNavigate,
}: AppLayoutProps) {
const { theme, toggle } = useTheme()
const navigate = useNavigate()
@@ -121,6 +123,7 @@ export default function AppLayout({
onlineCount={onlineCount}
runningCount={runningCount}
onSearch={onSearch}
onSearchNavigate={onSearchNavigate}
theme={theme}
onToggleTheme={toggle}
/>
+3 -1
View File
@@ -8,6 +8,7 @@ export interface HeaderProps {
onlineCount?: number
runningCount?: number
onSearch?: (query: string) => any
onSearchNavigate?: (value: string) => void
theme: string
onToggleTheme: () => void
}
@@ -17,6 +18,7 @@ export default function Header({
onlineCount = 0,
runningCount = 0,
onSearch,
onSearchNavigate,
theme,
onToggleTheme,
}: HeaderProps) {
@@ -54,7 +56,7 @@ export default function Header({
{/* Search */}
{onSearch && (
<SearchBox onSearch={onSearch} placeholder="搜索功能、任务、日志..." />
<SearchBox onSearch={onSearch} onItemClick={onSearchNavigate} placeholder="搜索功能、任务、日志..." />
)}
{/* Status badges */}
+4
View File
@@ -10,6 +10,7 @@ export interface SearchBoxProps {
placeholder?: string
debounceMs?: number
className?: string
onItemClick?: (value: string) => void
}
export default function SearchBox({
@@ -17,6 +18,7 @@ export default function SearchBox({
placeholder = "搜索...",
debounceMs = 300,
className = "",
onItemClick,
}: SearchBoxProps) {
const [query, setQuery] = useState("")
const [groups, setGroups] = useState<SearchGroup[]>([])
@@ -87,6 +89,7 @@ export default function SearchBox({
e.preventDefault()
setQuery(String(flatItems[activeIdx].label))
setOpen(false)
onItemClick?.(flatItems[activeIdx].value)
inputRef.current?.blur()
} else if (e.key === "Escape") {
setOpen(false)
@@ -144,6 +147,7 @@ export default function SearchBox({
onMouseDown={() => {
setQuery(String(item.label))
setOpen(false)
onItemClick?.(item.value)
}}
>
{item.label}
+350 -2
View File
@@ -1,3 +1,351 @@
export default function DashboardPage() {
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Dashboard 仪表板</p></div>
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>
)
}