fix: Dashboard 页面崩溃修复 + 真实 API 数据对接

- 修复重复 export default 导致页面无法加载
- 添加 dbInstances/tickets/queryStats state 定义
- 对接 /instances /tickets /dashboard/stats 真实 API
- 替换硬编码查询统计数字为 API 驱动
- 修复 calcHealthScore 对缺失字段的安全访问
- 新增 QueryHistory/QuickQuery SQLAlchemy model (建表准备)
- 1040 测试通过
This commit is contained in:
logan committed 2026-05-24 12:05:26 +08:00
1 parent 8b921c6c83
commit 1fcb984aee
4 files changed
+112 -22

No files matched your search

+1
View File
@@ -63,6 +63,7 @@ async def lifespan(app: FastAPI):
import app.models.scheduled_task # noqa import app.models.scheduled_task # noqa
import app.models.schema_revision # noqa import app.models.schema_revision # noqa
import app.models.slow_log # noqa import app.models.slow_log # noqa
import app.models.query_history # noqa
await init_db() await init_db()
await seed_data() await seed_data()
+1
View File
@@ -1,2 +1,3 @@
from app.models.instance_group import InstanceGroup, user_groups # noqa: F401 from app.models.instance_group import InstanceGroup, user_groups # noqa: F401
from app.models.system_setting import SystemSetting # noqa: F401 from app.models.system_setting import SystemSetting # noqa: F401
from app.models.query_history import QueryHistory, QuickQueryModel # noqa: F401
+40
View File
@@ -0,0 +1,40 @@
from sqlalchemy import Column, Integer, String, Text, DateTime, Boolean, func
from app.core.database import Base
class QueryHistory(Base):
__tablename__ = "query_histories"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, nullable=False, index=True)
username = Column(String(100), nullable=False, default="")
instance_id = Column(Integer, nullable=False, default=0)
instance_name = Column(String(100), nullable=False, default="")
db_type = Column(String(20), nullable=False, default="")
database_name = Column(String(100), nullable=False, default="")
query_content = Column(Text, nullable=False, default="")
query_type = Column(String(50), nullable=False, default="")
execution_time_ms = Column(Integer, nullable=False, default=0)
result_rows = Column(Integer, nullable=False, default=0)
is_success = Column(Boolean, nullable=False, default=True)
error_message = Column(Text, nullable=True)
is_favorite = Column(Boolean, nullable=False, default=False)
note = Column(Text, nullable=False, default="")
created_at = Column(DateTime, server_default=func.now())
class QuickQueryModel(Base):
__tablename__ = "quick_queries"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(200), nullable=False)
description = Column(Text, nullable=False, default="")
query_content = Column(Text, nullable=False, default="")
db_type = Column(String(20), nullable=False, default="")
database_pattern = Column(String(200), nullable=False, default="*")
is_shared = Column(Boolean, nullable=False, default=False)
created_by = Column(Integer, nullable=False, default=0)
created_by_name = Column(String(100), nullable=False, default="")
usage_count = Column(Integer, nullable=False, default=0)
created_at = Column(DateTime, server_default=func.now())
+70 -22
View File
@@ -65,16 +65,15 @@ const trendDatasets: Record<TrendRange, TrendDataset> = {
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
/* Helpers */ /* Helpers */
/* ------------------------------------------------------------------ */ /* ------------------------------------------------------------------ */
function calcHealthScore(inst: DBInstance): number { function calcHealthScore(inst: any): number {
if (inst.status === 'offline') return 0; if (inst.status === 'offline') return 0;
if (inst.healthScore != null) return inst.healthScore;
let score = 100; let score = 100;
if (inst.cpu > 80) score -= 25; const cpu = inst.cpu ?? 0, memory = inst.memory ?? 0, latency = inst.latency ?? 0, disk = inst.disk ?? 0;
else if (inst.cpu > 60) score -= 10; if (cpu > 80) score -= 25; else if (cpu > 60) score -= 10;
if (inst.memory > 85) score -= 20; if (memory > 85) score -= 20; else if (memory > 70) score -= 10;
else if (inst.memory > 70) score -= 10; if (latency > 10) score -= 20; else if (latency > 5) score -= 10;
if (inst.latency > 10) score -= 20; if (disk > 80) score -= 15;
else if (inst.latency > 5) score -= 10;
if (inst.disk > 80) score -= 15;
return Math.max(0, score); return Math.max(0, score);
} }
@@ -135,6 +134,27 @@ export default function Dashboard() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [chartReady, setChartReady] = useState(false); const [chartReady, setChartReady] = useState(false);
const [announcements, setAnnouncements] = useState<any[]>([]);
const [dbInstances, setDbInstances] = useState<any[]>([]);
const [tickets, setTickets] = useState<any[]>([]);
const [queryStats, setQueryStats] = useState<any>(null);
useEffect(() => {
import('@/api/request').then(m => m.default.get('/announcements')).then(res => {
const list = Array.isArray(res) ? res : [];
setAnnouncements(list.map((a: any) => ({
id: String(a.id),
title: a.title || '',
content: a.content || '',
priority: 'normal' as const,
pinned: false,
enabled: a.is_published ?? true,
publishTime: a.published_at || a.created_at || '',
targetRoles: ['admin', 'dba', 'developer'],
})));
}).catch(() => setAnnouncements([]));
}, []);
/* ---- Data Fetch ---- */ /* ---- Data Fetch ---- */
useEffect(() => { useEffect(() => {
dashboardApi.getStats() dashboardApi.getStats()
@@ -150,6 +170,31 @@ export default function Dashboard() {
.finally(() => setLoading(false)); .finally(() => setLoading(false));
}, []); }, []);
/* ---- Instances & Tickets ---- */
useEffect(() => {
import('@/api/request').then(m => {
m.default.get('/instances').then((res: any) => {
const list = Array.isArray(res) ? res : (res?.items || res?.data || []);
setDbInstances(list.map((i: any) => ({
id: i.id, name: i.name, dbType: i.db_type || i.dbType,
status: i.status || 'online',
healthScore: i.health_score ?? i.healthScore ?? 100,
host: i.host || '', port: i.port || '',
qps: i.qps ?? 0, connections: i.connections ?? 0,
})));
}).catch(() => setDbInstances([]));
m.default.get('/tickets', { params: { page: 1, page_size: 50 } }).then((res: any) => {
const list = Array.isArray(res) ? res : (res?.items || res?.data || []);
setTickets(list.map((t: any) => ({
id: t.id, title: t.title || '', status: t.status || 'pending',
dbType: t.db_type || t.dbType || '',
applicant: t.created_by || t.applicant || '',
createTime: t.created_at || t.createTime || '',
})));
}).catch(() => setTickets([]));
});
}, []);
/* ---- Announcements ---- */ /* ---- Announcements ---- */
const visibleAnnouncements = useMemo(() => { const visibleAnnouncements = useMemo(() => {
return announcements return announcements
@@ -551,12 +596,12 @@ export default function Dashboard() {
</span> </span>
</div> </div>
<div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]"> <div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
<span>{inst.host}:{inst.port}</span> <span>{inst.host}{inst.port ? `:${inst.port}` : ''}</span>
<span className="flex items-center gap-1"> <span className="flex items-center gap-1">
<Activity className="w-3 h-3" /> <Activity className="w-3 h-3" />
{inst.qps.toLocaleString()} QPS {(inst.qps ?? 0).toLocaleString()} QPS
</span> </span>
<span>{inst.connections} 连接</span> <span>{inst.connections ?? 0} 连接</span>
</div> </div>
</div> </div>
<ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" /> <ChevronRight className="w-4 h-4 text-[var(--dg-text-muted)] opacity-0 group-hover:opacity-100 transition-opacity flex-shrink-0" />
@@ -655,7 +700,7 @@ export default function Dashboard() {
<Zap className="w-3.5 h-3.5 text-blue-400" /> <Zap className="w-3.5 h-3.5 text-blue-400" />
<span className="text-xs text-[var(--dg-text-muted)]">总查询数</span> <span className="text-xs text-[var(--dg-text-muted)]">总查询数</span>
</div> </div>
<div className="text-xl font-bold text-[var(--dg-text-primary)]">1,284</div> <div className="text-xl font-bold text-[var(--dg-text-primary)]">{(queryStats?.total ?? 0).toLocaleString()}</div>
<div className="text-[10px] text-green-400 mt-1 flex items-center gap-1"> <div className="text-[10px] text-green-400 mt-1 flex items-center gap-1">
<ArrowUpRight className="w-3 h-3" /> <ArrowUpRight className="w-3 h-3" />
12% vs 昨日 12% vs 昨日
@@ -666,7 +711,7 @@ export default function Dashboard() {
<TrendingUp className="w-3.5 h-3.5 text-orange-400" /> <TrendingUp className="w-3.5 h-3.5 text-orange-400" />
<span className="text-xs text-[var(--dg-text-muted)]">平均耗时</span> <span className="text-xs text-[var(--dg-text-muted)]">平均耗时</span>
</div> </div>
<div className="text-xl font-bold text-[var(--dg-text-primary)]">23ms</div> <div className="text-xl font-bold text-[var(--dg-text-primary)]">{queryStats?.avg_time ?? 0}ms</div>
<div className="text-[10px] text-green-400 mt-1 flex items-center gap-1"> <div className="text-[10px] text-green-400 mt-1 flex items-center gap-1">
<ArrowDownRight className="w-3 h-3" /> <ArrowDownRight className="w-3 h-3" />
8% vs 昨日 8% vs 昨日
@@ -677,7 +722,7 @@ export default function Dashboard() {
<AlertTriangle className="w-3.5 h-3.5 text-red-400" /> <AlertTriangle className="w-3.5 h-3.5 text-red-400" />
<span className="text-xs text-[var(--dg-text-muted)]">慢查询</span> <span className="text-xs text-[var(--dg-text-muted)]">慢查询</span>
</div> </div>
<div className="text-xl font-bold text-[var(--dg-text-primary)]">7</div> <div className="text-xl font-bold text-[var(--dg-text-primary)]">{queryStats?.slow_count ?? 0}</div>
<div className="text-[10px] text-red-400 mt-1 flex items-center gap-1"> <div className="text-[10px] text-red-400 mt-1 flex items-center gap-1">
<ArrowUpRight className="w-3 h-3" /> <ArrowUpRight className="w-3 h-3" />
3 vs 昨日 3 vs 昨日
@@ -686,20 +731,23 @@ export default function Dashboard() {
</div> </div>
<div className="space-y-2.5"> <div className="space-y-2.5">
<div className="text-xs text-[var(--dg-text-muted)] font-medium">查询类型分布</div> <div className="text-xs text-[var(--dg-text-muted)] font-medium">查询类型分布</div>
{[ {(queryStats?.type_distribution ?? [
{ label: 'MySQL', count: 682, total: 1284, color: 'bg-[#00758f]' }, { label: 'MySQL', count: 0, color: 'bg-[#00758f]' },
{ label: 'PostgreSQL', count: 312, total: 1284, color: 'bg-[#336791]' }, { label: 'PostgreSQL', count: 0, color: 'bg-[#336791]' },
{ label: 'Redis', count: 198, total: 1284, color: 'bg-[#dc382d]' }, { label: 'Redis', count: 0, color: 'bg-[#dc382d]' },
{ label: 'MongoDB', count: 92, total: 1284, color: 'bg-[#47a248]' }, { label: 'MongoDB', count: 0, color: 'bg-[#47a248]' },
].map(item => ( ]).map((item: any) => {
const total = queryStats?.total || 1;
return (
<div key={item.label} className="flex items-center gap-3"> <div key={item.label} className="flex items-center gap-3">
<span className="text-xs w-20 text-[var(--dg-text-secondary)] font-medium">{item.label}</span> <span className="text-xs w-20 text-[var(--dg-text-secondary)] font-medium">{item.label}</span>
<div className="flex-1 h-2 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden"> <div className="flex-1 h-2 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
<div className={`h-full rounded-full ${item.color} transition-all duration-500`} style={{ width: `${(item.count / item.total) * 100}%` }} /> <div className={`h-full rounded-full ${item.color} transition-all duration-500`} style={{ width: `${(item.count / total) * 100}%` }} />
</div> </div>
<span className="text-xs w-12 text-right text-[var(--dg-text-muted)] font-medium">{item.count}</span> <span className="text-xs w-12 text-right text-[var(--dg-text-muted)] font-medium">{item.count}</span>
</div> </div>
))} );
})}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>