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
+117
View File
@@ -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
View File
@@ -4,7 +4,7 @@ from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from app.database import engine, Base 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 from app.middleware.audit import AuditMiddleware
@@ -34,6 +34,7 @@ app.add_middleware(AuditMiddleware)
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(users.router) app.include_router(users.router)
app.include_router(audit.router) app.include_router(audit.router)
app.include_router(dashboard.router, prefix="/api/v1")
@app.get("/api/v1/health") @app.get("/api/v1/health")
+79 -2
View File
@@ -1,10 +1,13 @@
import { lazy, Suspense, type ReactNode } from 'react' 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 { AuthProvider, useAuth } from './hooks/useAuth'
import Skeleton from './components/ui/Skeleton' import Skeleton from './components/ui/Skeleton'
import AppLayout from './components/layout/AppLayout' import AppLayout from './components/layout/AppLayout'
import LoginPage from './pages/LoginPage' import LoginPage from './pages/LoginPage'
import NotFoundPage from './pages/NotFoundPage' 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 DashboardPage = lazy(() => import('./pages/DashboardPage'))
const GatewayPage = lazy(() => import('./pages/GatewayPage')) const GatewayPage = lazy(() => import('./pages/GatewayPage'))
@@ -47,6 +50,80 @@ function RedirectIfAuth({ children }: { children: ReactNode }) {
function AppRoutes() { function AppRoutes() {
const { user } = useAuth() 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 ( return (
<Routes> <Routes>
@@ -63,7 +140,7 @@ function AppRoutes() {
path="/" path="/"
element={ element={
<RequireAuth> <RequireAuth>
<AppLayout> <AppLayout onSearch={handleSearch} onSearchNavigate={handleSearchNavigate}>
<Suspense fallback={<Loading />}> <Suspense fallback={<Loading />}>
<Routes> <Routes>
<Route index element={<Navigate to="/dashboard" replace />} /> <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 onlineCount?: number
runningCount?: number runningCount?: number
onSearch?: (query: string) => any onSearch?: (query: string) => any
onSearchNavigate?: (value: string) => void
} }
export default function AppLayout({ export default function AppLayout({
@@ -36,6 +37,7 @@ export default function AppLayout({
onlineCount = 0, onlineCount = 0,
runningCount = 0, runningCount = 0,
onSearch, onSearch,
onSearchNavigate,
}: AppLayoutProps) { }: AppLayoutProps) {
const { theme, toggle } = useTheme() const { theme, toggle } = useTheme()
const navigate = useNavigate() const navigate = useNavigate()
@@ -121,6 +123,7 @@ export default function AppLayout({
onlineCount={onlineCount} onlineCount={onlineCount}
runningCount={runningCount} runningCount={runningCount}
onSearch={onSearch} onSearch={onSearch}
onSearchNavigate={onSearchNavigate}
theme={theme} theme={theme}
onToggleTheme={toggle} onToggleTheme={toggle}
/> />
+3 -1
View File
@@ -8,6 +8,7 @@ export interface HeaderProps {
onlineCount?: number onlineCount?: number
runningCount?: number runningCount?: number
onSearch?: (query: string) => any onSearch?: (query: string) => any
onSearchNavigate?: (value: string) => void
theme: string theme: string
onToggleTheme: () => void onToggleTheme: () => void
} }
@@ -17,6 +18,7 @@ export default function Header({
onlineCount = 0, onlineCount = 0,
runningCount = 0, runningCount = 0,
onSearch, onSearch,
onSearchNavigate,
theme, theme,
onToggleTheme, onToggleTheme,
}: HeaderProps) { }: HeaderProps) {
@@ -54,7 +56,7 @@ export default function Header({
{/* Search */} {/* Search */}
{onSearch && ( {onSearch && (
<SearchBox onSearch={onSearch} placeholder="搜索功能、任务、日志..." /> <SearchBox onSearch={onSearch} onItemClick={onSearchNavigate} placeholder="搜索功能、任务、日志..." />
)} )}
{/* Status badges */} {/* Status badges */}
+4
View File
@@ -10,6 +10,7 @@ export interface SearchBoxProps {
placeholder?: string placeholder?: string
debounceMs?: number debounceMs?: number
className?: string className?: string
onItemClick?: (value: string) => void
} }
export default function SearchBox({ export default function SearchBox({
@@ -17,6 +18,7 @@ export default function SearchBox({
placeholder = "搜索...", placeholder = "搜索...",
debounceMs = 300, debounceMs = 300,
className = "", className = "",
onItemClick,
}: SearchBoxProps) { }: SearchBoxProps) {
const [query, setQuery] = useState("") const [query, setQuery] = useState("")
const [groups, setGroups] = useState<SearchGroup[]>([]) const [groups, setGroups] = useState<SearchGroup[]>([])
@@ -87,6 +89,7 @@ export default function SearchBox({
e.preventDefault() e.preventDefault()
setQuery(String(flatItems[activeIdx].label)) setQuery(String(flatItems[activeIdx].label))
setOpen(false) setOpen(false)
onItemClick?.(flatItems[activeIdx].value)
inputRef.current?.blur() inputRef.current?.blur()
} else if (e.key === "Escape") { } else if (e.key === "Escape") {
setOpen(false) setOpen(false)
@@ -144,6 +147,7 @@ export default function SearchBox({
onMouseDown={() => { onMouseDown={() => {
setQuery(String(item.label)) setQuery(String(item.label))
setOpen(false) setOpen(false)
onItemClick?.(item.value)
}} }}
> >
{item.label} {item.label}
+350 -2
View File
@@ -1,3 +1,351 @@
export default function DashboardPage() { import { useEffect, useState, useCallback } from 'react'
return <div><h2 style={{ color: 'var(--text)', fontSize: 18, fontWeight: 600 }}>蜂群总览</h2><p style={{ color: 'var(--text-dim)', marginTop: 8 }}>Dashboard 仪表板</p></div> 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>
)
} }