docs: update README, API reference, dev-log, project structure with Agent monitoring + quick-alarm + latest fixes

- README.md: React 19, default pwd 123456, Docker Compose deploy, correct module list
- api-reference.md: add Agent monitoring section (report/latest/rules/quick-setup/alerts/tasks/kline)
- dev-log.md: new entry documenting hostname/IP fix, system collector, offline detection, quick alarm
- project-structure.md: add agent/ directory, dev-log.md link, React 19
This commit is contained in:
attacker committed 2026-06-18 16:11:13 +00:00
1 parent c51d0b8b8b
commit 4e3f8cb306
20 files changed
+1315 -1344

No files matched your search

@@ -1,751 +1,249 @@
// 监控大屏 — 标签页切换 Agent / Zabbix / Prometheus + 自定义仪表盘 // Logan.Li
import React, { useState, useEffect } from 'react';
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, ArrowUpDown, ArrowUp, ArrowDown,
Wifi, WifiOff
Monitor, Plus, RefreshCw, Maximize2, Minimize2, BarChart3, Clock,
Server, Activity, Database, Wifi, WifiOff, ArrowUpDown, ArrowUp, ArrowDown,
ExternalLink
} 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, DialogDescription, DialogFooter
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Label } from '@/components/ui/label';
import { Separator } from '@/components/ui/separator';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { dashboardApi } from '@/services/api';
import { dashboardApi, promDatasourceApi, zabbixDatasourceApi } 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;
name: string;
description: string;
datasource: 'prometheus' | 'zabbix' | 'skywalking' | 'custom';
datasourceUrl: string;
panels: Panel[];
createdAt: string;
updatedAt: string;
createdBy: string;
isDefault: boolean;
}
export interface Panel {
id: string;
title: string;
type: 'graph' | 'stat' | 'gauge' | 'table' | 'pie' | 'heatmap' | 'text';
query: string;
datasource: string;
width: number;
height: number;
x: number;
y: number;
options: PanelOptions;
}
interface PanelOptions {
unit?: string;
min?: number;
max?: number;
thresholds?: Threshold[];
legend?: boolean;
tooltip?: boolean;
refreshInterval?: number;
}
interface Threshold {
value: number;
color: string;
name: string;
}
interface Datasource {
id: string;
name: string;
type: 'prometheus' | 'zabbix' | 'skywalking' | 'influxdb' | 'elasticsearch';
url: string;
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;
}
// ═══════════ 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 { 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 GrafanaDashboard: React.FC = () => {
const _confirm = useConfirm();
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
const [datasources, setDatasources] = useState<Datasource[]>([]);
const [activeDashboard, setActiveDashboard] = useState<Dashboard | null>(null);
const [loading, setLoading] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [createOpen, setCreateOpen] = useState(false);
const [addPanelOpen, setAddPanelOpen] = useState(false);
const [datasourceOpen, setDatasourceOpen] = useState(false);
const [newDashboard, setNewDashboard] = useState({
name: '',
description: '',
datasource: 'prometheus' as const,
datasourceUrl: ''
});
const [newPanel, setNewPanel] = useState({
title: '',
type: 'graph' as const,
query: '',
width: 6,
height: 4
});
const [newDatasource, setNewDatasource] = useState({
name: '',
type: 'prometheus' as const,
url: ''
});
// ==================== Agent 主机监控 ====================
const AgentTab: React.FC = () => {
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(() => {
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 [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 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 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}%` }}
/>
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>
);
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">暂无 Agent 上报数据</p><p className="text-xs mt-1">请确保至少一台主机安装了 AIOps Agent</p></div>;
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"><tr>
{['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>
))}</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-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)]">{h.agent_version || '—'}</td>
<td className="px-3 py-2.5"><Badge className={`text-[10px] ${h.agent_status==='online'?'bg-emerald-500/20 text-emerald-400':'bg-slate-500/10 text-slate-400'}`}>{h.agent_status==='online'?<><Wifi className="w-3 h-3 mr-1"/>在线</>:<><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>
</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>
<span className={`w-14 text-right text-xs font-mono tabular-nums ${textColor}`}>
{label}
</span>
</div>
);
};
)}
</div>
);
};
// ==================== 仪表盘 CRUD ====================
// ═══════════ 数据源 Wrapper ═══════════
const ZabbixTab: React.FC = () => {
const [ds, setDs] = useState<any>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchDashboards();
fetchDatasources();
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 fetchDashboards = async () => {
setLoading(true);
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 res = await dashboardApi.getDashboards();
setDashboards(res.data || []);
if (res.data?.length > 0 && !activeDashboard) {
setActiveDashboard(res.data[0]);
}
} catch (error) {
toast.error('获取仪表盘失败');
} finally {
setLoading(false);
}
};
const fetchDatasources = async () => {
try {
const res = await dashboardApi.getDatasources();
setDatasources(res.data || []);
} catch (error) {
toast.error('获取数据源失败');
}
};
const handleCreateDashboard = async () => {
if (!newDashboard.name) {
toast.error('请输入仪表盘名称');
return;
}
try {
await dashboardApi.createDashboard({
...newDashboard,
panels: []
});
toast.success('仪表盘创建成功');
setCreateOpen(false);
setNewDashboard({ name: '', description: '', datasource: 'prometheus', datasourceUrl: '' });
fetchDashboards();
} catch (error) {
toast.error('创建失败');
}
};
const handleAddPanel = async () => {
if (!newPanel.title || !newPanel.query || !activeDashboard) {
toast.error('请填写完整信息');
return;
}
try {
const panel: Panel = {
id: Date.now().toString(),
...newPanel,
datasource: activeDashboard.datasource,
x: 0,
y: 0,
options: {
unit: '',
legend: true,
tooltip: true,
refreshInterval: 30
}
};
await dashboardApi.updateDashboard(activeDashboard.id, {
...activeDashboard,
panels: [...activeDashboard.panels, panel]
});
toast.success('面板添加成功');
setAddPanelOpen(false);
setNewPanel({ title: '', type: 'graph', query: '', width: 6, height: 4 });
fetchDashboards();
} catch (error) {
toast.error('添加失败');
}
};
const handleAddDatasource = async () => {
if (!newDatasource.name || !newDatasource.url) {
toast.error('请填写完整信息');
return;
}
try {
await dashboardApi.createDatasource(newDatasource);
toast.success('数据源添加成功');
setDatasourceOpen(false);
setNewDatasource({ name: '', type: 'prometheus', url: '' });
fetchDatasources();
} catch (error) {
toast.error('添加失败');
}
};
const handleDeleteDashboard = async (id: string) => {
{ const ok = await _confirm({ title: '删除仪表盘', description: '确定删除此仪表盘吗?', confirmText: '删除', variant: 'destructive' }); if (!ok) return; }
try {
await dashboardApi.deleteDashboard(id);
toast.success('删除成功');
if (activeDashboard?.id === id) {
setActiveDashboard(null);
}
fetchDashboards();
} catch (error) {
toast.error('删除失败');
}
};
const getDatasourceIcon = (type: string) => {
switch (type) {
case 'prometheus': return <Activity className="w-4 h-4" />;
case 'zabbix': return <Server className="w-4 h-4" />;
case 'skywalking': return <Globe className="w-4 h-4" />;
case 'influxdb': return <Database className="w-4 h-4" />;
default: return <Database className="w-4 h-4" />;
}
};
const getPanelIcon = (type: string) => {
switch (type) {
case 'graph': return <LineChart className="w-4 h-4" />;
case 'stat': return <Activity className="w-4 h-4" />;
case 'gauge': return <Cpu className="w-4 h-4" />;
case 'pie': return <PieChart className="w-4 h-4" />;
case 'table': return <BarChart3 className="w-4 h-4" />;
default: return <BarChart3 className="w-4 h-4" />;
}
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 ${fullscreen ? 'fixed inset-0 z-50 bg-background p-4' : ''}`}>
<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>
{/* ===== 顶部工具栏 ===== */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Select
value={activeDashboard?.id}
onValueChange={(id) => {
const dashboard = dashboards.find(d => d.id === id);
if (dashboard) setActiveDashboard(dashboard);
}}
{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)]'
}`}
>
<SelectTrigger className="w-[250px]">
<SelectValue placeholder="选择仪表盘" />
</SelectTrigger>
<SelectContent>
{dashboards.map(d => (
<SelectItem key={d.id} value={d.id}>
<div className="flex items-center gap-2">
{getDatasourceIcon(d.datasource)}
{d.name}
{d.isDefault && <Badge variant="outline" className="text-xs">默认</Badge>}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
{activeDashboard && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock className="w-4 h-4" />
<span>更新于 {activeDashboard.updatedAt}</span>
</div>
)}
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={fetchDashboards}>
<RefreshCw className="w-4 h-4 mr-2" />
刷新
</Button>
{activeDashboard && (
<>
<Button variant="outline" size="sm" onClick={() => setAddPanelOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
添加面板
</Button>
</>
)}
<Button variant="outline" size="sm" onClick={() => setFullscreen(!fullscreen)}>
{fullscreen ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</Button>
<Button variant="outline" size="sm" onClick={() => setDatasourceOpen(true)}>
<Database className="w-4 h-4 mr-2" />
数据源
</Button>
<Button size="sm" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
新建仪表盘
{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>
{/* ===== 仪表盘内容 ===== */}
{activeDashboard ? (
<div className="space-y-4">
{/* 仪表盘信息 */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold">{activeDashboard.name}</h2>
<p className="text-sm text-muted-foreground">{activeDashboard.description}</p>
</div>
<div className="flex items-center gap-2">
<Badge variant="outline" className="gap-1">
{getDatasourceIcon(activeDashboard.datasource)}
{activeDashboard.datasource}
</Badge>
</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) => (
<Card
key={panel.id}
className={`col-span-${panel.width}`}
style={{ gridColumn: `span ${panel.width}`, minHeight: `${panel.height * 80}px` }}
>
<CardHeader className="py-3 px-4">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
{getPanelIcon(panel.type)}
{panel.title}
</CardTitle>
</div>
</CardHeader>
<CardContent className="p-4 pt-0 flex-1">
<GrafanaPanelContent panel={panel} />
</CardContent>
</Card>
))}
{activeDashboard.panels.length === 0 && (
<div className="col-span-12 py-16 text-center text-muted-foreground">
<BarChart3 className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>暂无面板,点击"添加面板"开始创建</p>
</div>
)}
</div>
</div>
) : (
<div className="py-16 text-center text-muted-foreground">
<Monitor className="w-16 h-16 mx-auto mb-4 opacity-50" />
<p>请选择一个仪表盘或创建新的仪表盘</p>
<Button className="mt-4" onClick={() => setCreateOpen(true)}>
<Plus className="w-4 h-4 mr-2" />
新建仪表盘
</Button>
</div>
)}
{/* 新建仪表盘对话框 */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>新建仪表盘</DialogTitle>
<DialogDescription>创建一个新的监控仪表盘</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>名称</Label>
<Input
value={newDashboard.name}
onChange={(e) => setNewDashboard({ ...newDashboard, name: e.target.value })}
placeholder="仪表盘名称"
/>
</div>
<div>
<Label>描述</Label>
<Input
value={newDashboard.description}
onChange={(e) => setNewDashboard({ ...newDashboard, description: e.target.value })}
placeholder="仪表盘描述"
/>
</div>
<div>
<Label>数据源类型</Label>
<Select
value={newDashboard.datasource}
onValueChange={(v) => setNewDashboard({ ...newDashboard, 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>
<Label>数据源 URL</Label>
<Input
value={newDashboard.datasourceUrl}
onChange={(e) => setNewDashboard({ ...newDashboard, datasourceUrl: e.target.value })}
placeholder="http://prometheus:9090"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
<Button onClick={handleCreateDashboard}>创建</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 添加面板对话框 */}
<Dialog open={addPanelOpen} onOpenChange={setAddPanelOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>添加面板</DialogTitle>
<DialogDescription>向当前仪表盘添加一个监控面板</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<Label>面板标题</Label>
<Input
value={newPanel.title}
onChange={(e) => setNewPanel({ ...newPanel, title: e.target.value })}
placeholder="CPU 使用率"
/>
</div>
<div>
<Label>图表类型</Label>
<Select
value={newPanel.type}
onValueChange={(v) => setNewPanel({ ...newPanel, type: v as any })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="graph">折线图</SelectItem>
<SelectItem value="stat">数值</SelectItem>
<SelectItem value="gauge">仪表盘</SelectItem>
<SelectItem value="pie">饼图</SelectItem>
<SelectItem value="table">表格</SelectItem>
<SelectItem value="text">文本</SelectItem>
</SelectContent>
</Select>
</div>
<div>
<Label>PromQL / 查询语句</Label>
<Input
value={newPanel.query}
onChange={(e) => setNewPanel({ ...newPanel, query: e.target.value })}
placeholder="rate(node_cpu_seconds_total[5m])"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setAddPanelOpen(false)}>取消</Button>
<Button onClick={handleAddPanel}>添加</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 数据源对话框 */}
<Dialog open={datasourceOpen} onOpenChange={setDatasourceOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>管理数据源</DialogTitle>
<DialogDescription>配置 Prometheus / Zabbix / SkyWalking 等外部数据源连接</DialogDescription>
</DialogHeader>
<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>
{/* Tab 内容 */}
<div className="min-h-[50vh]">
{tab === 'agent' && <AgentTab />}
{tab === 'zabbix' && <ZabbixTab />}
{tab === 'prometheus' && <PrometheusTab />}
{tab === 'dashboard' && <DashboardTab />}
</div>
</div>
);
};