- 后端上报时若 agent_version 为空则不覆盖已有版本(防止旧Agent清空版本号) - 安装/升级后写入版本号 v0.1.0(而非 installed/latest) - DB 存量 online 主机 agent_version 补填 v0.1.0 - 前端恢复批量卸载 Agent 按钮
517 lines
33 KiB
TypeScript
517 lines
33 KiB
TypeScript
// 监控大屏 — 标签页切换 Agent / Zabbix / Prometheus + 自定义仪表盘 // Logan.Li
|
||
import React, { useState, useEffect, useMemo } from 'react';
|
||
import {
|
||
Monitor, Plus, RefreshCw, Maximize2, Minimize2, BarChart3, Clock,
|
||
Server, Activity, Database, Wifi, WifiOff, ArrowUpDown, ArrowUp, ArrowDown,
|
||
ExternalLink, Download, Rocket, PackageOpen, Loader2, Info, Trash2
|
||
} 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 {
|
||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
|
||
} from '@/components/ui/dialog';
|
||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||
import { Label } from '@/components/ui/label';
|
||
import { toast } from 'sonner';
|
||
import { useUserStore } from '@/store';
|
||
import { dashboardApi, promDatasourceApi, zabbixDatasourceApi, agentApi, serverApi } 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';
|
||
import { AgentWizard } from '@/components/agent/AgentWizard';
|
||
import { Checkbox } from '@/components/ui/checkbox';
|
||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||
import { useNavigate } from 'react-router-dom';
|
||
|
||
// ═══════════ Tab 配置 ═══════════
|
||
type TabId = 'agent' | 'zabbix' | 'prometheus' | 'dashboard';
|
||
const TABS: { id: TabId; label: string; icon: React.ReactNode }[] = [
|
||
{ id: 'agent', label: 'Agent 主机', icon: <Server className="w-3.5 h-3.5" /> },
|
||
{ id: 'zabbix', label: 'Zabbix', icon: <Activity className="w-3.5 h-3.5" /> },
|
||
{ id: 'prometheus', label: 'Prometheus', icon: <Activity className="w-3.5 h-3.5" /> },
|
||
{ id: 'dashboard', label: '自定义', icon: <Monitor className="w-3.5 h-3.5" /> },
|
||
];
|
||
|
||
// ═══════════ Agent 主机监控表格 ═══════════
|
||
interface AgentHost { server_id: number; hostname: string; ip: string; cpu_usage: number; cpu_cores: number; mem_usage: number; mem_total: number; mem_used: number; disk_usage: number; 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 AgentTab: React.FC = () => {
|
||
const _confirm = useConfirm();
|
||
const navigate = useNavigate();
|
||
const [hosts, setHosts] = useState<AgentHost[]>([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [sf, setSf] = useState<SortField>('cpu_usage');
|
||
const [sd, setSd] = useState<'asc' | 'desc'>('desc');
|
||
const [page, setPage] = useState(1);
|
||
const [pageSize, setPageSize] = useState(10);
|
||
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||
const [wizardOpen, setWizardOpen] = useState(false);
|
||
const [wizardServer, setWizardServer] = useState<any>(null);
|
||
// 批量部署对话框
|
||
const [batchOpen, setBatchOpen] = useState(false);
|
||
const [batchMode, setBatchMode] = useState<'install' | 'upgrade'>('install');
|
||
const [batchAck, setBatchAck] = useState(false);
|
||
const [batchSubmitting, setBatchSubmitting] = useState(false);
|
||
// 正在执行操作的主机 ID 集合(版本列 loading)
|
||
const [operating, setOperating] = useState<Set<number>>(new Set());
|
||
// 批量卸载
|
||
const [batchUninstalling, setBatchUninstalling] = useState(false);
|
||
const handleBatchUninstall = async () => {
|
||
const ok = await _confirm({ title: '批量卸载 Agent', description: `确定卸载选中的 ${selIds.length} 台主机的 AIOps Agent?卸载将在后台异步执行。`, confirmText: '卸载', variant: 'destructive' });
|
||
if (!ok) return;
|
||
setBatchUninstalling(true);
|
||
try {
|
||
await agentApi.batchUninstall(selIds);
|
||
toast.success(`已提交:批量卸载 ${selIds.length} 台主机的 Agent,后台执行中,30秒后自动刷新`);
|
||
setSelected(new Set());
|
||
setTimeout(() => fetch(), 30000);
|
||
} catch { toast.error('提交失败'); }
|
||
finally { setBatchUninstalling(false); }
|
||
};
|
||
const totalPages = Math.max(1, Math.ceil(hosts.length / pageSize));
|
||
const fetch = async () => { try { setHosts(asList(await http.get('/agent/latest'))); } catch {} finally { setLoading(false); } };
|
||
useEffect(() => { fetch(); const t = setInterval(fetch, 30000); return () => clearInterval(t); }, []);
|
||
const toggle = (f: SortField) => { if (sf === f) setSd(d => d === 'asc' ? 'desc' : 'asc'); else { setSf(f); setSd('desc'); } };
|
||
const sorted = [...hosts].sort((a, b) => {
|
||
let va: any = (a as any)[sf] ?? '', vb: any = (b as any)[sf] ?? '';
|
||
if (sf === 'agent_status') { va = a.agent_status || ''; vb = b.agent_status || ''; }
|
||
if (typeof va === 'number') return sd === 'asc' ? va - vb : vb - va;
|
||
return sd === 'asc' ? String(va).localeCompare(String(vb)) : String(vb).localeCompare(String(va));
|
||
});
|
||
const paged = sorted.slice((page - 1) * pageSize, page * pageSize);
|
||
const SI = ({ f }: { f: SortField }) => sf !== f ? <ArrowUpDown className="w-3 h-3 text-slate-500" /> : sd === 'asc' ? <ArrowUp className="w-3 h-3 text-emerald-400" /> : <ArrowDown className="w-3 h-3 text-emerald-400" />;
|
||
const bar = (v: number, l: string) => (
|
||
<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 ${v >= 80 ? 'bg-orange-500' : 'bg-emerald-500'}`} style={{ width: `${Math.min(v, 100)}%` }} /></div><span className={`w-14 text-right text-xs font-mono tabular-nums ${v >= 80 ? 'text-orange-400' : ''}`}>{l}</span></div>
|
||
);
|
||
|
||
const selAll = selected.size === hosts.length && hosts.length > 0;
|
||
const selAny = selected.size > 0;
|
||
const selIds = [...selected].map(Number).filter(n => !isNaN(n));
|
||
|
||
// 选中主机按安装/在线分组
|
||
const selectedHosts = useMemo(() => {
|
||
return hosts.filter(h => selected.has(String(h.server_id)));
|
||
}, [hosts, selected]);
|
||
const needInstall = useMemo(() => selectedHosts.filter(h => h.agent_status !== 'online'), [selectedHosts]);
|
||
const needUpgrade = useMemo(() => selectedHosts.filter(h => h.agent_status === 'online'), [selectedHosts]);
|
||
|
||
// 打开批量部署对话框
|
||
const openBatchDialog = () => {
|
||
setBatchAck(false);
|
||
// 自动推断模式: 全无Agent → install,全在线 → upgrade,混合 → install
|
||
if (needInstall.length > 0 && needUpgrade.length === 0) setBatchMode('install');
|
||
else if (needInstall.length === 0 && needUpgrade.length > 0) setBatchMode('upgrade');
|
||
else setBatchMode('install');
|
||
setBatchOpen(true);
|
||
};
|
||
|
||
// 执行批量部署
|
||
const handleBatchSubmit = async () => {
|
||
if (!batchAck) { toast.error('请先确认并勾选「我已知悉」'); return; }
|
||
setBatchSubmitting(true);
|
||
const token = useUserStore.getState().token;
|
||
const ids = selectedHosts.map(h => h.server_id);
|
||
setOperating(new Set(ids));
|
||
try {
|
||
if (batchMode === 'install') {
|
||
if (!token) { toast.error('未登录'); return; }
|
||
// 安装:只安装无 Agent 的
|
||
const installIds = needInstall.map(h => h.server_id);
|
||
if (installIds.length === 0) { toast.warning('所选主机均已安装 Agent,无需安装'); return; }
|
||
const res = await serverApi.batchInstallAgent(installIds, token);
|
||
toast.success(`已提交:批量安装 ${res?.total ?? installIds.length} 台主机的 Agent,后台执行中,30秒后自动刷新`);
|
||
setSelected(new Set());
|
||
setTimeout(() => { fetch(); setOperating(new Set()); }, 30000);
|
||
} else {
|
||
// 升级:只升级在线的
|
||
const upgradeIds = needUpgrade.map(h => h.server_id);
|
||
if (upgradeIds.length === 0) { toast.warning('所选主机中没有在线 Agent,无法升级'); return; }
|
||
const res = await agentApi.batchUpgrade(upgradeIds);
|
||
toast.success(res?.message || `已提交:批量升级 ${upgradeIds.length} 台主机的 Agent,后台执行中,30秒后自动刷新`);
|
||
setSelected(new Set());
|
||
setTimeout(() => { fetch(); setOperating(new Set()); }, 30000);
|
||
}
|
||
setBatchOpen(false);
|
||
} catch { toast.error('提交失败'); setOperating(new Set()); }
|
||
finally { setBatchSubmitting(false); }
|
||
};
|
||
|
||
if (loading) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Activity className="h-6 w-6 mx-auto mb-2 animate-spin" /><p className="text-xs">加载中…</p></div>;
|
||
if (!hosts.length) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Server className="h-8 w-8 mx-auto mb-2" /><p className="text-sm">暂无服务器资产</p><p className="text-xs mt-1">请先在「服务器管理」中添加主机,然后在此安装 Agent</p></div>;
|
||
return (
|
||
<div>
|
||
{/* 工具栏 */}
|
||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||
{selAny && <span className="text-xs text-[var(--dg-text-secondary)]">已选 {selected.size} 项</span>}
|
||
<div className="flex-1" />
|
||
<Button size="sm" variant="outline" className="h-7 text-xs gap-1" onClick={() => {
|
||
const picked = selAny ? hosts.find(h => selected.has(String(h.server_id))) : null;
|
||
setWizardServer(picked ? { id: picked.server_id, hostname: picked.hostname, ip: picked.ip, has_auth: (picked as any).has_auth } : null);
|
||
setWizardOpen(true);
|
||
}}>
|
||
<Rocket className="w-3 h-3" />安装 Agent
|
||
</Button>
|
||
{selAny && (
|
||
<>
|
||
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 border-blue-500/30 text-blue-400 hover:bg-blue-500/10" onClick={openBatchDialog}>
|
||
<PackageOpen className="w-3 h-3" />批量部署 ({selected.size})
|
||
</Button>
|
||
<Button size="sm" variant="outline" className="h-7 text-xs gap-1 border-red-500/30 text-red-400 hover:bg-red-500/10" onClick={handleBatchUninstall} disabled={batchUninstalling}>
|
||
<Trash2 className="w-3 h-3" />批量卸载 ({selected.size})
|
||
</Button>
|
||
</>
|
||
)}
|
||
<Button size="sm" variant="ghost" className="h-7 text-xs gap-1" onClick={fetch}><RefreshCw className="w-3 h-3" />刷新</Button>
|
||
</div>
|
||
|
||
<AgentWizard open={wizardOpen} onOpenChange={setWizardOpen} server={wizardServer}
|
||
onInstalled={() => { toast.success('Agent 安装成功'); setWizardOpen(false); fetch(); }}
|
||
onFailed={() => toast.error('安装失败')} />
|
||
|
||
{/* ────────── 批量部署 Agent 对话框 ────────── */}
|
||
<Dialog open={batchOpen} onOpenChange={(v) => { if (!v) { setBatchOpen(false); setBatchAck(false); } }}>
|
||
<DialogContent className="max-w-2xl max-h-[85vh] overflow-auto bg-slate-900 border-slate-700 text-slate-100">
|
||
<DialogHeader>
|
||
<DialogTitle className="flex items-center gap-2 text-base">
|
||
<PackageOpen className="w-5 h-5 text-blue-400" />
|
||
批量部署 Agent
|
||
</DialogTitle>
|
||
<DialogDescription className="text-slate-400 text-xs">
|
||
选中 {selectedHosts.length} 台主机(需安装: {needInstall.length},待升级: {needUpgrade.length})
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
<div className="space-y-4">
|
||
{/* 模式选择 — 类似阿里云安装/升级切换 */}
|
||
<div className="flex items-center gap-2 p-1 rounded-lg bg-slate-800 border border-slate-700 w-fit">
|
||
{(['install', 'upgrade'] as const).map(m => (
|
||
<button
|
||
key={m}
|
||
disabled={m === 'install' ? needInstall.length === 0 : needUpgrade.length === 0}
|
||
onClick={() => setBatchMode(m)}
|
||
className={`px-4 py-1.5 rounded text-xs font-medium transition-colors disabled:opacity-30 disabled:cursor-not-allowed ${
|
||
batchMode === m ? 'bg-blue-600 text-white shadow-sm' : 'text-slate-400 hover:text-slate-200'
|
||
}`}
|
||
>
|
||
{m === 'install' ? `⚡ 安装 (${needInstall.length})` : `🔄 升级到最新版 (${needUpgrade.length})`}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{/* 主机列表 */}
|
||
<div className="rounded-md border border-slate-700 overflow-hidden">
|
||
<table className="w-full text-xs">
|
||
<thead className="bg-slate-800">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left font-medium text-slate-400">主机名</th>
|
||
<th className="px-3 py-2 text-left font-medium text-slate-400">IP</th>
|
||
<th className="px-3 py-2 text-left font-medium text-slate-400">状态</th>
|
||
<th className="px-3 py-2 text-left font-medium text-slate-400">当前版本</th>
|
||
<th className="px-3 py-2 text-left font-medium text-slate-400">操作</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{(batchMode === 'install' ? needInstall : needUpgrade).map(h => (
|
||
<tr key={h.server_id} className="border-t border-slate-800 hover:bg-slate-800/50">
|
||
<td className="px-3 py-2 font-medium text-slate-200">{h.hostname}</td>
|
||
<td className="px-3 py-2 font-mono text-slate-400">{h.ip}</td>
|
||
<td className="px-3 py-2">
|
||
{h.agent_status === 'online'
|
||
? <Badge className="text-[10px] bg-emerald-500/20 text-emerald-400">在线</Badge>
|
||
: <Badge className="text-[10px] bg-slate-500/20 text-slate-400">
|
||
{h.agent_status === 'not_installed' ? '未安装' : h.agent_status || '离线'}
|
||
</Badge>}
|
||
</td>
|
||
<td className="px-3 py-2 font-mono text-slate-400">{h.agent_version || '—'}</td>
|
||
<td className="px-3 py-2">
|
||
<span className="text-blue-400 text-[11px]">
|
||
{batchMode === 'install' ? '即将安装' : '即将升级'}
|
||
</span>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
{/* ⚠️ 警告文案 — 参考阿里云批量安装/升级风格 */}
|
||
<div className="rounded-md border border-amber-500/30 bg-amber-500/5 p-3 space-y-2">
|
||
<div className="flex items-start gap-2">
|
||
<Info className="w-4 h-4 text-amber-400 mt-0.5 shrink-0" />
|
||
<div>
|
||
<p className="text-xs font-medium text-amber-300 mb-1">
|
||
{batchMode === 'install' ? '本次安装' : '本次升级'},主机监控探针将部署 AIOps Agent。AIOps Agent 是 AIOps 平台推出的新一代数据采集器,提供主机监控指标采集和可观测数据采集能力。
|
||
</p>
|
||
<p className="text-[11px] text-amber-400/80 leading-relaxed">
|
||
{batchMode === 'upgrade' && '升级前请注意以下事项:'}
|
||
{batchMode === 'install' && '安装前请注意以下事项:'}
|
||
</p>
|
||
<ul className="mt-1 space-y-0.5 text-[11px] text-slate-400 list-disc list-inside">
|
||
{batchMode === 'upgrade' && (
|
||
<>
|
||
<li>如果您的机器已经安装旧版 Agent,升级操作将停止旧版并替换为最新版本。</li>
|
||
<li>如果您的机器之前安装过 Agent 但已卸载,遗留有相关采集配置,本次升级可能导致配置重新生效。升级前请确保存量废弃配置已经移除。</li>
|
||
<li>如果您的机器采用自定义安装的 Agent(非 AIOps 提供的一键安装脚本),请不要直接使用批量升级。</li>
|
||
<li>批量升级前,请务必先单机灰度升级,确保升级成功且不影响业务后再操作。</li>
|
||
</>
|
||
)}
|
||
{batchMode === 'install' && (
|
||
<>
|
||
<li>安装将自动检测目标主机架构(amd64/arm64),下载对应版本的 Agent 二进制并启动服务。</li>
|
||
<li>请确保目标主机的 SSH 端口(默认 22)可访问,且已关联有效的 SSH 凭据。</li>
|
||
<li>安装过程约 30-60 秒,期间目标主机 CPU 负载会短暂升高。</li>
|
||
<li>若安装失败,请检查目标主机的防火墙规则和 SSH 服务状态后重试。</li>
|
||
</>
|
||
)}
|
||
<li>操作不可回滚,请在操作前确认已备份关键配置。</li>
|
||
<li>操作将在后台异步执行,您可以在「任务执行记录」中查看详细进度。</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 我已知悉 勾选 */}
|
||
<div className="flex items-center gap-2">
|
||
<Checkbox id="batch-ack" checked={batchAck} onCheckedChange={(c) => setBatchAck(!!c)} />
|
||
<label htmlFor="batch-ack" className="text-xs text-slate-300 cursor-pointer select-none">我已知悉以上风险和注意事项</label>
|
||
</div>
|
||
</div>
|
||
|
||
<DialogFooter className="gap-2">
|
||
<Button variant="outline" className="border-slate-700 text-slate-300 h-8 text-xs" onClick={() => { setBatchOpen(false); setBatchAck(false); }}>
|
||
取消
|
||
</Button>
|
||
<Button
|
||
className="bg-blue-600 hover:bg-blue-700 text-white h-8 text-xs gap-1"
|
||
disabled={batchSubmitting || !batchAck || (batchMode === 'install' ? needInstall.length === 0 : needUpgrade.length === 0)}
|
||
onClick={handleBatchSubmit}
|
||
>
|
||
{batchSubmitting && <Loader2 className="w-3 h-3 animate-spin" />}
|
||
{batchMode === 'install' ? '确认安装' : '确认升级'}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
|
||
<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"><tr>
|
||
<th className="px-2 py-2 w-8">
|
||
<Checkbox checked={selAll} onCheckedChange={(c) => setSelected(c ? new Set(hosts.map(h => String(h.server_id))) : new Set())} />
|
||
</th>
|
||
{['hostname','agent_version','agent_status','cpu_usage','mem_usage','disk_usage','region','ip'].map(f => (
|
||
<th key={f} 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={() => toggle(f as SortField)}>
|
||
<div className="flex items-center gap-1">{{hostname:'主机名',agent_version:'Agent版本',agent_status:'状态',cpu_usage:'CPU',mem_usage:'内存',disk_usage:'磁盘',region:'区域',ip:'IP'}[f]}<SI f={f as SortField} /></div>
|
||
</th>
|
||
))}
|
||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] whitespace-nowrap">操作</th></tr></thead>
|
||
<tbody>
|
||
{paged.map((h, i) => (
|
||
<tr key={h.hostname + i} className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50">
|
||
<td className="px-2 py-2.5">
|
||
<Checkbox checked={selected.has(String(h.server_id))} onCheckedChange={(c) => {
|
||
const next = new Set(selected);
|
||
c ? next.add(String(h.server_id)) : next.delete(String(h.server_id));
|
||
setSelected(next);
|
||
}} />
|
||
</td>
|
||
<td className="px-3 py-2.5 font-medium text-[var(--dg-text-primary)]">{h.hostname}</td>
|
||
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">
|
||
{operating.has(h.server_id) ? (
|
||
<span className="inline-flex items-center gap-1 text-blue-400">
|
||
<Loader2 className="w-3 h-3 animate-spin" />
|
||
<span className="text-[10px]">部署中</span>
|
||
</span>
|
||
) : (
|
||
h.agent_version || '—'
|
||
)}
|
||
</td>
|
||
<td className="px-3 py-2.5">{h.agent_status==='online'?<Badge className="text-[10px] bg-emerald-500/20 text-emerald-400"><Wifi className="w-3 h-3 mr-1"/>在线</Badge>:h.agent_status==='not_installed'?<Badge className="text-[10px] bg-amber-500/20 text-amber-400">未安装</Badge>:<Badge className="text-[10px] bg-slate-500/10 text-slate-400"><WifiOff className="w-3 h-3 mr-1"/>离线</Badge>}</td>
|
||
<td className="px-3 py-2.5">{bar(Number(h.cpu_usage||0), `${Number(h.cpu_usage||0).toFixed(1)}%`)}</td>
|
||
<td className="px-3 py-2.5">{bar(Number(h.mem_usage||0), `${Number(h.mem_usage||0).toFixed(1)}%`)}</td>
|
||
<td className="px-3 py-2.5">{bar(Number(h.disk_usage||0), `${Number(h.disk_usage||0).toFixed(1)}%`)}</td>
|
||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)]">{h.region||'—'}</td>
|
||
<td className="px-3 py-2.5 font-mono text-[var(--dg-text-secondary)]">{h.ip||'—'}</td>
|
||
<td className="px-3 py-2.5">
|
||
<Button variant="ghost" size="sm" className="h-7 text-xs text-blue-400 hover:text-blue-300 hover:bg-blue-500/10 px-2"
|
||
onClick={() => navigate(`/host-monitor?host=${encodeURIComponent(h.hostname)}`)}>
|
||
<BarChart3 className="w-3.5 h-3.5 mr-1" />图表
|
||
</Button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
{hosts.length > pageSize && (
|
||
<div className="flex items-center justify-between px-3 py-2 bg-[var(--dg-bg-tertiary)] border-t border-[var(--dg-border)] text-xs">
|
||
<div className="flex items-center gap-2 text-[var(--dg-text-muted)]">
|
||
<span>共 {hosts.length} 条</span>
|
||
<select className="bg-[var(--dg-bg-primary)] border border-[var(--dg-border)] rounded px-2 py-0.5 text-xs" value={pageSize} onChange={e => { setPageSize(Number(e.target.value)); setPage(1); }}>
|
||
<option value={10}>10/页</option>
|
||
<option value={20}>20/页</option>
|
||
<option value={30}>30/页</option>
|
||
</select>
|
||
</div>
|
||
<div className="flex items-center gap-1">
|
||
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={page <= 1} onClick={() => setPage(p => p - 1)}>上一页</button>
|
||
<span className="px-2 text-[var(--dg-text-primary)]">{page} / {totalPages}</span>
|
||
<button className="px-2 py-0.5 rounded hover:bg-[var(--dg-bg-primary)] disabled:opacity-30" disabled={page >= totalPages} onClick={() => setPage(p => p + 1)}>下一页</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ═══════════ 数据源 Wrapper ═══════════
|
||
const ZabbixTab: React.FC = () => {
|
||
const [ds, setDs] = useState<any>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
useEffect(() => {
|
||
zabbixDatasourceApi.list().then((r: any) => {
|
||
const list = r?.data || r || [];
|
||
setDs(list.find((d: any) => d.is_default) || list[0] || null);
|
||
}).catch(() => {}).finally(() => setLoading(false));
|
||
}, []);
|
||
if (loading) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Activity className="h-6 w-6 mx-auto mb-2 animate-spin" /><p className="text-xs">加载数据源…</p></div>;
|
||
if (!ds) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Database className="h-8 w-8 mx-auto mb-2" /><p className="text-sm">未配置 Zabbix 数据源</p><a href="/datasources" className="text-xs text-blue-400 hover:underline inline-flex items-center gap-1">前往配置 <ExternalLink className="w-3 h-3" /></a></div>;
|
||
return <ZabbixDashboard dsUrl={ds.url} dsUsername={ds.username} dsPassword={ds.password} />;
|
||
};
|
||
|
||
const PrometheusTab: React.FC = () => {
|
||
const [ds, setDs] = useState<any>(null);
|
||
const [loading, setLoading] = useState(true);
|
||
useEffect(() => {
|
||
promDatasourceApi.list().then((r: any) => {
|
||
const list = r?.data || r || [];
|
||
setDs(list.find((d: any) => d.is_default) || list[0] || null);
|
||
}).catch(() => {}).finally(() => setLoading(false));
|
||
}, []);
|
||
if (loading) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Activity className="h-6 w-6 mx-auto mb-2 animate-spin" /><p className="text-xs">加载数据源…</p></div>;
|
||
if (!ds) return <div className="text-center py-20 text-[var(--dg-text-muted)]"><Database className="h-8 w-8 mx-auto mb-2" /><p className="text-sm">未配置 Prometheus 数据源</p><a href="/datasources" className="text-xs text-blue-400 hover:underline inline-flex items-center gap-1">前往配置 <ExternalLink className="w-3 h-3" /></a></div>;
|
||
return <PrometheusDashboard dsUrl={ds.url} />;
|
||
};
|
||
|
||
// ═══════════ 自定义仪表盘 ═══════════
|
||
interface Dashboard { id: string; name: string; description: string; datasource: string; datasourceUrl: string; panels: Panel[]; createdAt: string; updatedAt: string; createdBy: string; isDefault: boolean; }
|
||
export interface Panel { id: string; title: string; type: string; query: string; datasource: string; width: number; height: number; x: number; y: number; options: any; }
|
||
|
||
const DashboardTab: React.FC = () => {
|
||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||
const [active, setActive] = useState<Dashboard | null>(null);
|
||
const [createOpen, setCreateOpen] = useState(false);
|
||
const [addOpen, setAddOpen] = useState(false);
|
||
const [nd, setNd] = useState({ name: '', description: '', datasource: 'prometheus' as const, datasourceUrl: '' });
|
||
const [np, setNp] = useState({ title: '', type: 'graph' as const, query: '', width: 6, height: 4 });
|
||
|
||
const fetch = async () => {
|
||
try { const res = await dashboardApi.getDashboards(); const list = res.data || []; setDashboards(list); if (list.length > 0 && !active) setActive(list[0]); } catch {}
|
||
};
|
||
useEffect(() => { fetch(); }, []);
|
||
|
||
const create = async () => {
|
||
if (!nd.name) { toast.error('请输入名称'); return; }
|
||
try { await dashboardApi.createDashboard({ ...nd, panels: [] }); toast.success('创建成功'); setCreateOpen(false); setNd({ name: '', description: '', datasource: 'prometheus', datasourceUrl: '' }); fetch(); } catch { toast.error('创建失败'); }
|
||
};
|
||
const addPanel = async () => {
|
||
if (!np.title || !np.query || !active) return;
|
||
try {
|
||
const p: Panel = { id: Date.now().toString(), ...np, datasource: active.datasource, x: 0, y: 0, options: { legend: true, tooltip: true, refreshInterval: 30 } };
|
||
await dashboardApi.updateDashboard(active.id, { ...active, panels: [...active.panels, p] });
|
||
toast.success('面板添加成功'); setAddOpen(false); setNp({ title: '', type: 'graph', query: '', width: 6, height: 4 }); fetch();
|
||
} catch { toast.error('添加失败'); }
|
||
};
|
||
|
||
return (
|
||
<div className="space-y-4">
|
||
<div className="flex items-center gap-2">
|
||
<Select value={active?.id} onValueChange={id => { const d = dashboards.find(x => x.id === id); if (d) setActive(d); }}>
|
||
<SelectTrigger className="w-[240px] h-8 text-xs"><SelectValue placeholder="选择仪表盘" /></SelectTrigger>
|
||
<SelectContent>{dashboards.map(d => <SelectItem key={d.id} value={d.id}>{d.name}{d.isDefault ? ' (默认)' : ''}</SelectItem>)}</SelectContent>
|
||
</Select>
|
||
<Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={fetch}><RefreshCw className="w-3 h-3 mr-1" />刷新</Button>
|
||
{active && <Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={() => setAddOpen(true)}><Plus className="w-3 h-3 mr-1" />添加面板</Button>}
|
||
<Button variant="outline" size="sm" className="h-8 text-[10px]" onClick={() => setCreateOpen(true)}><Plus className="w-3 h-3 mr-1" />新建</Button>
|
||
</div>
|
||
|
||
{active ? (
|
||
active.panels.length === 0 ? (
|
||
<div className="text-center py-16 text-[var(--dg-text-muted)]"><BarChart3 className="w-10 h-10 mx-auto mb-2 opacity-50" /><p className="text-xs">暂无面板</p></div>
|
||
) : (
|
||
<div className="grid grid-cols-12 gap-3">
|
||
{active.panels.map(p => (
|
||
<Card key={p.id} style={{ gridColumn: `span ${p.width}`, minHeight: `${p.height * 80}px` }}>
|
||
<CardHeader className="py-2 px-3"><CardTitle className="text-[11px] font-medium">{p.title}</CardTitle></CardHeader>
|
||
<CardContent className="p-3 pt-0"><GrafanaPanelContent panel={p} /></CardContent>
|
||
</Card>
|
||
))}
|
||
</div>
|
||
)
|
||
) : (
|
||
<div className="text-center py-16 text-[var(--dg-text-muted)]"><Monitor className="w-10 h-10 mx-auto mb-2 opacity-50" /><p className="text-xs">暂无仪表盘</p></div>
|
||
)}
|
||
|
||
<Dialog open={createOpen} onOpenChange={setCreateOpen}><DialogContent><DialogHeader><DialogTitle>新建仪表盘</DialogTitle></DialogHeader>
|
||
<div className="space-y-3"><div><Label>名称</Label><Input value={nd.name} onChange={e => setNd({ ...nd, name: e.target.value })} /></div>
|
||
<div><Label>数据源</Label><Select value={nd.datasource} onValueChange={v => setNd({ ...nd, datasource: v as any })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="prometheus">Prometheus</SelectItem><SelectItem value="zabbix">Zabbix</SelectItem><SelectItem value="skywalking">SkyWalking</SelectItem><SelectItem value="custom">自定义</SelectItem></SelectContent></Select></div></div>
|
||
<DialogFooter><Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button><Button onClick={create}>创建</Button></DialogFooter></DialogContent></Dialog>
|
||
|
||
<Dialog open={addOpen} onOpenChange={setAddOpen}><DialogContent><DialogHeader><DialogTitle>添加面板</DialogTitle></DialogHeader>
|
||
<div className="space-y-3"><div><Label>标题</Label><Input value={np.title} onChange={e => setNp({ ...np, title: e.target.value })} /></div>
|
||
<div><Label>类型</Label><Select value={np.type} onValueChange={v => setNp({ ...np, type: v as any })}><SelectTrigger><SelectValue /></SelectTrigger><SelectContent><SelectItem value="graph">折线图</SelectItem><SelectItem value="stat">数值</SelectItem><SelectItem value="gauge">仪表盘</SelectItem><SelectItem value="table">表格</SelectItem></SelectContent></Select></div>
|
||
<div><Label>查询</Label><Input value={np.query} onChange={e => setNp({ ...np, query: e.target.value })} /></div></div>
|
||
<DialogFooter><Button variant="outline" onClick={() => setAddOpen(false)}>取消</Button><Button onClick={addPanel}>添加</Button></DialogFooter></DialogContent></Dialog>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
// ═══════════ 主组件 ═══════════
|
||
const GrafanaDashboard: React.FC = () => {
|
||
const [tab, setTab] = useState<TabId>('agent');
|
||
const [fullscreen, setFullscreen] = useState(false);
|
||
|
||
return (
|
||
<div className={`space-y-4 ${fullscreen ? 'fixed inset-0 z-50 bg-background p-4 overflow-auto' : ''}`}>
|
||
|
||
{/* Tab 切换栏 */}
|
||
<div className="flex items-center gap-1 p-1 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||
{TABS.map(t => (
|
||
<button
|
||
key={t.id}
|
||
onClick={() => setTab(t.id)}
|
||
className={`flex items-center gap-1.5 px-4 py-2 rounded-md text-xs font-medium transition-colors ${
|
||
tab === t.id
|
||
? 'bg-[var(--dg-bg-primary)] text-[var(--dg-text-primary)] shadow-sm'
|
||
: 'text-[var(--dg-text-muted)] hover:text-[var(--dg-text-secondary)]'
|
||
}`}
|
||
>
|
||
{t.icon}
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
<div className="ml-auto">
|
||
<Button variant="ghost" size="sm" className="h-7 w-7 p-0" onClick={() => setFullscreen(!fullscreen)}>
|
||
{fullscreen ? <Minimize2 className="w-3.5 h-3.5" /> : <Maximize2 className="w-3.5 h-3.5" />}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Tab 内容 */}
|
||
<div className="min-h-[50vh]">
|
||
{tab === 'agent' && <AgentTab />}
|
||
{tab === 'zabbix' && <ZabbixTab />}
|
||
{tab === 'prometheus' && <PrometheusTab />}
|
||
{tab === 'dashboard' && <DashboardTab />}
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default GrafanaDashboard;
|