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:
1 parent
8b921c6c83
commit
1fcb984aee
4 files changed
+112
-22
No files matched your search
@@ -63,6 +63,7 @@ async def lifespan(app: FastAPI):
|
||||
import app.models.scheduled_task # noqa
|
||||
import app.models.schema_revision # noqa
|
||||
import app.models.slow_log # noqa
|
||||
import app.models.query_history # noqa
|
||||
|
||||
await init_db()
|
||||
await seed_data()
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from app.models.instance_group import InstanceGroup, user_groups # noqa: F401
|
||||
from app.models.system_setting import SystemSetting # noqa: F401
|
||||
from app.models.query_history import QueryHistory, QuickQueryModel # noqa: F401
|
||||
@@ -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())
|
||||
@@ -65,16 +65,15 @@ const trendDatasets: Record<TrendRange, TrendDataset> = {
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Helpers */
|
||||
/* ------------------------------------------------------------------ */
|
||||
function calcHealthScore(inst: DBInstance): number {
|
||||
function calcHealthScore(inst: any): number {
|
||||
if (inst.status === 'offline') return 0;
|
||||
if (inst.healthScore != null) return inst.healthScore;
|
||||
let score = 100;
|
||||
if (inst.cpu > 80) score -= 25;
|
||||
else if (inst.cpu > 60) score -= 10;
|
||||
if (inst.memory > 85) score -= 20;
|
||||
else if (inst.memory > 70) score -= 10;
|
||||
if (inst.latency > 10) score -= 20;
|
||||
else if (inst.latency > 5) score -= 10;
|
||||
if (inst.disk > 80) score -= 15;
|
||||
const cpu = inst.cpu ?? 0, memory = inst.memory ?? 0, latency = inst.latency ?? 0, disk = inst.disk ?? 0;
|
||||
if (cpu > 80) score -= 25; else if (cpu > 60) score -= 10;
|
||||
if (memory > 85) score -= 20; else if (memory > 70) score -= 10;
|
||||
if (latency > 10) score -= 20; else if (latency > 5) score -= 10;
|
||||
if (disk > 80) score -= 15;
|
||||
return Math.max(0, score);
|
||||
}
|
||||
|
||||
@@ -135,6 +134,27 @@ export default function Dashboard() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
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 ---- */
|
||||
useEffect(() => {
|
||||
dashboardApi.getStats()
|
||||
@@ -150,6 +170,31 @@ export default function Dashboard() {
|
||||
.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 ---- */
|
||||
const visibleAnnouncements = useMemo(() => {
|
||||
return announcements
|
||||
@@ -551,12 +596,12 @@ export default function Dashboard() {
|
||||
</span>
|
||||
</div>
|
||||
<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">
|
||||
<Activity className="w-3 h-3" />
|
||||
{inst.qps.toLocaleString()} QPS
|
||||
{(inst.qps ?? 0).toLocaleString()} QPS
|
||||
</span>
|
||||
<span>{inst.connections} 连接</span>
|
||||
<span>{inst.connections ?? 0} 连接</span>
|
||||
</div>
|
||||
</div>
|
||||
<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" />
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">总查询数</span>
|
||||
</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">
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
12% vs 昨日
|
||||
@@ -666,7 +711,7 @@ export default function Dashboard() {
|
||||
<TrendingUp className="w-3.5 h-3.5 text-orange-400" />
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">平均耗时</span>
|
||||
</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">
|
||||
<ArrowDownRight className="w-3 h-3" />
|
||||
8% vs 昨日
|
||||
@@ -677,7 +722,7 @@ export default function Dashboard() {
|
||||
<AlertTriangle className="w-3.5 h-3.5 text-red-400" />
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">慢查询</span>
|
||||
</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">
|
||||
<ArrowUpRight className="w-3 h-3" />
|
||||
3 vs 昨日
|
||||
@@ -686,20 +731,23 @@ export default function Dashboard() {
|
||||
</div>
|
||||
<div className="space-y-2.5">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] font-medium">查询类型分布</div>
|
||||
{[
|
||||
{ label: 'MySQL', count: 682, total: 1284, color: 'bg-[#00758f]' },
|
||||
{ label: 'PostgreSQL', count: 312, total: 1284, color: 'bg-[#336791]' },
|
||||
{ label: 'Redis', count: 198, total: 1284, color: 'bg-[#dc382d]' },
|
||||
{ label: 'MongoDB', count: 92, total: 1284, color: 'bg-[#47a248]' },
|
||||
].map(item => (
|
||||
{(queryStats?.type_distribution ?? [
|
||||
{ label: 'MySQL', count: 0, color: 'bg-[#00758f]' },
|
||||
{ label: 'PostgreSQL', count: 0, color: 'bg-[#336791]' },
|
||||
{ label: 'Redis', count: 0, color: 'bg-[#dc382d]' },
|
||||
{ label: 'MongoDB', count: 0, color: 'bg-[#47a248]' },
|
||||
]).map((item: any) => {
|
||||
const total = queryStats?.total || 1;
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
<span className="text-xs w-12 text-right text-[var(--dg-text-muted)] font-medium">{item.count}</span>
|
||||
</div>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
Reference in new issue
Block a user