Files
aiops/frontend/src/components/monitor/dashboard/GrafanaDashboard.tsx
T
attacker 4dd3e3238d fix: agent_version 为空 & 恢复批量卸载
- 后端上报时若 agent_version 为空则不覆盖已有版本(防止旧Agent清空版本号)
- 安装/升级后写入版本号 v0.1.0(而非 installed/latest)
- DB 存量 online 主机 agent_version 补填 v0.1.0
- 前端恢复批量卸载 Agent 按钮
2026-06-19 01:51:16 +00:00

517 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 监控大屏 — 标签页切换 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;