diff --git a/backend/app/api/dashboard.py b/backend/app/api/dashboard.py new file mode 100644 index 0000000..07a3e9c --- /dev/null +++ b/backend/app/api/dashboard.py @@ -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, + } diff --git a/backend/app/main.py b/backend/app/main.py index fa3945f..0b5043f 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -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") diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6eea1e8..b861a6c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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 => { + 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: ( + + {p.name} + + {p.status} + + + ), + })), + }) + } + if (res.data.kanban_tasks.length > 0) { + groups.push({ + label: 'Kanban 任务', + items: res.data.kanban_tasks.map((t) => ({ + value: '/kanban', + label: ( + + {t.title} + {t.column} + + ), + })), + }) + } + if (res.data.cron_jobs.length > 0) { + groups.push({ + label: 'Cron 任务', + items: res.data.cron_jobs.map((j) => ({ + value: '/cron', + label: ( + + {j.job} + {j.status} + + ), + })), + }) + } + if (res.data.events.length > 0) { + groups.push({ + label: '事件', + items: res.data.events.map((e) => ({ + value: '/events', + label: ( + + {e.title} + {e.status} + + ), + })), + }) + } + return groups + } catch { + return [] + } + } + + const handleSearchNavigate = (value: string) => { + navigate(value) + } return ( @@ -63,7 +140,7 @@ function AppRoutes() { path="/" element={ - + }> } /> diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts new file mode 100644 index 0000000..992d25a --- /dev/null +++ b/frontend/src/api/dashboard.ts @@ -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('/dashboard/summary') + +export const getHealthDistribution = () => + client.get('/dashboard/health-distribution') + +export const getGatewayTrend = () => + client.get('/dashboard/gateway-trend') + +export const getKanbanSummary = () => + client.get('/dashboard/kanban-summary') + +export const getCronRecent = () => + client.get('/dashboard/cron-recent') + +export const globalSearch = (q: string) => + client.get('/search', { params: { q } }) diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx index 24af74a..23cb5b0 100644 --- a/frontend/src/components/layout/AppLayout.tsx +++ b/frontend/src/components/layout/AppLayout.tsx @@ -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} /> diff --git a/frontend/src/components/layout/Header.tsx b/frontend/src/components/layout/Header.tsx index e8db7cf..6803a1f 100644 --- a/frontend/src/components/layout/Header.tsx +++ b/frontend/src/components/layout/Header.tsx @@ -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 && ( - + )} {/* Status badges */} diff --git a/frontend/src/components/ui/SearchBox.tsx b/frontend/src/components/ui/SearchBox.tsx index 953cb6d..a0de85e 100644 --- a/frontend/src/components/ui/SearchBox.tsx +++ b/frontend/src/components/ui/SearchBox.tsx @@ -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([]) @@ -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} diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 917f796..0cbaf29 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -1,3 +1,351 @@ -export default function DashboardPage() { - return

蜂群总览

Dashboard 仪表板

+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

暂无数据

+ } + + const maxVal = Math.max(dist.A, dist.B, dist.C, dist.D, 1) + + return ( +
+ {GRADE_META.map(({ key, color }) => { + const val = dist[key as keyof typeof dist] as number + const pct = (val / maxVal) * 100 + return ( +
onBarClick(key)} + > + + {key} + +
+
+
+ + {val} + +
+ ) + })} +
+ ) +} + +/* ---------- main page ---------- */ + +export default function DashboardPage() { + const navigate = useNavigate() + + const [summary, setSummary] = useState(null) + const [health, setHealth] = useState(null) + const [trend, setTrend] = useState([]) + const [kanban, setKanban] = useState([]) + const [cron, setCron] = useState([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState(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 ( +
+

蜂群总览

+ +
+ + +
+
+ + +
+
+ ) + } + + /* ---------- error ---------- */ + if (error) { + return ( +
+

蜂群总览

+
+

{error}

+ +
+
+ ) + } + + /* ---------- empty ---------- */ + if (!summary) { + return ( +
+

蜂群总览

+

暂无数据

+
+ ) + } + + /* ---------- 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: ( + + {item.restarts > 0 && R:{item.restarts}} + {item.feishu_disconnects > 0 && F:{item.feishu_disconnects}} + {item.heartbeat_timeouts > 0 && H:{item.heartbeat_timeouts}} + {item.restarts === 0 && item.feishu_disconnects === 0 && item.heartbeat_timeouts === 0 && ( + OK + )} + + ), + color: trendColor(item), + })) + + return ( +
+

蜂群总览

+ + {/* ---- summary cards ---- */} +
+ + + + 3 ? 'red' : 'yellow'} + change={summary.cron_failures_today > 3 ? '需关注' : undefined} + changeDirection={summary.cron_failures_today > 3 ? 'up' : undefined} + /> +
+ + {/* ---- health + trend ---- */} +
+ {/* health distribution */} +
+

+ 蜂群健康分布 +

+

+ 共 {health?.total ?? 0} 个 Profile +

+ {health ? ( + navigate(`/health?filter=${g}`)} /> + ) : ( +

暂无数据

+ )} +
+ + {/* gateway trend */} +
+

+ Gateway 异常趋势 (24h) +

+ {trend.length > 0 ? ( +
+ +
+ ) : ( +

暂无数据

+ )} +
+ ● 正常 + ● 心跳超时 + ● 飞书断连 + ● 重启 +
+
+
+ + {/* ---- kanban + cron ---- */} +
+ {/* kanban overview */} +
+

+ Kanban 概览 +

+ {kanban.length > 0 ? ( +
+ {kanban.map((item) => ( +
navigate('/kanban')} + > + + {item.board} + + + RUNNING: {item.running ?? '-'} + + + READY: {item.ready ?? '-'} + +
+ ))} +
+ ) : ( +

暂无数据

+ )} +
+ + {/* cron recent */} +
+

+ Cron 最近执行 +

+ {cron.length > 0 ? ( +
+ {cron.map((item) => ( +
navigate('/cron')} + > + + {item.job} + + {statusIcon(item.status)} + + {item.time ?? '-'} + +
+ ))} +
+ ) : ( +

暂无数据

+ )} +
+
+
+ ) }