snapshot: 数据源管理+大屏增强前快照
This commit is contained in:
1 parent
2bf3e7c308
commit
1726ad65ac
19 files changed
+2540
-273
No files matched your search
@@ -3,7 +3,8 @@ import {
|
||||
Monitor, Settings, Plus, Edit, Trash2, Copy, Eye, Download, RefreshCw,
|
||||
Maximize2, Minimize2, Save, X, ChevronDown, BarChart3, LineChart, PieChart,
|
||||
Activity, Cpu, Database, Server, Globe, Zap, AlertTriangle, TrendingUp, HardDrive,
|
||||
TrendingDown, Clock, Calendar, Filter, Search
|
||||
TrendingDown, Clock, Calendar, Filter, Search, ArrowUpDown, ArrowUp, ArrowDown,
|
||||
Wifi, WifiOff
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -15,15 +16,16 @@ import {
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { dashboardApi } from '@/services/api';
|
||||
import { GrafanaPanelContent } from './GrafanaPanelContent';
|
||||
import { ZabbixDashboard } from './ZabbixDashboard';
|
||||
import { PrometheusDashboard } from './PrometheusDashboard';
|
||||
import { http } from '@/services/http';
|
||||
import { asList } from '@/components/agent/agentUtils';
|
||||
|
||||
interface Dashboard {
|
||||
id: string;
|
||||
@@ -75,6 +77,27 @@ interface Datasource {
|
||||
status: 'connected' | 'disconnected' | 'error';
|
||||
}
|
||||
|
||||
// Agent 主机完整信息(对齐后端 AgentLatestResponse)
|
||||
interface AgentHost {
|
||||
hostname: string;
|
||||
ip: string;
|
||||
cpu_usage: number;
|
||||
cpu_cores: number;
|
||||
mem_usage: number;
|
||||
mem_total: number;
|
||||
mem_used: number;
|
||||
disk_usage: number;
|
||||
disk_info: string;
|
||||
net_info: string;
|
||||
report_time: string;
|
||||
agent_version: string;
|
||||
agent_status: string;
|
||||
os: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
type SortField = 'hostname' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'mem_usage' | 'disk_usage' | 'region' | 'ip';
|
||||
|
||||
const GrafanaDashboard: React.FC = () => {
|
||||
const _confirm = useConfirm();
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
@@ -108,38 +131,79 @@ const GrafanaDashboard: React.FC = () => {
|
||||
url: ''
|
||||
});
|
||||
|
||||
// Agent 主机监控 — 默认面板
|
||||
const [hostTop, setHostTop] = useState<any[]>([]);
|
||||
// ==================== Agent 主机监控 ====================
|
||||
const [hosts, setHosts] = useState<AgentHost[]>([]);
|
||||
const [hostsLoading, setHostsLoading] = useState(true);
|
||||
const [sortField, setSortField] = useState<SortField>('cpu_usage');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const fetchHosts = async () => {
|
||||
try {
|
||||
const data = await http.get('/agent/latest');
|
||||
setHosts(asList(data));
|
||||
} catch { /* 后端未就绪时静默 */ }
|
||||
finally { setHostsLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [cpu, mem, disk] = await Promise.all([
|
||||
http.get('/metrics/top?metric=cpu&limit=10'),
|
||||
http.get('/metrics/top?metric=memory&limit=10'),
|
||||
http.get('/metrics/top?metric=disk&limit=10'),
|
||||
]);
|
||||
setHostTop([
|
||||
{ name: 'CPU', data: Array.isArray(cpu) ? cpu : [], key: 'cpu_usage', unit: '%', icon: 'cpu' },
|
||||
{ name: '内存', data: Array.isArray(mem) ? mem : [], key: 'memory_usage', unit: '%', icon: 'mem' },
|
||||
{ name: '磁盘', data: Array.isArray(disk) ? disk : [], key: 'disk_usage', unit: '%', icon: 'disk' },
|
||||
]);
|
||||
} catch { /* silent */ }
|
||||
})();
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const cpu = await http.get('/metrics/top?metric=cpu&limit=10');
|
||||
const mem = await http.get('/metrics/top?metric=memory&limit=10');
|
||||
const disk = await http.get('/metrics/top?metric=disk&limit=10');
|
||||
setHostTop([
|
||||
{ name: 'CPU', data: Array.isArray(cpu) ? cpu : [], key: 'cpu_usage', unit: '%', icon: 'cpu' },
|
||||
{ name: '内存', data: Array.isArray(mem) ? mem : [], key: 'memory_usage', unit: '%', icon: 'mem' },
|
||||
{ name: '磁盘', data: Array.isArray(disk) ? disk : [], key: 'disk_usage', unit: '%', icon: 'disk' },
|
||||
]);
|
||||
} catch { /* silent */ }
|
||||
}, 30000);
|
||||
fetchHosts();
|
||||
const timer = setInterval(fetchHosts, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// 排序
|
||||
const toggleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedHosts = [...hosts].sort((a, b) => {
|
||||
let va: any, vb: any;
|
||||
if (sortField === 'agent_status') {
|
||||
va = a.agent_status || '';
|
||||
vb = b.agent_status || '';
|
||||
} else {
|
||||
va = (a as any)[sortField] ?? '';
|
||||
vb = (b as any)[sortField] ?? '';
|
||||
}
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
return sortDir === 'asc' ? va - vb : vb - va;
|
||||
}
|
||||
return sortDir === 'asc'
|
||||
? String(va).localeCompare(String(vb))
|
||||
: String(vb).localeCompare(String(va));
|
||||
});
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortField !== field) return <ArrowUpDown className="w-3 h-3 text-slate-500" />;
|
||||
return sortDir === 'asc' ? <ArrowUp className="w-3 h-3 text-emerald-400" /> : <ArrowDown className="w-3 h-3 text-emerald-400" />;
|
||||
};
|
||||
|
||||
const usageBar = (val: number, label: string) => {
|
||||
const pct = Math.min(val, 100);
|
||||
const isWarn = val >= 80;
|
||||
const barColor = isWarn ? 'bg-orange-500' : 'bg-emerald-500';
|
||||
const textColor = isWarn ? 'text-orange-400' : '';
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[90px]">
|
||||
<div className="flex-1 h-2.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`w-14 text-right text-xs font-mono tabular-nums ${textColor}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 仪表盘 CRUD ====================
|
||||
useEffect(() => {
|
||||
fetchDashboards();
|
||||
fetchDatasources();
|
||||
@@ -271,78 +335,10 @@ const GrafanaDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getDatasourceStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return <Badge variant="default" className="bg-green-500">已连接</Badge>;
|
||||
case 'disconnected':
|
||||
return <Badge variant="secondary">已断开</Badge>;
|
||||
case 'error':
|
||||
return <Badge variant="destructive">错误</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">未知</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${fullscreen ? 'fixed inset-0 z-50 bg-background p-4' : ''}`}>
|
||||
{/* ===== 默认:Agent 主机监控面板 ===== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-emerald-500/20">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-emerald-400" />
|
||||
<CardTitle className="text-base text-[var(--dg-text-primary)]">Agent 主机监控</CardTitle>
|
||||
<Badge variant="outline" className="text-xs bg-emerald-500/10 text-emerald-400 border-emerald-500/30">默认</Badge>
|
||||
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">每 30s 刷新</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{hostTop.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||||
<Server className="h-8 w-8 mx-auto mb-2 text-slate-600" />
|
||||
<p className="text-sm">暂无 Agent 上报数据</p>
|
||||
<p className="text-xs mt-1">请确保至少一台主机安装了 AIOps Agent 并正常上报</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{hostTop.map((group: any) => (
|
||||
<div key={group.name} className="bg-[var(--dg-bg-primary)] rounded-lg p-3 border border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{group.icon === 'cpu' && <Cpu className="h-4 w-4 text-red-400" />}
|
||||
{group.icon === 'mem' && <Server className="h-4 w-4 text-amber-400" />}
|
||||
{group.icon === 'disk' && <HardDrive className="h-4 w-4 text-orange-400" />}
|
||||
<span className="text-xs font-medium text-[var(--dg-text-primary)]">{group.name} Top 10</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{group.data.slice(0, 10).map((h: any, i: number) => {
|
||||
const val = Number(h[group.key] || 0);
|
||||
const barPct = Math.min(val, 100);
|
||||
const barColor = val > 85 ? 'bg-red-500' : val > 60 ? 'bg-amber-500' : 'bg-emerald-500';
|
||||
const dotColor = i < 3 ? (i === 0 ? 'text-red-400' : i === 1 ? 'text-amber-400' : 'text-orange-400') : 'text-slate-500';
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className={`w-4 text-right font-mono font-bold ${dotColor}`}>
|
||||
{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}`}
|
||||
</span>
|
||||
<span className="w-24 truncate text-[var(--dg-text-secondary)]" title={h.hostname}>
|
||||
{h.hostname}
|
||||
</span>
|
||||
<div className="flex-1 h-1 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${barPct}%` }} />
|
||||
</div>
|
||||
<span className="w-12 text-right font-mono text-[var(--dg-text-primary)]">{val.toFixed(1)}{group.unit}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 顶部工具栏 */}
|
||||
{/* ===== 顶部工具栏 ===== */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Select
|
||||
@@ -407,7 +403,7 @@ const GrafanaDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 仪表盘内容 */}
|
||||
{/* ===== 仪表盘内容 ===== */}
|
||||
{activeDashboard ? (
|
||||
<div className="space-y-4">
|
||||
{/* 仪表盘信息 */}
|
||||
@@ -424,6 +420,110 @@ const GrafanaDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Agent 主机监控 — 默认表格面板 ===== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-emerald-500/20">
|
||||
<CardHeader className="py-3 px-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-emerald-400" />
|
||||
<CardTitle className="text-sm font-medium text-[var(--dg-text-primary)]">Agent 主机监控</CardTitle>
|
||||
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 text-emerald-400 border-emerald-500/30">默认</Badge>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)] ml-auto">每 30s 刷新</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3 pt-0">
|
||||
{hostsLoading ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-muted)]">
|
||||
<Activity className="h-6 w-6 mx-auto mb-2 animate-spin text-slate-600" />
|
||||
<p className="text-xs">加载中…</p>
|
||||
</div>
|
||||
) : hosts.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-muted)]">
|
||||
<Server className="h-8 w-8 mx-auto mb-2 text-slate-600" />
|
||||
<p className="text-sm">暂无 Agent 上报数据</p>
|
||||
<p className="text-xs mt-1">请确保至少一台主机安装了 AIOps Agent 并正常上报</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
{[
|
||||
{ field: 'hostname', label: '主机名' },
|
||||
{ field: 'agent_version', label: 'Agent 版本' },
|
||||
{ field: 'agent_status', label: '状态' },
|
||||
{ field: 'cpu_usage', label: 'CPU 使用率' },
|
||||
{ field: 'mem_usage', label: '内存使用率' },
|
||||
{ field: 'disk_usage' as SortField, label: '磁盘使用率' },
|
||||
{ field: 'region', label: '所在区域' },
|
||||
{ field: 'ip', label: 'IP 地址' },
|
||||
].map(({ field, label }) => (
|
||||
<th
|
||||
key={field}
|
||||
className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap"
|
||||
onClick={() => toggleSort(field as SortField)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{label}
|
||||
<SortIcon field={field as SortField} />
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHosts.map((host, i) => {
|
||||
const isOnline = host.agent_status === 'online';
|
||||
return (
|
||||
<tr
|
||||
key={host.hostname + i}
|
||||
className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50 transition-colors"
|
||||
>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{host.hostname}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--dg-text-secondary)] font-mono">
|
||||
{host.agent_version || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
<Badge
|
||||
variant={isOnline ? 'default' : 'secondary'}
|
||||
className={`text-[10px] ${isOnline ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/10 text-slate-400 border-slate-500/20'}`}
|
||||
>
|
||||
{isOnline ? <Wifi className="w-3 h-3 mr-1" /> : <WifiOff className="w-3 h-3 mr-1" />}
|
||||
{isOnline ? '在线' : '离线'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
{usageBar(Number(host.cpu_usage || 0), `${Number(host.cpu_usage || 0).toFixed(1)}%`)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
{usageBar(Number(host.mem_usage || 0), `${Number(host.mem_usage || 0).toFixed(1)}%`)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
{usageBar(Number(host.disk_usage || 0), `${Number(host.disk_usage || 0).toFixed(1)}%`)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--dg-text-secondary)]">
|
||||
{host.region || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap font-mono text-[var(--dg-text-secondary)]">
|
||||
{host.ip || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Zabbix 数据源面板 */}
|
||||
<ZabbixDashboard />
|
||||
|
||||
{/* Prometheus 数据源面板 */}
|
||||
<PrometheusDashboard />
|
||||
|
||||
{/* 面板网格 */}
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{activeDashboard.panels.map((panel) => (
|
||||
@@ -438,7 +538,6 @@ const GrafanaDashboard: React.FC = () => {
|
||||
{getPanelIcon(panel.type)}
|
||||
{panel.title}
|
||||
</CardTitle>
|
||||
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1">
|
||||
@@ -508,20 +607,17 @@ const GrafanaDashboard: React.FC = () => {
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>数据源地址</Label>
|
||||
<Label>数据源 URL</Label>
|
||||
<Input
|
||||
value={newDashboard.datasourceUrl}
|
||||
onChange={(e) => setNewDashboard({ ...newDashboard, datasourceUrl: e.target.value })}
|
||||
placeholder="http://localhost:9090"
|
||||
placeholder="http://prometheus:9090"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreateDashboard}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
创建
|
||||
</Button>
|
||||
<Button onClick={handleCreateDashboard}>创建</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -531,19 +627,19 @@ const GrafanaDashboard: React.FC = () => {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加面板</DialogTitle>
|
||||
<DialogDescription>添加一个新的监控面板</DialogDescription>
|
||||
<DialogDescription>向当前仪表盘添加一个监控面板</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>标题</Label>
|
||||
<Label>面板标题</Label>
|
||||
<Input
|
||||
value={newPanel.title}
|
||||
onChange={(e) => setNewPanel({ ...newPanel, title: e.target.value })}
|
||||
placeholder="面板标题"
|
||||
placeholder="CPU 使用率"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>类型</Label>
|
||||
<Label>图表类型</Label>
|
||||
<Select
|
||||
value={newPanel.type}
|
||||
onValueChange={(v) => setNewPanel({ ...newPanel, type: v as any })}
|
||||
@@ -553,149 +649,101 @@ const GrafanaDashboard: React.FC = () => {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="graph">折线图</SelectItem>
|
||||
<SelectItem value="stat">统计值</SelectItem>
|
||||
<SelectItem value="stat">数值</SelectItem>
|
||||
<SelectItem value="gauge">仪表盘</SelectItem>
|
||||
<SelectItem value="pie">饼图</SelectItem>
|
||||
<SelectItem value="table">表格</SelectItem>
|
||||
<SelectItem value="heatmap">热力图</SelectItem>
|
||||
<SelectItem value="text">文本</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>查询语句</Label>
|
||||
<textarea
|
||||
<Label>PromQL / 查询语句</Label>
|
||||
<Input
|
||||
value={newPanel.query}
|
||||
onChange={(e) => setNewPanel({ ...newPanel, query: e.target.value })}
|
||||
placeholder={newPanel.type === 'graph' ? 'sum(rate(http_requests_total[5m]))' : 'up'}
|
||||
className="w-full min-h-[100px] p-2 border rounded-md font-mono text-sm"
|
||||
placeholder="rate(node_cpu_seconds_total[5m])"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>宽度 (列)</Label>
|
||||
<Select
|
||||
value={newPanel.width.toString()}
|
||||
onValueChange={(v) => setNewPanel({ ...newPanel, width: parseInt(v) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">3列 (25%)</SelectItem>
|
||||
<SelectItem value="4">4列 (33%)</SelectItem>
|
||||
<SelectItem value="6">6列 (50%)</SelectItem>
|
||||
<SelectItem value="8">8列 (66%)</SelectItem>
|
||||
<SelectItem value="12">12列 (100%)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>高度 (行)</Label>
|
||||
<Select
|
||||
value={newPanel.height.toString()}
|
||||
onValueChange={(v) => setNewPanel({ ...newPanel, height: parseInt(v) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">3行 (短)</SelectItem>
|
||||
<SelectItem value="4">4行 (中)</SelectItem>
|
||||
<SelectItem value="6">6行 (长)</SelectItem>
|
||||
<SelectItem value="8">8行 (超长)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddPanelOpen(false)}>取消</Button>
|
||||
<Button onClick={handleAddPanel}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加
|
||||
</Button>
|
||||
<Button onClick={handleAddPanel}>添加</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 数据源管理对话框 */}
|
||||
{/* 数据源对话框 */}
|
||||
<Dialog open={datasourceOpen} onOpenChange={setDatasourceOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>数据源管理</DialogTitle>
|
||||
<DialogDescription>管理监控数据源配置</DialogDescription>
|
||||
<DialogTitle>管理数据源</DialogTitle>
|
||||
<DialogDescription>配置 Prometheus / Zabbix / SkyWalking 等外部数据源连接</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="list">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="list">数据源列表</TabsTrigger>
|
||||
<TabsTrigger value="add">添加数据源</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="list" className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{datasources.map((ds) => (
|
||||
<Card key={ds.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{getDatasourceIcon(ds.type)}
|
||||
<div>
|
||||
<p className="font-medium">{ds.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{ds.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getDatasourceStatusBadge(ds.status)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="add" className="space-y-4">
|
||||
<div>
|
||||
<Label>名称</Label>
|
||||
<Input
|
||||
value={newDatasource.name}
|
||||
onChange={(e) => setNewDatasource({ ...newDatasource, name: e.target.value })}
|
||||
placeholder="数据源名称"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>类型</Label>
|
||||
<Select
|
||||
value={newDatasource.type}
|
||||
onValueChange={(v) => setNewDatasource({ ...newDatasource, type: v as any })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="prometheus">Prometheus</SelectItem>
|
||||
<SelectItem value="zabbix">Zabbix</SelectItem>
|
||||
<SelectItem value="skywalking">SkyWalking</SelectItem>
|
||||
<SelectItem value="influxdb">InfluxDB</SelectItem>
|
||||
<SelectItem value="elasticsearch">Elasticsearch</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>地址</Label>
|
||||
<Input
|
||||
value={newDatasource.url}
|
||||
onChange={(e) => setNewDatasource({ ...newDatasource, url: e.target.value })}
|
||||
placeholder="http://localhost:9090"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleAddDatasource} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加数据源
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="space-y-3">
|
||||
{datasources.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">暂无数据源配置</p>
|
||||
) : (
|
||||
datasources.map(ds => (
|
||||
<div key={ds.id} className="flex items-center justify-between p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2">
|
||||
{getDatasourceIcon(ds.type)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{ds.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{ds.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={ds.status === 'connected' ? 'default' : 'secondary'}
|
||||
className={ds.status === 'connected' ? 'bg-green-500' : ds.status === 'error' ? 'bg-red-500' : ''}
|
||||
>
|
||||
{ds.status === 'connected' ? '已连接' : ds.status === 'error' ? '错误' : '已断开'}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>数据源名称</Label>
|
||||
<Input
|
||||
value={newDatasource.name}
|
||||
onChange={(e) => setNewDatasource({ ...newDatasource, name: e.target.value })}
|
||||
placeholder="My Prometheus"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>类型</Label>
|
||||
<Select
|
||||
value={newDatasource.type}
|
||||
onValueChange={(v) => setNewDatasource({ ...newDatasource, type: v as any })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="prometheus">Prometheus</SelectItem>
|
||||
<SelectItem value="zabbix">Zabbix</SelectItem>
|
||||
<SelectItem value="skywalking">SkyWalking</SelectItem>
|
||||
<SelectItem value="influxdb">InfluxDB</SelectItem>
|
||||
<SelectItem value="elasticsearch">Elasticsearch</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>URL</Label>
|
||||
<Input
|
||||
value={newDatasource.url}
|
||||
onChange={(e) => setNewDatasource({ ...newDatasource, url: e.target.value })}
|
||||
placeholder="http://prometheus:9090"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDatasourceOpen(false)}>关闭</Button>
|
||||
<Button onClick={handleAddDatasource}>添加数据源</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// 主机监控大屏 — 列表视图 + 可排序 + 操作栏 // Logan.Li
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Server, Cpu, HardDrive, Activity, Monitor, Bell,
|
||||
ArrowUpDown, ArrowUp, ArrowDown, RefreshCw, Search,
|
||||
ChevronLeft, ChevronRight
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { serverApi } from '@/services/api';
|
||||
|
||||
type SortField = 'hostname' | 'ip' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'memory_usage' | 'disk_usage' | 'region';
|
||||
type SortDir = 'asc' | 'desc';
|
||||
|
||||
function getUsageBar(val: number) {
|
||||
const v = val || 0;
|
||||
const color = v > 85 ? 'bg-red-500' : v > 60 ? 'bg-amber-500' : 'bg-emerald-500';
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-[80px]">
|
||||
<div className="flex-1 h-1.5 rounded-full bg-slate-700/50 overflow-hidden">
|
||||
<div className={`h-full rounded-full ${color}`} style={{ width: `${Math.min(v, 100)}%` }} />
|
||||
</div>
|
||||
<span className="text-xs font-mono w-10 text-right text-[var(--dg-text-primary)]">{v.toFixed(0)}%</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HostMonitorDashboard() {
|
||||
const navigate = useNavigate();
|
||||
const [hosts, setHosts] = useState<any[]>([]);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(15);
|
||||
const [search, setSearch] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
// 排序状态
|
||||
const [sortField, setSortField] = useState<SortField>('hostname');
|
||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||
|
||||
// 统计
|
||||
const [stats, setStats] = useState<{ online: number; offline: number; total: number }>({ online: 0, offline: 0, total: 0 });
|
||||
|
||||
const loadData = useCallback(async (isRefresh = false) => {
|
||||
if (isRefresh) setRefreshing(true);
|
||||
else setLoading(true);
|
||||
try {
|
||||
const params: any = { page, page_size: pageSize };
|
||||
if (search.trim()) params.keyword = search.trim();
|
||||
const res = await serverApi.getServers(params);
|
||||
const list = (res as any)?.list || [];
|
||||
setHosts(list);
|
||||
setTotal((res as any)?.total || 0);
|
||||
|
||||
// 统计
|
||||
const online = list.filter((h: any) => h.agent_status === 'online').length;
|
||||
setStats({ online, offline: list.length - online, total: (res as any)?.total || 0 });
|
||||
} catch {
|
||||
setHosts([]);
|
||||
setTotal(0);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [page, pageSize, search]);
|
||||
|
||||
useEffect(() => { loadData(); }, [loadData]);
|
||||
|
||||
// 排序后的数据
|
||||
const sorted = [...hosts].sort((a: any, b: any) => {
|
||||
let va: any, vb: any;
|
||||
if (sortField === 'region') {
|
||||
va = (a.region?.name || a.region?.Name || '').toLowerCase();
|
||||
vb = (b.region?.name || b.region?.Name || '').toLowerCase();
|
||||
} else if (sortField === 'agent_status') {
|
||||
va = a.agent_status || 'offline';
|
||||
vb = b.agent_status || 'offline';
|
||||
} else if (sortField === 'agent_version') {
|
||||
va = a.agent_version || '';
|
||||
vb = b.agent_version || '';
|
||||
} else {
|
||||
va = a[sortField] || '';
|
||||
vb = b[sortField] || '';
|
||||
}
|
||||
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
return sortDir === 'asc' ? va - vb : vb - va;
|
||||
}
|
||||
const sa = String(va).toLowerCase();
|
||||
const sb = String(vb).toLowerCase();
|
||||
if (sa < sb) return sortDir === 'asc' ? -1 : 1;
|
||||
if (sa > sb) return sortDir === 'asc' ? 1 : -1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const handleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir('asc');
|
||||
}
|
||||
};
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortField !== field) return <ArrowUpDown className="w-3 h-3 text-slate-500" />;
|
||||
return sortDir === 'asc'
|
||||
? <ArrowUp className="w-3 h-3 text-blue-400" />
|
||||
: <ArrowDown className="w-3 h-3 text-blue-400" />;
|
||||
};
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize);
|
||||
|
||||
const handleSearch = () => {
|
||||
setPage(1);
|
||||
loadData();
|
||||
};
|
||||
|
||||
// 状态标签颜色
|
||||
const statusBadge = (status: string) => {
|
||||
if (status === 'online') return <Badge className="bg-emerald-500/20 text-emerald-400 border-emerald-500/30 text-[11px]">在线</Badge>;
|
||||
if (status === 'running') return <Badge className="bg-blue-500/20 text-blue-400 border-blue-500/30 text-[11px]">运行中</Badge>;
|
||||
if (status === 'stopped') return <Badge className="bg-slate-500/20 text-slate-400 border-slate-500/30 text-[11px]">已停止</Badge>;
|
||||
return <Badge variant="outline" className="text-[11px] text-slate-500">{status || '--'}</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10"><Server className="h-5 w-5 text-emerald-400" /></div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-[var(--dg-text-primary)]">{stats.online}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">Agent 在线</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-slate-500/10"><Server className="h-5 w-5 text-slate-400" /></div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-[var(--dg-text-primary)]">{stats.offline}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">Agent 离线</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-4 flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-500/10"><Monitor className="h-5 w-5 text-blue-400" /></div>
|
||||
<div>
|
||||
<div className="text-2xl font-bold text-[var(--dg-text-primary)]">{stats.total}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)]">主机总数</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 表格卡片 */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-emerald-400" />
|
||||
<CardTitle className="text-base text-[var(--dg-text-primary)]">主机监控列表</CardTitle>
|
||||
<Badge variant="outline" className="text-xs bg-emerald-500/10 text-emerald-400 border-emerald-500/30">实时</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-slate-500" />
|
||||
<Input
|
||||
className="w-[200px] pl-8 h-8 text-sm bg-[var(--dg-bg-primary)]"
|
||||
placeholder="搜索主机名/IP..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={() => loadData(true)} disabled={refreshing}>
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${refreshing ? 'animate-spin' : ''}`} />
|
||||
刷新
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-0">
|
||||
{loading ? (
|
||||
<div className="py-16 text-center text-[var(--dg-text-muted)]">
|
||||
<Activity className="h-8 w-8 mx-auto mb-2 animate-spin text-emerald-400" />
|
||||
<p className="text-sm">加载中...</p>
|
||||
</div>
|
||||
) : sorted.length === 0 ? (
|
||||
<div className="py-16 text-center text-[var(--dg-text-muted)]">
|
||||
<Server className="h-10 w-10 mx-auto mb-2 text-slate-600" />
|
||||
<p className="text-sm">暂无主机数据</p>
|
||||
<p className="text-xs mt-1">请先添加服务器,或等待 Agent 上报数据</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-[var(--dg-border)]">
|
||||
{[
|
||||
{ key: 'hostname', label: '主机名/ID', w: '' },
|
||||
{ key: 'agent_version', label: 'Agent 版本', w: 'w-[100px]' },
|
||||
{ key: 'agent_status', label: 'Agent 状态', w: 'w-[90px]' },
|
||||
{ key: 'cpu_usage', label: 'CPU', w: 'w-[130px]' },
|
||||
{ key: 'memory_usage', label: '内存', w: 'w-[130px]' },
|
||||
{ key: 'disk_usage', label: '磁盘', w: 'w-[130px]' },
|
||||
{ key: 'region', label: '所在区域', w: 'w-[120px]' },
|
||||
{ key: 'ip', label: 'IP 地址', w: 'w-[140px]' },
|
||||
].map(col => (
|
||||
<th
|
||||
key={col.key}
|
||||
className={`text-left px-3 py-2.5 text-xs font-medium text-[var(--dg-text-muted)] cursor-pointer select-none hover:text-[var(--dg-text-primary)] transition-colors ${col.w}`}
|
||||
onClick={() => handleSort(col.key as SortField)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{col.label}
|
||||
<SortIcon field={col.key as SortField} />
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<th className="text-left px-3 py-2.5 text-xs font-medium text-[var(--dg-text-muted)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((h: any) => (
|
||||
<tr key={h.id} className="border-b border-[var(--dg-border)]/50 hover:bg-[var(--dg-bg-tertiary)]/50 transition-colors">
|
||||
{/* 主机名/ID */}
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${h.agent_status === 'online' ? 'bg-emerald-400' : h.status === 'running' ? 'bg-blue-400' : 'bg-slate-500'}`} />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{h.hostname}</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)]">ID: {h.id}</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
{/* Agent 版本 */}
|
||||
<td className="px-3 py-2">
|
||||
<code className="text-xs text-blue-300">{h.agent_version || '--'}</code>
|
||||
</td>
|
||||
{/* Agent 状态 */}
|
||||
<td className="px-3 py-2">
|
||||
{statusBadge(h.agent_status)}
|
||||
</td>
|
||||
{/* CPU 使用率 */}
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Cpu className="h-3 w-3 text-red-400 flex-shrink-0" />
|
||||
{getUsageBar(h.cpu_usage)}
|
||||
</div>
|
||||
</td>
|
||||
{/* 内存使用率 */}
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Server className="h-3 w-3 text-amber-400 flex-shrink-0" />
|
||||
{getUsageBar(h.memory_usage)}
|
||||
</div>
|
||||
</td>
|
||||
{/* 磁盘使用率 */}
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<HardDrive className="h-3 w-3 text-orange-400 flex-shrink-0" />
|
||||
{getUsageBar(h.disk_usage)}
|
||||
</div>
|
||||
</td>
|
||||
{/* 所在区域 */}
|
||||
<td className="px-3 py-2">
|
||||
<span className="text-xs text-[var(--dg-text-secondary)]">
|
||||
{h.region?.name || h.region?.Name || (h.region ? h.region : '--')}
|
||||
</span>
|
||||
</td>
|
||||
{/* IP 地址 */}
|
||||
<td className="px-3 py-2">
|
||||
<code className="text-xs text-slate-300 font-mono">{h.ip || '--'}</code>
|
||||
</td>
|
||||
{/* 操作栏 */}
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10"
|
||||
onClick={() => navigate(`/monitor-center?host=${encodeURIComponent(h.hostname)}`)}
|
||||
title="查看监控图表"
|
||||
>
|
||||
<Monitor className="h-3 w-3 mr-1" />
|
||||
监控
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-amber-400 hover:text-amber-300 hover:bg-amber-500/10"
|
||||
onClick={() => navigate(`/alerts?hostname=${encodeURIComponent(h.hostname)}`)}
|
||||
title="查看报警规则"
|
||||
>
|
||||
<Bell className="h-3 w-3 mr-1" />
|
||||
告警
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
|
||||
<span>每页</span>
|
||||
<Select value={String(pageSize)} onValueChange={(v) => { setPageSize(Number(v)); setPage(1); }}>
|
||||
<SelectTrigger className="h-7 w-[60px] text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="10">10</SelectItem>
|
||||
<SelectItem value="15">15</SelectItem>
|
||||
<SelectItem value="20">20</SelectItem>
|
||||
<SelectItem value="50">50</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span>条,共 {total} 条</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(p => p - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-[var(--dg-text-secondary)] px-2">
|
||||
{page} / {Math.max(totalPages, 1)}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(p => p + 1)}
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
// Prometheus 数据源监控面板 — K8s / Node / Pod / JVM // Logan.Li
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Server, Loader2, Activity, Cpu, HardDrive, Network, RefreshCw,
|
||||
Plug, Settings, X, CheckCircle2, AlertTriangle, Container,
|
||||
BarChart3, Globe, Zap, TrendingUp, TrendingDown
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { toast } from 'sonner';
|
||||
import { http } from '@/services/http';
|
||||
|
||||
const LS_KEY = 'aiops_prometheus_ds';
|
||||
|
||||
function getSavedDS(): { url: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
function saveDS(ds: { url: string }) { localStorage.setItem(LS_KEY, JSON.stringify(ds)); }
|
||||
|
||||
// 小进度条组件
|
||||
const MiniBar = ({ val, label, color }: { val: number; label: string; color?: string }) => {
|
||||
const pct = Math.min(val, 100);
|
||||
const c = color || (val >= 80 ? 'bg-orange-500' : 'bg-purple-500');
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 min-w-[80px]">
|
||||
<div className="flex-1 h-2 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all ${c}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className="w-14 text-right text-[11px] font-mono tabular-nums text-[var(--dg-text-secondary)]">{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const fmtBps = (bps: number): string => {
|
||||
if (!bps || bps === 0) return '—';
|
||||
if (bps > 1e9) return `${(bps / 1e9).toFixed(1)} GB/s`;
|
||||
if (bps > 1e6) return `${(bps / 1e6).toFixed(1)} MB/s`;
|
||||
if (bps > 1e3) return `${(bps / 1e3).toFixed(1)} KB/s`;
|
||||
return `${bps.toFixed(1)} B/s`;
|
||||
};
|
||||
|
||||
const fmtBytes = (b: number): string => {
|
||||
if (!b || b === 0) return '—';
|
||||
if (b > 1e9) return `${(b / 1e9).toFixed(1)} GB`;
|
||||
if (b > 1e6) return `${(b / 1e6).toFixed(1)} MB`;
|
||||
if (b > 1e3) return `${(b / 1e3).toFixed(1)} KB`;
|
||||
return `${b.toFixed(1)} B`;
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// K8s 面板
|
||||
// ============================================
|
||||
const K8sPanel: React.FC<{ data: any }> = ({ data }) => (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="text-2xl font-bold text-purple-400">
|
||||
{data.podCount?.[0]?.value || 0}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Pods</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="text-2xl font-bold text-blue-400">
|
||||
{data.deployCount?.[0]?.value || 0}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Deployments</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="text-2xl font-bold text-emerald-400">
|
||||
{data.nodeCount?.[0]?.value || 0}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Nodes</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
|
||||
<CardContent className="p-3 text-center">
|
||||
<div className="text-2xl font-bold text-amber-400">
|
||||
{data.nsPods?.length || 0}
|
||||
</div>
|
||||
<div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Namespaces</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Node CPU / Memory 利用率 */}
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-3">
|
||||
<div className="text-xs font-medium text-[var(--dg-text-primary)] mb-2 flex items-center gap-1.5"><Cpu className="w-3 h-3 text-purple-400" />节点资源利用率</div>
|
||||
<div className="space-y-2 max-h-60 overflow-y-auto">
|
||||
{(data.nodeCPU || []).map((n: any, i: number) => {
|
||||
const mem = (data.nodeMem || []).find((m: any) => m.node === n.node);
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-3 text-[11px]">
|
||||
<span className="w-28 truncate text-[var(--dg-text-secondary)]" title={n.node}>{n.node}</span>
|
||||
<MiniBar val={Number(n.value || 0)} label={`${Number(n.value || 0).toFixed(1)}%`} />
|
||||
<MiniBar val={Number(mem?.value || 0)} label={`${Number(mem?.value || 0).toFixed(1)}%`} color="bg-blue-500" />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// ============================================
|
||||
// Node 面板
|
||||
// ============================================
|
||||
const NodePanel: React.FC<{ data: any }> = ({ data }) => {
|
||||
const cpuItems = data.cpuUsage || [];
|
||||
const memItems = data.memUsage || [];
|
||||
const diskItems = data.diskUsage || [];
|
||||
const upItems = data.up || [];
|
||||
const netRx = data.netRx || [];
|
||||
const netTx = data.netTx || [];
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">节点</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">CPU</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">内存</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">磁盘</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">流入</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">流出</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(data.nodes || []).map((n: any, i: number) => {
|
||||
const instance = n.instance || n.exported_instance || '';
|
||||
const cpu = cpuItems.find((c: any) => c.instance === instance);
|
||||
const mem = memItems.find((m: any) => m.instance === instance);
|
||||
const disk = diskItems.find((d: any) => d.instance === instance);
|
||||
const up = upItems.find((u: any) => u.instance === instance);
|
||||
const rx = netRx.find((r: any) => r.instance === instance);
|
||||
const tx = netTx.find((t: any) => t.instance === instance);
|
||||
const isUp = up?.value === 1;
|
||||
return (
|
||||
<tr key={i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
|
||||
<td className="px-3 py-2 whitespace-nowrap font-medium text-[var(--dg-text-primary)]">
|
||||
{n.nodename || instance}
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={`text-[10px] ${isUp ? 'bg-emerald-500/20 text-emerald-400' : 'bg-red-500/20 text-red-400'}`}>
|
||||
{isUp ? 'UP' : 'DOWN'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2"><MiniBar val={Number(cpu?.value || 0)} label={`${Number(cpu?.value || 0).toFixed(1)}%`} /></td>
|
||||
<td className="px-3 py-2"><MiniBar val={Number(mem?.value || 0)} label={`${Number(mem?.value || 0).toFixed(1)}%`} color="bg-blue-500" /></td>
|
||||
<td className="px-3 py-2"><MiniBar val={Number(disk?.value || 0)} label={`${Number(disk?.value || 0).toFixed(1)}%`} color="bg-amber-500" /></td>
|
||||
<td className="px-3 py-2 font-mono text-[var(--dg-text-secondary)]">{fmtBps(Number(rx?.value || 0))}</td>
|
||||
<td className="px-3 py-2 font-mono text-[var(--dg-text-secondary)]">{fmtBps(Number(tx?.value || 0))}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// Pod 面板
|
||||
// ============================================
|
||||
const PodPanel: React.FC<{ data: any }> = ({ data }) => {
|
||||
const cpuItems = data.cpuTop || [];
|
||||
const memItems = data.memTop || [];
|
||||
const running = data.running?.[0]?.value || 0;
|
||||
const pending = data.pending?.[0]?.value || 0;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-xl font-bold text-emerald-400">{running}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Running</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-xl font-bold text-amber-400">{pending}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Pending</div></CardContent></Card>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-3">
|
||||
<div className="text-xs font-medium text-[var(--dg-text-primary)] mb-2">CPU Top 10</div>
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||
{cpuItems.map((p: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[11px]">
|
||||
<span className="w-10 text-right text-[var(--dg-text-muted)]">{i + 1}</span>
|
||||
<span className="w-32 truncate text-[var(--dg-text-secondary)]" title={p.pod}>{p.pod}</span>
|
||||
<MiniBar val={Number(p.value || 0) * 100} label={`${Number(p.value || 0).toFixed(2)}c`} color="bg-purple-500" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] p-3">
|
||||
<div className="text-xs font-medium text-[var(--dg-text-primary)] mb-2">内存 Top 10</div>
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||
{memItems.map((p: any, i: number) => (
|
||||
<div key={i} className="flex items-center gap-2 text-[11px]">
|
||||
<span className="w-10 text-right text-[var(--dg-text-muted)]">{i + 1}</span>
|
||||
<span className="w-32 truncate text-[var(--dg-text-secondary)]" title={p.pod}>{p.pod}</span>
|
||||
<span className="ml-auto text-[var(--dg-text-primary)]">{fmtBytes(Number(p.value || 0))}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// JVM 面板
|
||||
// ============================================
|
||||
const JvmPanel: React.FC<{ data: any }> = ({ data }) => {
|
||||
const heap = Number(data.heapUsed?.[0]?.value || 0);
|
||||
const nonHeap = Number(data.nonHeapUsed?.[0]?.value || 0);
|
||||
const threads = Number(data.threadCount?.[0]?.value || 0);
|
||||
const uptime = Number(data.uptime?.[0]?.value || 0);
|
||||
const uptimeStr = uptime > 86400 ? `${(uptime / 86400).toFixed(1)}d` : `${(uptime / 3600).toFixed(1)}h`;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-5 gap-3">
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold text-[var(--dg-text-primary)]">{uptimeStr}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Uptime</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><MiniBar val={heap} label={`${heap.toFixed(1)}%`} color={heap > 80 ? 'bg-orange-500' : 'bg-purple-500'} /><div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Heap</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><MiniBar val={nonHeap} label={`${nonHeap.toFixed(1)}%`} color="bg-blue-500" /><div className="text-[10px] text-[var(--dg-text-muted)] mt-1">Non-Heap</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold text-emerald-400">{threads}</div><div className="text-[10px] text-[var(--dg-text-muted)]">Threads</div></CardContent></Card>
|
||||
<Card className="bg-[var(--dg-bg-primary)] border-[var(--dg-border)]"><CardContent className="p-3 text-center"><div className="text-lg font-bold text-amber-400">{Number(data.gcPause?.[0]?.value || 0).toFixed(3)}</div><div className="text-[10px] text-[var(--dg-text-muted)]">GC Pause (s)</div></CardContent></Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ============================================
|
||||
// 主组件
|
||||
// ============================================
|
||||
export const PrometheusDashboard: React.FC = () => {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [url, setUrl] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [targets, setTargets] = useState(0);
|
||||
const [tab, setTab] = useState('k8s');
|
||||
|
||||
// Dashboard 数据
|
||||
const [k8sData, setK8sData] = useState<any>({});
|
||||
const [nodeData, setNodeData] = useState<any>({});
|
||||
const [podData, setPodData] = useState<any>({});
|
||||
const [jvmData, setJvmData] = useState<any>({});
|
||||
|
||||
useEffect(() => {
|
||||
const saved = getSavedDS();
|
||||
if (saved) { setUrl(saved.url); doConnect(saved.url); }
|
||||
}, []);
|
||||
|
||||
const doConnect = async (u: string) => {
|
||||
setLoading(true); setError('');
|
||||
try {
|
||||
const data: any = await http.post('/prometheus/connect', { url: u });
|
||||
setConnected(true); setTargets(data.targets || 0);
|
||||
saveDS({ url: u });
|
||||
toast.success(`Prometheus 连接成功,${data.targets} 个 targets`);
|
||||
loadAllDashboards(u);
|
||||
} catch (e: any) {
|
||||
setConnected(false); setError(e.message || '连接失败');
|
||||
} finally { setLoading(false); }
|
||||
};
|
||||
|
||||
const loadAllDashboards = async (u: string) => {
|
||||
for (const t of ['k8s', 'node', 'pod', 'jvm']) {
|
||||
try {
|
||||
const data: any = await http.post('/prometheus/dashboard', { url: u, type: t });
|
||||
const items: any = {};
|
||||
for (const [k, v] of Object.entries(data || {})) {
|
||||
items[k] = Array.isArray(v) ? v : ((v as any)?.data?.result ? parsePrune((v as any).data.result) : v);
|
||||
}
|
||||
if (t === 'k8s') setK8sData(items);
|
||||
if (t === 'node') setNodeData(items);
|
||||
if (t === 'pod') setPodData(items);
|
||||
if (t === 'jvm') setJvmData(items);
|
||||
} catch {}
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
if (!url) { toast.error('请输入 Prometheus URL'); return; }
|
||||
doConnect(url);
|
||||
};
|
||||
|
||||
const refresh = () => { if (url) { doConnect(url); } };
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-purple-500/20">
|
||||
<CardHeader className="py-3 px-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-purple-400" />
|
||||
<CardTitle className="text-sm font-medium text-[var(--dg-text-primary)]">Prometheus 监控</CardTitle>
|
||||
{connected && <Badge className="text-[10px] bg-purple-500/10 text-purple-400 border-purple-500/30">{targets} targets</Badge>}
|
||||
{connected && <div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refresh}><RefreshCw className="w-3 h-3 mr-1" />刷新</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[10px] text-red-400" onClick={() => { setConnected(false); localStorage.removeItem(LS_KEY); }}>
|
||||
<X className="w-3 h-3 mr-1" />断开
|
||||
</Button>
|
||||
</div>}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3 pt-0">
|
||||
{loading ? (
|
||||
<div className="text-center py-12"><Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin text-purple-400" /><p className="text-xs text-[var(--dg-text-muted)]">连接中...</p></div>
|
||||
) : !connected ? (
|
||||
<div>
|
||||
{error && <div className="mb-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-400 flex items-start gap-2"><AlertTriangle className="h-4 w-4 mt-0.5" /><span>{error}</span></div>}
|
||||
{!showForm ? (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||||
<Plug className="h-8 w-8 mx-auto mb-2 text-slate-600" />
|
||||
<p className="text-sm">接入 Prometheus 以显示 K8s/Node/JVM 监控</p>
|
||||
<Button size="sm" variant="outline" className="mt-4" onClick={() => setShowForm(true)}><Settings className="w-3 h-3 mr-1" />配置连接</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-md mx-auto p-4 space-y-3 rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between"><span className="text-sm font-medium">Prometheus 连接</span><Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => setShowForm(false)}><X className="w-3 h-3" /></Button></div>
|
||||
<div><Label className="text-xs">URL</Label><Input className="h-8 text-xs mt-1" placeholder="http://prometheus:9090" value={url} onChange={e => setUrl(e.target.value)} /></div>
|
||||
<Button size="sm" className="w-full" onClick={handleConnect}><CheckCircle2 className="w-3 h-3 mr-1" />连接</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={tab} onValueChange={setTab}>
|
||||
<TabsList className="bg-[var(--dg-bg-tertiary)] mb-3">
|
||||
<TabsTrigger value="k8s" className="text-xs"><Container className="w-3 h-3 mr-1" />K8s 集群</TabsTrigger>
|
||||
<TabsTrigger value="node" className="text-xs"><Server className="w-3 h-3 mr-1" />节点</TabsTrigger>
|
||||
<TabsTrigger value="pod" className="text-xs"><Box className="w-3 h-3 mr-1" />Pod</TabsTrigger>
|
||||
<TabsTrigger value="jvm" className="text-xs"><Zap className="w-3 h-3 mr-1" />JVM</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="k8s"><K8sPanel data={k8sData} /></TabsContent>
|
||||
<TabsContent value="node"><NodePanel data={nodeData} /></TabsContent>
|
||||
<TabsContent value="pod"><PodPanel data={podData} /></TabsContent>
|
||||
<TabsContent value="jvm"><JvmPanel data={jvmData} /></TabsContent>
|
||||
</Tabs>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
// helper
|
||||
function parsePrune(result: any[]): any[] {
|
||||
return (result || []).map((r: any) => {
|
||||
const m: any = { ...(r.metric || {}) };
|
||||
if (r.value) m.value = r.value[1];
|
||||
return m;
|
||||
});
|
||||
}
|
||||
|
||||
// inline Box icon
|
||||
const Box = (props: any) => <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" {...props}><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/></svg>;
|
||||
|
||||
export default PrometheusDashboard;
|
||||
@@ -0,0 +1,310 @@
|
||||
// Zabbix 数据源监控面板 — 镜像 Grafana Dashboard 5456 + 5363 // Logan.Li
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import {
|
||||
Server, Wifi, WifiOff, Loader2, AlertTriangle, Activity,
|
||||
Cpu, HardDrive, Network, Clock, RefreshCw, Plug, Settings,
|
||||
ArrowUpDown, ArrowUp, ArrowDown, X, CheckCircle2
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { toast } from 'sonner';
|
||||
import { http } from '@/services/http';
|
||||
|
||||
interface ZabbixHost {
|
||||
hostid: string;
|
||||
hostname: string;
|
||||
name: string;
|
||||
ip: string;
|
||||
available: string; // 0=未知 1=可用 2=不可用
|
||||
cpu_usage: number;
|
||||
memory_usage: number;
|
||||
disk_usage: number;
|
||||
load5: number;
|
||||
net_in_bps: number;
|
||||
net_out_bps: number;
|
||||
problem_count: number;
|
||||
uptime_days: number;
|
||||
cpu_count: number;
|
||||
total_memory_gb: number;
|
||||
}
|
||||
|
||||
interface ZabbixData {
|
||||
version: string;
|
||||
hosts: ZabbixHost[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
type SortField = 'hostname' | 'cpu_usage' | 'memory_usage' | 'disk_usage' | 'load5' | 'net_in_bps' | 'net_out_bps' | 'problem_count' | 'uptime_days' | 'ip';
|
||||
|
||||
const LS_KEY = 'aiops_zabbix_ds';
|
||||
|
||||
function getSavedDS(): { url: string; username: string; password: string } | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (raw) return JSON.parse(raw);
|
||||
} catch {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function saveDS(ds: { url: string; username: string; password: string }) {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(ds));
|
||||
}
|
||||
|
||||
export const ZabbixDashboard: React.FC = () => {
|
||||
const [connected, setConnected] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [hosts, setHosts] = useState<ZabbixHost[]>([]);
|
||||
const [version, setVersion] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const [url, setUrl] = useState('');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
const [sortField, setSortField] = useState<SortField>('cpu_usage');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
// 尝试用保存的凭据自动连接
|
||||
useEffect(() => {
|
||||
const saved = getSavedDS();
|
||||
if (saved) {
|
||||
setUrl(saved.url);
|
||||
setUsername(saved.username);
|
||||
setPassword(saved.password);
|
||||
doConnect(saved.url, saved.username, saved.password);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const doConnect = async (u: string, un: string, pw: string) => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const data: ZabbixData = await http.post('/zabbix/connect', { url: u, username: un, password: pw });
|
||||
setHosts(data.hosts || []);
|
||||
setVersion(data.version || '');
|
||||
setConnected(true);
|
||||
saveDS({ url: u, username: un, password: pw });
|
||||
toast.success(`Zabbix ${data.version} 连接成功,${data.total} 台主机`);
|
||||
} catch (e: any) {
|
||||
setConnected(false);
|
||||
setError(e.message || '连接失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConnect = () => {
|
||||
if (!url || !username || !password) {
|
||||
toast.error('请填写完整连接信息');
|
||||
return;
|
||||
}
|
||||
doConnect(url, username, password);
|
||||
};
|
||||
|
||||
const handleDisconnect = () => {
|
||||
setConnected(false);
|
||||
setHosts([]);
|
||||
setError('');
|
||||
localStorage.removeItem(LS_KEY);
|
||||
toast.info('已断开 Zabbix 连接');
|
||||
};
|
||||
|
||||
const refreshData = () => {
|
||||
if (url && username && password) doConnect(url, username, password);
|
||||
};
|
||||
|
||||
const toggleSort = (field: SortField) => {
|
||||
if (sortField === field) setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
else { setSortField(field); setSortDir('desc'); }
|
||||
};
|
||||
|
||||
const sortedHosts = [...hosts].sort((a, b) => {
|
||||
const va = (a as any)[sortField] ?? '';
|
||||
const vb = (b as any)[sortField] ?? '';
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
return sortDir === 'asc' ? va - vb : vb - va;
|
||||
}
|
||||
return sortDir === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
||||
});
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortField !== field) return <ArrowUpDown className="w-3 h-3 text-slate-500" />;
|
||||
return sortDir === 'asc' ? <ArrowUp className="w-3 h-3 text-blue-400" /> : <ArrowDown className="w-3 h-3 text-blue-400" />;
|
||||
};
|
||||
|
||||
const usageBar = (val: number, label: string) => {
|
||||
const pct = Math.min(val, 100);
|
||||
const isWarn = val >= 80;
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[90px]">
|
||||
<div className="flex-1 h-2.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div className={`h-full rounded-full transition-all duration-500 ${isWarn ? 'bg-orange-500' : 'bg-blue-500'}`}
|
||||
style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<span className={`w-14 text-right text-xs font-mono tabular-nums ${isWarn ? 'text-orange-400' : ''}`}>{label}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const fmtBps = (bps: number): string => {
|
||||
if (!bps || bps === 0) return '—';
|
||||
if (bps > 1e9) return `${(bps / 1e9).toFixed(1)} GB/s`;
|
||||
if (bps > 1e6) return `${(bps / 1e6).toFixed(1)} MB/s`;
|
||||
if (bps > 1e3) return `${(bps / 1e3).toFixed(1)} KB/s`;
|
||||
return `${bps.toFixed(1)} B/s`;
|
||||
};
|
||||
|
||||
const getAvailBadge = (avail: string) => {
|
||||
return avail === '1'
|
||||
? <Badge className="text-[10px] bg-emerald-500/20 text-emerald-400 border-emerald-500/30"><Wifi className="w-3 h-3 mr-1" />可用</Badge>
|
||||
: <Badge className="text-[10px] bg-red-500/20 text-red-400 border-red-500/30"><WifiOff className="w-3 h-3 mr-1" />不可用</Badge>;
|
||||
};
|
||||
|
||||
const getPriorityColor = (count: number) => {
|
||||
if (count === 0) return '';
|
||||
if (count <= 2) return 'text-amber-400';
|
||||
return 'text-red-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-blue-500/20">
|
||||
<CardHeader className="py-3 px-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-4 w-4 text-blue-400" />
|
||||
<CardTitle className="text-sm font-medium text-[var(--dg-text-primary)]">Zabbix 主机监控</CardTitle>
|
||||
{version && <Badge className="text-[10px] bg-blue-500/10 text-blue-400 border-blue-500/30">v{version}</Badge>}
|
||||
{connected && (
|
||||
<>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)] ml-2">{hosts.length} 台主机</span>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[10px]" onClick={refreshData}><RefreshCw className="w-3 h-3 mr-1" />刷新</Button>
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2 text-[10px] text-red-400" onClick={handleDisconnect}><X className="w-3 h-3 mr-1" />断开</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3 pt-0">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-6 w-6 mx-auto mb-2 animate-spin text-blue-400" />
|
||||
<p className="text-xs">正在连接 Zabbix...</p>
|
||||
</div>
|
||||
) : !connected ? (
|
||||
<div>
|
||||
{error && (
|
||||
<div className="mb-3 p-3 rounded-lg bg-red-500/10 border border-red-500/20 text-xs text-red-400 flex items-start gap-2">
|
||||
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
{!showForm ? (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||||
<Plug className="h-8 w-8 mx-auto mb-2 text-slate-600" />
|
||||
<p className="text-sm">接入 Zabbix 数据源以显示主机监控</p>
|
||||
<p className="text-xs mt-1 mb-4">支持 Zabbix 5.0 / 6.0 / 7.0</p>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button size="sm" variant="outline" onClick={() => setShowForm(true)}>
|
||||
<Settings className="w-3 h-3 mr-1" />配置连接
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="max-w-md mx-auto p-4 space-y-3 rounded-lg bg-[var(--dg-bg-primary)] border border-[var(--dg-border)]">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">Zabbix 连接配置</span>
|
||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0" onClick={() => setShowForm(false)}><X className="w-3 h-3" /></Button>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">API URL</Label>
|
||||
<Input className="h-8 text-xs mt-1" placeholder="http://zabbix.example.com/api_jsonrpc.php"
|
||||
value={url} onChange={e => setUrl(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">用户名</Label>
|
||||
<Input className="h-8 text-xs mt-1" placeholder="Admin" value={username}
|
||||
onChange={e => setUsername(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">密码</Label>
|
||||
<Input className="h-8 text-xs mt-1" type="password" placeholder="••••••••" value={password}
|
||||
onChange={e => setPassword(e.target.value)} />
|
||||
</div>
|
||||
<Button size="sm" className="w-full" onClick={handleConnect}>
|
||||
<CheckCircle2 className="w-3 h-3 mr-1" />连接 Zabbix
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : hosts.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||||
<Server className="h-8 w-8 mx-auto mb-2 text-slate-600" />
|
||||
<p className="text-sm">未找到被监控的 Zabbix 主机</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
{[
|
||||
{ field: 'hostname' as SortField, label: '主机名', w: '' },
|
||||
{ field: 'ip' as SortField, label: 'IP', w: '' },
|
||||
{ field: 'cpu_usage' as SortField, label: 'CPU', w: 'min-w-[90px]' },
|
||||
{ field: 'memory_usage' as SortField, label: '内存', w: 'min-w-[90px]' },
|
||||
{ field: 'disk_usage' as SortField, label: '磁盘', w: 'min-w-[90px]' },
|
||||
{ field: 'load5' as SortField, label: 'Load5', w: '' },
|
||||
{ field: 'net_in_bps' as SortField, label: '流入', w: '' },
|
||||
{ field: 'net_out_bps' as SortField, label: '流出', w: '' },
|
||||
{ field: 'problem_count' as SortField, label: '问题', w: '' },
|
||||
{ field: 'uptime_days' as SortField, label: '运行', w: '' },
|
||||
].map(({ field, label, w }) => (
|
||||
<th key={field}
|
||||
className={`px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap ${w}`}
|
||||
onClick={() => toggleSort(field)}>
|
||||
<div className="flex items-center gap-1">{label}<SortIcon field={field} /></div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHosts.map((host, i) => (
|
||||
<tr key={host.hostid || i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50 transition-colors">
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{getAvailBadge(host.available)}
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{host.name || host.hostname}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap font-mono text-[var(--dg-text-secondary)]">{host.ip || '—'}</td>
|
||||
<td className="px-3 py-2.5">{usageBar(Number(host.cpu_usage || 0), `${Number(host.cpu_usage || 0).toFixed(1)}%`)}</td>
|
||||
<td className="px-3 py-2.5">{usageBar(Number(host.memory_usage || 0), `${Number(host.memory_usage || 0).toFixed(1)}%`)}</td>
|
||||
<td className="px-3 py-2.5">{usageBar(Number(host.disk_usage || 0), `${Number(host.disk_usage || 0).toFixed(1)}%`)}</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--dg-text-secondary)]">{host.load5 > 0 ? host.load5.toFixed(1) : '—'}</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap font-mono text-[var(--dg-text-secondary)]">{fmtBps(host.net_in_bps)}</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap font-mono text-[var(--dg-text-secondary)]">{fmtBps(host.net_out_bps)}</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap text-center">
|
||||
<span className={`font-mono font-bold ${getPriorityColor(host.problem_count)}`}>
|
||||
{host.problem_count > 0
|
||||
? <span className="inline-flex items-center gap-1"><AlertTriangle className="w-3 h-3" />{host.problem_count}</span>
|
||||
: <span className="text-emerald-400">0</span>}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--dg-text-secondary)]">
|
||||
{host.uptime_days > 0 ? `${host.uptime_days}d` : '—'}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ZabbixDashboard;
|
||||
Reference in new issue
Block a user