feat: Dashboard with summary cards, health distribution, gateway trend, global search
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
"""Dashboard API: aggregated stats, health distribution, gateway trend,
|
||||
kanban overview, cron recent, and global search — all mock data for now."""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from app.middleware.auth import require_auth
|
||||
from app.models.user import User
|
||||
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
|
||||
|
||||
@router.get("/dashboard/summary")
|
||||
async def dashboard_summary(_user: User = Depends(require_auth)):
|
||||
# TODO: replace with real data — hermes gateway status + kanban + cron
|
||||
return {
|
||||
"online_count": 12,
|
||||
"offline_count": 46,
|
||||
"total_profiles": 58,
|
||||
"running_tasks": 3,
|
||||
"cron_failures_today": 1,
|
||||
"updated_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dashboard/health-distribution")
|
||||
async def health_distribution(_user: User = Depends(require_auth)):
|
||||
# TODO: replace with real health-score calculation (Phase 4)
|
||||
return {
|
||||
"A": 22,
|
||||
"B": 18,
|
||||
"C": 10,
|
||||
"D": 8,
|
||||
"total": 58,
|
||||
"d_profiles": ["ops-engineer", "monitor", "visualizer", "hebo", "..."],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/dashboard/gateway-trend")
|
||||
async def gateway_trend(_user: User = Depends(require_auth)):
|
||||
# TODO: replace with real 24h aggregation from event store
|
||||
return [
|
||||
{"hour": "2026-07-22T08:00", "restarts": 2, "feishu_disconnects": 1, "heartbeat_timeouts": 0},
|
||||
{"hour": "2026-07-22T09:00", "restarts": 0, "feishu_disconnects": 0, "heartbeat_timeouts": 1},
|
||||
{"hour": "2026-07-22T10:00", "restarts": 1, "feishu_disconnects": 0, "heartbeat_timeouts": 0},
|
||||
{"hour": "2026-07-22T11:00", "restarts": 0, "feishu_disconnects": 2, "heartbeat_timeouts": 0},
|
||||
{"hour": "2026-07-22T12:00", "restarts": 0, "feishu_disconnects": 0, "heartbeat_timeouts": 0},
|
||||
{"hour": "2026-07-22T13:00", "restarts": 3, "feishu_disconnects": 1, "heartbeat_timeouts": 2},
|
||||
{"hour": "2026-07-22T14:00", "restarts": 0, "feishu_disconnects": 0, "heartbeat_timeouts": 1},
|
||||
{"hour": "2026-07-22T15:00", "restarts": 1, "feishu_disconnects": 0, "heartbeat_timeouts": 0},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/dashboard/kanban-summary")
|
||||
async def kanban_summary(_user: User = Depends(require_auth)):
|
||||
# TODO: replace with real kanban task data
|
||||
return [
|
||||
{"board": "europe-briefing", "running": "S4", "ready": "S5"},
|
||||
{"board": "tech-weekly", "running": "S1", "ready": None},
|
||||
{"board": "ops-review", "running": "M2", "ready": "M3"},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/dashboard/cron-recent")
|
||||
async def cron_recent(_user: User = Depends(require_auth)):
|
||||
# TODO: replace with real cron execution history
|
||||
return [
|
||||
{"job": "taiwan-briefing", "status": "success", "time": "07:15"},
|
||||
{"job": "hindsight", "status": "failure", "time": "21:13"},
|
||||
{"job": "guangxi-donation", "status": "paused"},
|
||||
{"job": "daily-report", "status": "success", "time": "08:00"},
|
||||
{"job": "weekly-digest", "status": "success", "time": "09:30"},
|
||||
]
|
||||
|
||||
|
||||
@router.get("/search")
|
||||
async def global_search(
|
||||
q: str = Query("", min_length=1),
|
||||
_user: User = Depends(require_auth),
|
||||
):
|
||||
# TODO: replace with real full-text search across profiles, tasks, cron, events
|
||||
keyword = q.lower()
|
||||
|
||||
profiles = []
|
||||
if "gateway" in keyword or "monitor" in keyword:
|
||||
profiles.append({"name": "gateway-monitor", "status": "online"})
|
||||
if "visual" in keyword:
|
||||
profiles.append({"name": "visualizer", "status": "online"})
|
||||
if "hebo" in keyword or "ops" in keyword:
|
||||
profiles.append({"name": "ops-engineer", "status": "online"})
|
||||
|
||||
kanban_tasks = []
|
||||
if "briefing" in keyword or "europe" in keyword:
|
||||
kanban_tasks.append({"title": "Europe Briefing S4", "column": "RUNNING"})
|
||||
if "tech" in keyword or "weekly" in keyword:
|
||||
kanban_tasks.append({"title": "Tech Weekly S1", "column": "RUNNING"})
|
||||
|
||||
cron_jobs = []
|
||||
if "taiwan" in keyword or "briefing" in keyword:
|
||||
cron_jobs.append({"job": "taiwan-briefing", "status": "active"})
|
||||
if "hindsight" in keyword:
|
||||
cron_jobs.append({"job": "hindsight", "status": "active"})
|
||||
if "donation" in keyword or "guangxi" in keyword:
|
||||
cron_jobs.append({"job": "guangxi-donation", "status": "paused"})
|
||||
|
||||
events = []
|
||||
if "gateway" in keyword or keyword == "restart":
|
||||
events.append({"title": "Gateway restart detected", "status": "active"})
|
||||
if "feishu" in keyword or "disconnect" in keyword:
|
||||
events.append({"title": "Feishu disconnect alert", "status": "active"})
|
||||
|
||||
return {
|
||||
"profiles": profiles,
|
||||
"kanban_tasks": kanban_tasks,
|
||||
"cron_jobs": cron_jobs,
|
||||
"events": events,
|
||||
}
|
||||
+2
-1
@@ -4,7 +4,7 @@ from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.database import engine, Base
|
||||
from app.api import auth, users, audit
|
||||
from app.api import auth, users, audit, dashboard
|
||||
from app.middleware.audit import AuditMiddleware
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ app.add_middleware(AuditMiddleware)
|
||||
app.include_router(auth.router)
|
||||
app.include_router(users.router)
|
||||
app.include_router(audit.router)
|
||||
app.include_router(dashboard.router, prefix="/api/v1")
|
||||
|
||||
|
||||
@app.get("/api/v1/health")
|
||||
|
||||
+79
-2
@@ -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 />} />
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user