后端: - ticket_statistics/list_tickets 按用户身份过滤(admin全部, dba/auditor审批相关, developer自己的) - 新增 /dashboard/my-tickets 接口(按角色返回待处理工单) - auth.py 本地优先+LDAP fallback 登录认证 - ldap_config.py LDAPS 证书验证修复 - instance_detail.py developer/auditor 权限修复 前端: - Dashboard 仪表盘: 3核心大数字指标+公告+我的待处理工单 - 删除 ECharts 趋势图(无真实数据) - TicketDetailPage 审批流程时间线展示 - TicketsPage 工单统计按用户过滤+关联列(我提交的/待我审批) - types 新增 TicketApproval 等类型定义
564 lines
29 KiB
TypeScript
564 lines
29 KiB
TypeScript
import { useEffect, useState, useCallback, useMemo } from 'react';
|
|
import { useNavigate } from 'react-router-dom';
|
|
import {
|
|
ClipboardList, Clock, CheckCircle, AlertTriangle,
|
|
Database, ChevronRight, X, Bell, Eye,
|
|
Activity, TrendingUp, Zap, Server, Search,
|
|
FileText, ArrowUpRight, ArrowDownRight,
|
|
} from 'lucide-react';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { statusMap, statusColorMap, dbTypeColorMap } from '@/lib/constants';
|
|
import { useTheme } from '@/hooks/useTheme';
|
|
import { useAuth } from '@/hooks/useAuth';
|
|
import { dashboardApi } from '@/api/dashboard';
|
|
import type { DashboardStats } from '@/types';
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Types */
|
|
/* ------------------------------------------------------------------ */
|
|
interface QuickAction {
|
|
label: string;
|
|
icon: any;
|
|
onClick: () => void;
|
|
color: string;
|
|
bg: string;
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Helpers */
|
|
/* ------------------------------------------------------------------ */
|
|
function calcHealthScore(inst: any): number {
|
|
if (inst.status === 'offline') return 0;
|
|
if (inst.healthScore != null) return inst.healthScore;
|
|
let score = 100;
|
|
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);
|
|
}
|
|
|
|
function healthColor(score: number): string {
|
|
if (score >= 90) return 'text-green-400';
|
|
if (score >= 70) return 'text-yellow-400';
|
|
if (score >= 50) return 'text-orange-400';
|
|
return 'text-red-400';
|
|
}
|
|
|
|
function healthBg(score: number): string {
|
|
if (score >= 90) return 'bg-green-500/15';
|
|
if (score >= 70) return 'bg-yellow-500/15';
|
|
if (score >= 50) return 'bg-orange-500/15';
|
|
return 'bg-red-500/15';
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Skeleton Components */
|
|
/* ------------------------------------------------------------------ */
|
|
function StatCardSkeleton() {
|
|
return (
|
|
<Card className="dg-card">
|
|
<CardContent className="p-5">
|
|
<div className="flex items-center justify-between mb-4">
|
|
<div className="h-4 w-16 animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
|
|
<div className="h-10 w-10 animate-pulse rounded-xl bg-[var(--dg-bg-tertiary)]" />
|
|
</div>
|
|
<div className="h-8 w-24 animate-pulse rounded bg-[var(--dg-bg-tertiary)] mb-3" />
|
|
<div className="h-3 w-full animate-pulse rounded bg-[var(--dg-bg-tertiary)]" />
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Main Dashboard */
|
|
/* ------------------------------------------------------------------ */
|
|
export default function Dashboard() {
|
|
const theme = useTheme();
|
|
const { user } = useAuth();
|
|
const navigate = useNavigate();
|
|
const [dismissedIds, setDismissedIds] = useState<Set<string>>(new Set());
|
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
|
|
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
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()
|
|
.then(data => {
|
|
setStats(data);
|
|
// 关联查询统计
|
|
if (data.query_stats) setQueryStats(data.query_stats);
|
|
})
|
|
.catch(() => setStats(null))
|
|
.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('/dashboard/my-tickets').then((res: any) => {
|
|
const list = Array.isArray(res) ? res : [];
|
|
setTickets(list);
|
|
}).catch(() => setTickets([]));
|
|
});
|
|
}, []);
|
|
|
|
/* ---- Announcements ---- */
|
|
const visibleAnnouncements = useMemo(() => {
|
|
return announcements
|
|
.filter(a => a.enabled && !dismissedIds.has(a.id))
|
|
.sort((a, b) => new Date(b.publishTime).getTime() - new Date(a.publishTime).getTime());
|
|
}, [dismissedIds, announcements]);
|
|
|
|
const dismiss = useCallback((id: string) => setDismissedIds(prev => new Set(prev).add(id)), []);
|
|
const toggleExpand = useCallback((id: string) => setExpandedId(prev => prev === id ? null : id), []);
|
|
|
|
|
|
|
|
/* ---- Computed Data ---- */
|
|
const onlineInstances = dbInstances.filter(i => i.status === 'online').length;
|
|
const warningInstances = dbInstances.filter(i => i.status === 'warning').length;
|
|
const offlineInstances = dbInstances.filter(i => i.status === 'offline').length;
|
|
const pendingTickets = tickets;
|
|
|
|
const statsCards = useMemo(() => {
|
|
if (!stats) return [];
|
|
const queryTotal = stats.query_stats?.total ?? 0;
|
|
return [
|
|
{
|
|
title: '工单总数', value: stats.ticket_total.toLocaleString(), icon: ClipboardList,
|
|
color: 'text-blue-400', bg: 'bg-blue-400/10',
|
|
subtitle: `${stats.ticket_pending} 个待审批 · ${stats.ticket_executing} 个执行中`,
|
|
},
|
|
{
|
|
title: '查询总数', value: queryTotal.toLocaleString(), icon: Search,
|
|
color: 'text-green-400', bg: 'bg-green-400/10',
|
|
subtitle: `今日 ${stats.queries_today} 次 · 平均 ${stats.query_stats?.avg_time_ms ?? 0}ms`,
|
|
},
|
|
{
|
|
title: '数据源总数', value: stats.instance_total, icon: Database,
|
|
color: 'text-purple-400', bg: 'bg-purple-400/10',
|
|
subtitle: `${stats.instance_online} 个在线 · ${stats.user_total} 个用户`,
|
|
},
|
|
];
|
|
}, [stats]);
|
|
|
|
const quickActions = [
|
|
{ label: '新建工单', icon: FileText, onClick: () => navigate('/tickets/create'), color: 'text-blue-400', bg: 'bg-blue-400/10', border: 'border-blue-400/20' },
|
|
{ label: '新建查询', icon: Search, onClick: () => navigate('/queries'), color: 'text-green-400', bg: 'bg-green-400/10', border: 'border-green-400/20' },
|
|
{ label: '添加实例', icon: Database, onClick: () => navigate('/instances'), color: 'text-purple-400', bg: 'bg-purple-400/10', border: 'border-purple-400/20' },
|
|
];
|
|
|
|
|
|
|
|
/* ================================================================ */
|
|
/* RENDER */
|
|
/* ================================================================ */
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* ---- Quick Actions ---- */}
|
|
<div className="flex gap-2 flex-wrap">
|
|
{quickActions.map(action => (
|
|
<button
|
|
key={action.label}
|
|
onClick={action.onClick}
|
|
className={`flex items-center gap-2 px-4 py-2.5 rounded-lg text-sm font-medium border transition-all ${action.bg} ${action.color} ${action.border} hover:opacity-80`}
|
|
>
|
|
<action.icon className="w-4 h-4" />
|
|
{action.label}
|
|
<ArrowUpRight className="w-3 h-3 opacity-50" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* ---- Announcements ---- */}
|
|
{visibleAnnouncements.length > 0 && (
|
|
<div className="space-y-2">
|
|
{visibleAnnouncements.slice(0, 3).map(ann => {
|
|
const isExpanded = expandedId === ann.id;
|
|
return (
|
|
<div
|
|
key={ann.id}
|
|
className="dg-card overflow-hidden"
|
|
>
|
|
<div className="px-5 py-3 flex items-start gap-3">
|
|
<div className="w-7 h-7 rounded-md bg-blue-500/10 flex items-center justify-center flex-shrink-0 mt-0.5">
|
|
<Bell className="w-3.5 h-3.5 text-blue-400" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2 mb-0.5">
|
|
<span className="text-sm font-medium truncate">{ann.title}</span>
|
|
<span className="text-[10px] text-[var(--dg-text-muted)] flex-shrink-0">{ann.publishTime.slice(0, 10)}</span>
|
|
</div>
|
|
<div className={`text-xs text-[var(--dg-text-secondary)] leading-relaxed ${isExpanded ? '' : 'line-clamp-1'}`}>
|
|
{ann.content}
|
|
</div>
|
|
{ann.content.includes('\n') && (
|
|
<button
|
|
onClick={() => toggleExpand(ann.id)}
|
|
className="text-[11px] text-blue-400 hover:text-blue-300 mt-1 transition-colors"
|
|
>
|
|
{isExpanded ? '收起' : '展开全文'}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<button
|
|
onClick={() => dismiss(ann.id)}
|
|
className="w-6 h-6 rounded-md flex items-center justify-center text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)] hover:bg-[var(--dg-bg-hover)] transition-colors flex-shrink-0"
|
|
>
|
|
<X className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* ---- Stats Cards ---- */}
|
|
<div className="grid grid-cols-3 gap-5">
|
|
{loading ? (
|
|
Array.from({ length: 3 }).map((_, i) => <StatCardSkeleton key={i} />)
|
|
) : (
|
|
statsCards.map((s, i) => (
|
|
<Card key={i} className="dg-card dg-card-hover relative overflow-hidden">
|
|
<div className={`absolute top-0 right-0 w-20 h-20 ${s.bg} rounded-bl-[40px] opacity-30`} />
|
|
<CardContent className="p-5 relative z-10">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<span className="text-sm text-[var(--dg-text-secondary)] font-medium">{s.title}</span>
|
|
<div className={`w-9 h-9 rounded-lg ${s.bg} flex items-center justify-center`}>
|
|
<s.icon className={`w-4.5 h-4.5 ${s.color}`} />
|
|
</div>
|
|
</div>
|
|
<div className="text-4xl font-bold text-[var(--dg-text-primary)] tracking-tight">{s.value}</div>
|
|
<div className="text-xs text-[var(--dg-text-muted)] mt-2">{s.subtitle}</div>
|
|
</CardContent>
|
|
</Card>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
{/* ---- My Pending Tickets + Instance Status ---- */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
|
{/* My Pending Tickets */}
|
|
<Card className="dg-card col-span-1 lg:col-span-2">
|
|
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
|
<Clock className="w-4 h-4 text-orange-400" />
|
|
我的待处理
|
|
<span className="text-xs text-[var(--dg-text-muted)] font-normal">({pendingTickets.length})</span>
|
|
</CardTitle>
|
|
<a href="/tickets" className="text-xs text-blue-400 hover:text-blue-300 flex items-center gap-1 transition-colors">
|
|
查看全部 <ChevronRight className="w-3 h-3" />
|
|
</a>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{pendingTickets.length === 0 ? (
|
|
<div className="p-8 text-center text-[var(--dg-text-muted)] text-sm">
|
|
<CheckCircle className="w-8 h-8 mx-auto mb-2 text-green-400" />
|
|
暂无待处理工单
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-sm">
|
|
<thead>
|
|
<tr className="border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]">
|
|
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">工单号</th>
|
|
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">标题</th>
|
|
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">数据库</th>
|
|
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">状态</th>
|
|
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">待处理</th>
|
|
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">关联</th>
|
|
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">时间</th>
|
|
<th className="text-left px-5 py-2.5 font-semibold text-[var(--dg-text-primary)]">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{pendingTickets.map((ticket: any) => (
|
|
<tr key={ticket.id} className="border-b border-[var(--dg-border-light)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
<td className="px-5 py-3 font-mono text-xs text-[var(--dg-text-secondary)]">TK-{ticket.id}</td>
|
|
<td className="px-5 py-3 max-w-[200px] truncate font-medium">{ticket.title}</td>
|
|
<td className="px-5 py-3">
|
|
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-md text-xs font-medium ${dbTypeColorMap[ticket.db_type] || ''}`}>
|
|
{(ticket.db_type || '').toUpperCase()}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-3">
|
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${statusColorMap[ticket.status] || ''}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${ticket.status === 'reviewing' ? 'bg-blue-400 animate-pulse' : ticket.status === 'pending' ? 'bg-yellow-400' : 'bg-green-400'}`} />
|
|
{statusMap[ticket.status] || ticket.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-3 text-xs text-[var(--dg-text-secondary)] max-w-[180px] truncate">{ticket.pending_info || '-'}</td>
|
|
<td className="px-5 py-3">
|
|
<span className={`text-xs px-2 py-0.5 rounded-full ${ticket.relation === '我提交的' ? 'bg-blue-500/10 text-blue-400' : 'bg-orange-500/10 text-orange-400'}`}>
|
|
{ticket.relation}
|
|
</span>
|
|
</td>
|
|
<td className="px-5 py-3 text-xs text-[var(--dg-text-secondary)]">{ticket.create_time}</td>
|
|
<td className="px-5 py-3">
|
|
<button
|
|
onClick={() => navigate(`/tickets/${ticket.id}`)}
|
|
className="flex items-center gap-1 text-blue-400 hover:text-blue-300 text-xs font-medium transition-colors"
|
|
>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
查看
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Instance Status */}
|
|
<Card className="dg-card">
|
|
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
|
<Server className="w-4 h-4 text-blue-400" />
|
|
实例状态
|
|
</CardTitle>
|
|
<span className="text-xs text-[var(--dg-text-muted)]">{onlineInstances}/{dbInstances.length} 在线</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{/* Status Overview */}
|
|
<div className="p-4 border-b border-[var(--dg-border-light)]">
|
|
<div className="grid grid-cols-3 gap-3 text-center">
|
|
<div className="p-2 rounded-lg bg-green-500/5">
|
|
<div className="text-2xl font-bold text-green-400">{onlineInstances}</div>
|
|
<div className="text-xs text-[var(--dg-text-muted)] mt-1">在线</div>
|
|
</div>
|
|
<div className="p-2 rounded-lg bg-yellow-500/5">
|
|
<div className="text-2xl font-bold text-yellow-400">{warningInstances}</div>
|
|
<div className="text-xs text-[var(--dg-text-muted)] mt-1">告警</div>
|
|
</div>
|
|
<div className="p-2 rounded-lg bg-red-500/5">
|
|
<div className="text-2xl font-bold text-red-400">{offlineInstances}</div>
|
|
<div className="text-xs text-[var(--dg-text-muted)] mt-1">离线</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{/* Instance List */}
|
|
{dbInstances.slice(0, 5).map(inst => {
|
|
const health = calcHealthScore(inst);
|
|
return (
|
|
<div key={inst.id} className="flex items-center px-5 py-3 border-b border-[var(--dg-border-light)] last:border-0 hover:bg-[var(--dg-bg-hover)] transition-colors cursor-pointer group">
|
|
<div className={`w-9 h-9 rounded-lg flex items-center justify-center mr-3.5 flex-shrink-0 ${
|
|
inst.dbType === 'mysql' ? 'bg-[rgba(0,117,143,0.2)] text-[#26a69a]' :
|
|
inst.dbType === 'redis' ? 'bg-[rgba(220,50,32,0.2)] text-[#ef5350]' :
|
|
inst.dbType === 'postgres' ? 'bg-[rgba(51,103,145,0.2)] text-[#42a5f5]' :
|
|
'bg-[rgba(71,162,72,0.2)] text-[#66bb6a]'
|
|
}`}>
|
|
<Database className="w-4.5 h-4.5" />
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium truncate">{inst.name}</span>
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded-full ${healthBg(health)} ${healthColor(health)} font-medium`}>
|
|
{health}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-3 text-xs text-[var(--dg-text-muted)]">
|
|
<span>{inst.host}{inst.port ? `:${inst.port}` : ''}</span>
|
|
<span className="flex items-center gap-1">
|
|
<Activity className="w-3 h-3" />
|
|
{(inst.qps ?? 0).toLocaleString()} QPS
|
|
</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" />
|
|
</div>
|
|
);
|
|
})}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* ---- DataSource Health + Query Stats ---- */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
|
{/* DataSource Health */}
|
|
<Card className="dg-card">
|
|
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
|
<Server className="w-4 h-4 text-blue-400" />
|
|
数据源健康概览
|
|
</CardTitle>
|
|
<span className="text-xs text-[var(--dg-text-muted)]">{onlineInstances}/{dbInstances.length} 在线</span>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-0">
|
|
{/* By Type */}
|
|
<div className="grid grid-cols-4 gap-2 p-4 border-b border-[var(--dg-border-light)]">
|
|
{(['mysql', 'postgres', 'redis', 'mongo'] as const).map(type => {
|
|
const typeInstances = dbInstances.filter(i => i.dbType === type);
|
|
const typeOnline = typeInstances.filter(i => i.status === 'online').length;
|
|
const iconMap: Record<string, string> = { mysql: '/db-logos/mysql.svg', postgres: '/db-logos/postgres.svg', redis: '/db-logos/redis.svg', mongo: '/db-logos/mongo.svg' };
|
|
const labelMap: Record<string, string> = { mysql: 'MySQL', postgres: 'PostgreSQL', redis: 'Redis', mongo: 'MongoDB' };
|
|
const allHealthy = typeOnline === typeInstances.length && typeInstances.length > 0;
|
|
const someHealthy = typeOnline > 0;
|
|
return (
|
|
<div key={type} className="text-center p-2.5 rounded-lg bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)] transition-colors">
|
|
<img src={iconMap[type]} alt="" className="w-6 h-6 object-contain mx-auto mb-1.5" />
|
|
<div className="text-sm font-semibold">{typeInstances.length}</div>
|
|
<div className="text-[10px] text-[var(--dg-text-muted)]">{labelMap[type]}</div>
|
|
<div className="text-[10px] mt-1">
|
|
<span className={allHealthy ? 'text-green-400' : someHealthy ? 'text-yellow-400' : 'text-[var(--dg-text-muted)]'}>
|
|
{typeOnline}/{typeInstances.length}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{/* Attention List */}
|
|
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').length > 0 && (
|
|
<div className="p-4">
|
|
<div className="text-xs text-[var(--dg-text-muted)] mb-2 font-medium">需要关注</div>
|
|
<div className="space-y-2">
|
|
{dbInstances.filter(i => i.status === 'warning' || i.status === 'offline').slice(0, 3).map(inst => {
|
|
const health = calcHealthScore(inst);
|
|
return (
|
|
<div key={inst.id} className="flex items-center gap-3 py-2 px-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
|
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${inst.status === 'offline' ? 'bg-red-400' : 'bg-yellow-400'}`} />
|
|
<span className="text-sm flex-1">{inst.name}</span>
|
|
<span className="text-xs text-[var(--dg-text-muted)]">{inst.host}:{inst.port}</span>
|
|
<span className={`text-[10px] px-2 py-0.5 rounded-full ${healthBg(health)} ${healthColor(health)}`}>
|
|
{health}分
|
|
</span>
|
|
<span className={`text-[10px] px-2 py-0.5 rounded-full ${inst.status === 'offline' ? 'bg-red-500/15 text-red-400' : 'bg-yellow-500/15 text-yellow-400'}`}>
|
|
{inst.status === 'offline' ? '离线' : '告警'}
|
|
</span>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Query Stats */}
|
|
<Card className="dg-card">
|
|
<CardHeader className="pb-2 border-b border-[var(--dg-border)]">
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-[15px] font-semibold flex items-center gap-2">
|
|
<Activity className="w-4 h-4 text-green-400" />
|
|
查询统计
|
|
</CardTitle>
|
|
<div className="flex gap-1.5">
|
|
{(['今日', '本周', '本月'] as const).map((p, i) => (
|
|
<button key={p} className={`px-3 py-1 text-xs rounded-md transition-colors font-medium ${i === 0 ? 'bg-green-500/15 text-green-400' : 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'}`}>
|
|
{p}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="p-4">
|
|
<div className="grid grid-cols-3 gap-4 mb-4">
|
|
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<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)]">{(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 昨日
|
|
</div>
|
|
</div>
|
|
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<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)]">{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 昨日
|
|
</div>
|
|
</div>
|
|
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)]">
|
|
<div className="flex items-center gap-2 mb-2">
|
|
<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)]">{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 昨日
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2.5">
|
|
<div className="text-xs text-[var(--dg-text-muted)] font-medium">查询类型分布</div>
|
|
{(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 / 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>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|