snapshot: 数据源管理+大屏增强前快照
This commit is contained in:
1 parent
2bf3e7c308
commit
1726ad65ac
19 files changed
+2540
-273
No files matched your search
@@ -3,7 +3,8 @@ import {
|
||||
Monitor, Settings, Plus, Edit, Trash2, Copy, Eye, Download, RefreshCw,
|
||||
Maximize2, Minimize2, Save, X, ChevronDown, BarChart3, LineChart, PieChart,
|
||||
Activity, Cpu, Database, Server, Globe, Zap, AlertTriangle, TrendingUp, HardDrive,
|
||||
TrendingDown, Clock, Calendar, Filter, Search
|
||||
TrendingDown, Clock, Calendar, Filter, Search, ArrowUpDown, ArrowUp, ArrowDown,
|
||||
Wifi, WifiOff
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -15,15 +16,16 @@ import {
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
|
||||
} from '@/components/ui/select';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { dashboardApi } from '@/services/api';
|
||||
import { GrafanaPanelContent } from './GrafanaPanelContent';
|
||||
import { ZabbixDashboard } from './ZabbixDashboard';
|
||||
import { PrometheusDashboard } from './PrometheusDashboard';
|
||||
import { http } from '@/services/http';
|
||||
import { asList } from '@/components/agent/agentUtils';
|
||||
|
||||
interface Dashboard {
|
||||
id: string;
|
||||
@@ -75,6 +77,27 @@ interface Datasource {
|
||||
status: 'connected' | 'disconnected' | 'error';
|
||||
}
|
||||
|
||||
// Agent 主机完整信息(对齐后端 AgentLatestResponse)
|
||||
interface AgentHost {
|
||||
hostname: string;
|
||||
ip: string;
|
||||
cpu_usage: number;
|
||||
cpu_cores: number;
|
||||
mem_usage: number;
|
||||
mem_total: number;
|
||||
mem_used: number;
|
||||
disk_usage: number;
|
||||
disk_info: string;
|
||||
net_info: string;
|
||||
report_time: string;
|
||||
agent_version: string;
|
||||
agent_status: string;
|
||||
os: string;
|
||||
region: string;
|
||||
}
|
||||
|
||||
type SortField = 'hostname' | 'agent_version' | 'agent_status' | 'cpu_usage' | 'mem_usage' | 'disk_usage' | 'region' | 'ip';
|
||||
|
||||
const GrafanaDashboard: React.FC = () => {
|
||||
const _confirm = useConfirm();
|
||||
const [dashboards, setDashboards] = useState<Dashboard[]>([]);
|
||||
@@ -108,38 +131,79 @@ const GrafanaDashboard: React.FC = () => {
|
||||
url: ''
|
||||
});
|
||||
|
||||
// Agent 主机监控 — 默认面板
|
||||
const [hostTop, setHostTop] = useState<any[]>([]);
|
||||
// ==================== Agent 主机监控 ====================
|
||||
const [hosts, setHosts] = useState<AgentHost[]>([]);
|
||||
const [hostsLoading, setHostsLoading] = useState(true);
|
||||
const [sortField, setSortField] = useState<SortField>('cpu_usage');
|
||||
const [sortDir, setSortDir] = useState<'asc' | 'desc'>('desc');
|
||||
|
||||
const fetchHosts = async () => {
|
||||
try {
|
||||
const data = await http.get('/agent/latest');
|
||||
setHosts(asList(data));
|
||||
} catch { /* 后端未就绪时静默 */ }
|
||||
finally { setHostsLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const [cpu, mem, disk] = await Promise.all([
|
||||
http.get('/metrics/top?metric=cpu&limit=10'),
|
||||
http.get('/metrics/top?metric=memory&limit=10'),
|
||||
http.get('/metrics/top?metric=disk&limit=10'),
|
||||
]);
|
||||
setHostTop([
|
||||
{ name: 'CPU', data: Array.isArray(cpu) ? cpu : [], key: 'cpu_usage', unit: '%', icon: 'cpu' },
|
||||
{ name: '内存', data: Array.isArray(mem) ? mem : [], key: 'memory_usage', unit: '%', icon: 'mem' },
|
||||
{ name: '磁盘', data: Array.isArray(disk) ? disk : [], key: 'disk_usage', unit: '%', icon: 'disk' },
|
||||
]);
|
||||
} catch { /* silent */ }
|
||||
})();
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const cpu = await http.get('/metrics/top?metric=cpu&limit=10');
|
||||
const mem = await http.get('/metrics/top?metric=memory&limit=10');
|
||||
const disk = await http.get('/metrics/top?metric=disk&limit=10');
|
||||
setHostTop([
|
||||
{ name: 'CPU', data: Array.isArray(cpu) ? cpu : [], key: 'cpu_usage', unit: '%', icon: 'cpu' },
|
||||
{ name: '内存', data: Array.isArray(mem) ? mem : [], key: 'memory_usage', unit: '%', icon: 'mem' },
|
||||
{ name: '磁盘', data: Array.isArray(disk) ? disk : [], key: 'disk_usage', unit: '%', icon: 'disk' },
|
||||
]);
|
||||
} catch { /* silent */ }
|
||||
}, 30000);
|
||||
fetchHosts();
|
||||
const timer = setInterval(fetchHosts, 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
// 排序
|
||||
const toggleSort = (field: SortField) => {
|
||||
if (sortField === field) {
|
||||
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortField(field);
|
||||
setSortDir('desc');
|
||||
}
|
||||
};
|
||||
|
||||
const sortedHosts = [...hosts].sort((a, b) => {
|
||||
let va: any, vb: any;
|
||||
if (sortField === 'agent_status') {
|
||||
va = a.agent_status || '';
|
||||
vb = b.agent_status || '';
|
||||
} else {
|
||||
va = (a as any)[sortField] ?? '';
|
||||
vb = (b as any)[sortField] ?? '';
|
||||
}
|
||||
if (typeof va === 'number' && typeof vb === 'number') {
|
||||
return sortDir === 'asc' ? va - vb : vb - va;
|
||||
}
|
||||
return sortDir === 'asc'
|
||||
? String(va).localeCompare(String(vb))
|
||||
: String(vb).localeCompare(String(va));
|
||||
});
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortField !== field) return <ArrowUpDown className="w-3 h-3 text-slate-500" />;
|
||||
return sortDir === 'asc' ? <ArrowUp className="w-3 h-3 text-emerald-400" /> : <ArrowDown className="w-3 h-3 text-emerald-400" />;
|
||||
};
|
||||
|
||||
const usageBar = (val: number, label: string) => {
|
||||
const pct = Math.min(val, 100);
|
||||
const isWarn = val >= 80;
|
||||
const barColor = isWarn ? 'bg-orange-500' : 'bg-emerald-500';
|
||||
const textColor = isWarn ? 'text-orange-400' : '';
|
||||
return (
|
||||
<div className="flex items-center gap-2 min-w-[90px]">
|
||||
<div className="flex-1 h-2.5 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className={`w-14 text-right text-xs font-mono tabular-nums ${textColor}`}>
|
||||
{label}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// ==================== 仪表盘 CRUD ====================
|
||||
useEffect(() => {
|
||||
fetchDashboards();
|
||||
fetchDatasources();
|
||||
@@ -271,78 +335,10 @@ const GrafanaDashboard: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getDatasourceStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'connected':
|
||||
return <Badge variant="default" className="bg-green-500">已连接</Badge>;
|
||||
case 'disconnected':
|
||||
return <Badge variant="secondary">已断开</Badge>;
|
||||
case 'error':
|
||||
return <Badge variant="destructive">错误</Badge>;
|
||||
default:
|
||||
return <Badge variant="outline">未知</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`space-y-4 ${fullscreen ? 'fixed inset-0 z-50 bg-background p-4' : ''}`}>
|
||||
{/* ===== 默认:Agent 主机监控面板 ===== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-emerald-500/20">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-emerald-400" />
|
||||
<CardTitle className="text-base text-[var(--dg-text-primary)]">Agent 主机监控</CardTitle>
|
||||
<Badge variant="outline" className="text-xs bg-emerald-500/10 text-emerald-400 border-emerald-500/30">默认</Badge>
|
||||
<span className="text-xs text-[var(--dg-text-muted)] ml-auto">每 30s 刷新</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
{hostTop.length === 0 ? (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">
|
||||
<Server className="h-8 w-8 mx-auto mb-2 text-slate-600" />
|
||||
<p className="text-sm">暂无 Agent 上报数据</p>
|
||||
<p className="text-xs mt-1">请确保至少一台主机安装了 AIOps Agent 并正常上报</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
{hostTop.map((group: any) => (
|
||||
<div key={group.name} className="bg-[var(--dg-bg-primary)] rounded-lg p-3 border border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
{group.icon === 'cpu' && <Cpu className="h-4 w-4 text-red-400" />}
|
||||
{group.icon === 'mem' && <Server className="h-4 w-4 text-amber-400" />}
|
||||
{group.icon === 'disk' && <HardDrive className="h-4 w-4 text-orange-400" />}
|
||||
<span className="text-xs font-medium text-[var(--dg-text-primary)]">{group.name} Top 10</span>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{group.data.slice(0, 10).map((h: any, i: number) => {
|
||||
const val = Number(h[group.key] || 0);
|
||||
const barPct = Math.min(val, 100);
|
||||
const barColor = val > 85 ? 'bg-red-500' : val > 60 ? 'bg-amber-500' : 'bg-emerald-500';
|
||||
const dotColor = i < 3 ? (i === 0 ? 'text-red-400' : i === 1 ? 'text-amber-400' : 'text-orange-400') : 'text-slate-500';
|
||||
return (
|
||||
<div key={i} className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className={`w-4 text-right font-mono font-bold ${dotColor}`}>
|
||||
{i === 0 ? '🥇' : i === 1 ? '🥈' : i === 2 ? '🥉' : `${i + 1}`}
|
||||
</span>
|
||||
<span className="w-24 truncate text-[var(--dg-text-secondary)]" title={h.hostname}>
|
||||
{h.hostname}
|
||||
</span>
|
||||
<div className="flex-1 h-1 rounded-full bg-[var(--dg-bg-tertiary)] overflow-hidden">
|
||||
<div className={`h-full rounded-full ${barColor}`} style={{ width: `${barPct}%` }} />
|
||||
</div>
|
||||
<span className="w-12 text-right font-mono text-[var(--dg-text-primary)]">{val.toFixed(1)}{group.unit}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 顶部工具栏 */}
|
||||
{/* ===== 顶部工具栏 ===== */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Select
|
||||
@@ -407,7 +403,7 @@ const GrafanaDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 仪表盘内容 */}
|
||||
{/* ===== 仪表盘内容 ===== */}
|
||||
{activeDashboard ? (
|
||||
<div className="space-y-4">
|
||||
{/* 仪表盘信息 */}
|
||||
@@ -424,6 +420,110 @@ const GrafanaDashboard: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ===== Agent 主机监控 — 默认表格面板 ===== */}
|
||||
<Card className="bg-[var(--dg-bg-secondary)] border-emerald-500/20">
|
||||
<CardHeader className="py-3 px-4 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Server className="h-4 w-4 text-emerald-400" />
|
||||
<CardTitle className="text-sm font-medium text-[var(--dg-text-primary)]">Agent 主机监控</CardTitle>
|
||||
<Badge variant="outline" className="text-[10px] bg-emerald-500/10 text-emerald-400 border-emerald-500/30">默认</Badge>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)] ml-auto">每 30s 刷新</span>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="px-4 pb-3 pt-0">
|
||||
{hostsLoading ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-muted)]">
|
||||
<Activity className="h-6 w-6 mx-auto mb-2 animate-spin text-slate-600" />
|
||||
<p className="text-xs">加载中…</p>
|
||||
</div>
|
||||
) : hosts.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-muted)]">
|
||||
<Server className="h-8 w-8 mx-auto mb-2 text-slate-600" />
|
||||
<p className="text-sm">暂无 Agent 上报数据</p>
|
||||
<p className="text-xs mt-1">请确保至少一台主机安装了 AIOps Agent 并正常上报</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-[var(--dg-border)]">
|
||||
<table className="w-full text-xs">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
{[
|
||||
{ field: 'hostname', label: '主机名' },
|
||||
{ field: 'agent_version', label: 'Agent 版本' },
|
||||
{ field: 'agent_status', label: '状态' },
|
||||
{ field: 'cpu_usage', label: 'CPU 使用率' },
|
||||
{ field: 'mem_usage', label: '内存使用率' },
|
||||
{ field: 'disk_usage' as SortField, label: '磁盘使用率' },
|
||||
{ field: 'region', label: '所在区域' },
|
||||
{ field: 'ip', label: 'IP 地址' },
|
||||
].map(({ field, label }) => (
|
||||
<th
|
||||
key={field}
|
||||
className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] cursor-pointer hover:text-[var(--dg-text-primary)] select-none whitespace-nowrap"
|
||||
onClick={() => toggleSort(field as SortField)}
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
{label}
|
||||
<SortIcon field={field as SortField} />
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sortedHosts.map((host, i) => {
|
||||
const isOnline = host.agent_status === 'online';
|
||||
return (
|
||||
<tr
|
||||
key={host.hostname + i}
|
||||
className="border-b border-[var(--dg-border)] last:border-0 hover:bg-[var(--dg-bg-tertiary)]/50 transition-colors"
|
||||
>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
<span className="font-medium text-[var(--dg-text-primary)]">{host.hostname}</span>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--dg-text-secondary)] font-mono">
|
||||
{host.agent_version || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
<Badge
|
||||
variant={isOnline ? 'default' : 'secondary'}
|
||||
className={`text-[10px] ${isOnline ? 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30' : 'bg-slate-500/10 text-slate-400 border-slate-500/20'}`}
|
||||
>
|
||||
{isOnline ? <Wifi className="w-3 h-3 mr-1" /> : <WifiOff className="w-3 h-3 mr-1" />}
|
||||
{isOnline ? '在线' : '离线'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
{usageBar(Number(host.cpu_usage || 0), `${Number(host.cpu_usage || 0).toFixed(1)}%`)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
{usageBar(Number(host.mem_usage || 0), `${Number(host.mem_usage || 0).toFixed(1)}%`)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap">
|
||||
{usageBar(Number(host.disk_usage || 0), `${Number(host.disk_usage || 0).toFixed(1)}%`)}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap text-[var(--dg-text-secondary)]">
|
||||
{host.region || '—'}
|
||||
</td>
|
||||
<td className="px-3 py-2.5 whitespace-nowrap font-mono text-[var(--dg-text-secondary)]">
|
||||
{host.ip || '—'}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Zabbix 数据源面板 */}
|
||||
<ZabbixDashboard />
|
||||
|
||||
{/* Prometheus 数据源面板 */}
|
||||
<PrometheusDashboard />
|
||||
|
||||
{/* 面板网格 */}
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
{activeDashboard.panels.map((panel) => (
|
||||
@@ -438,7 +538,6 @@ const GrafanaDashboard: React.FC = () => {
|
||||
{getPanelIcon(panel.type)}
|
||||
{panel.title}
|
||||
</CardTitle>
|
||||
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="p-4 pt-0 flex-1">
|
||||
@@ -508,20 +607,17 @@ const GrafanaDashboard: React.FC = () => {
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>数据源地址</Label>
|
||||
<Label>数据源 URL</Label>
|
||||
<Input
|
||||
value={newDashboard.datasourceUrl}
|
||||
onChange={(e) => setNewDashboard({ ...newDashboard, datasourceUrl: e.target.value })}
|
||||
placeholder="http://localhost:9090"
|
||||
placeholder="http://prometheus:9090"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreateDashboard}>
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
创建
|
||||
</Button>
|
||||
<Button onClick={handleCreateDashboard}>创建</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -531,19 +627,19 @@ const GrafanaDashboard: React.FC = () => {
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加面板</DialogTitle>
|
||||
<DialogDescription>添加一个新的监控面板</DialogDescription>
|
||||
<DialogDescription>向当前仪表盘添加一个监控面板</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>标题</Label>
|
||||
<Label>面板标题</Label>
|
||||
<Input
|
||||
value={newPanel.title}
|
||||
onChange={(e) => setNewPanel({ ...newPanel, title: e.target.value })}
|
||||
placeholder="面板标题"
|
||||
placeholder="CPU 使用率"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>类型</Label>
|
||||
<Label>图表类型</Label>
|
||||
<Select
|
||||
value={newPanel.type}
|
||||
onValueChange={(v) => setNewPanel({ ...newPanel, type: v as any })}
|
||||
@@ -553,149 +649,101 @@ const GrafanaDashboard: React.FC = () => {
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="graph">折线图</SelectItem>
|
||||
<SelectItem value="stat">统计值</SelectItem>
|
||||
<SelectItem value="stat">数值</SelectItem>
|
||||
<SelectItem value="gauge">仪表盘</SelectItem>
|
||||
<SelectItem value="pie">饼图</SelectItem>
|
||||
<SelectItem value="table">表格</SelectItem>
|
||||
<SelectItem value="heatmap">热力图</SelectItem>
|
||||
<SelectItem value="text">文本</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>查询语句</Label>
|
||||
<textarea
|
||||
<Label>PromQL / 查询语句</Label>
|
||||
<Input
|
||||
value={newPanel.query}
|
||||
onChange={(e) => setNewPanel({ ...newPanel, query: e.target.value })}
|
||||
placeholder={newPanel.type === 'graph' ? 'sum(rate(http_requests_total[5m]))' : 'up'}
|
||||
className="w-full min-h-[100px] p-2 border rounded-md font-mono text-sm"
|
||||
placeholder="rate(node_cpu_seconds_total[5m])"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>宽度 (列)</Label>
|
||||
<Select
|
||||
value={newPanel.width.toString()}
|
||||
onValueChange={(v) => setNewPanel({ ...newPanel, width: parseInt(v) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">3列 (25%)</SelectItem>
|
||||
<SelectItem value="4">4列 (33%)</SelectItem>
|
||||
<SelectItem value="6">6列 (50%)</SelectItem>
|
||||
<SelectItem value="8">8列 (66%)</SelectItem>
|
||||
<SelectItem value="12">12列 (100%)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>高度 (行)</Label>
|
||||
<Select
|
||||
value={newPanel.height.toString()}
|
||||
onValueChange={(v) => setNewPanel({ ...newPanel, height: parseInt(v) })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="3">3行 (短)</SelectItem>
|
||||
<SelectItem value="4">4行 (中)</SelectItem>
|
||||
<SelectItem value="6">6行 (长)</SelectItem>
|
||||
<SelectItem value="8">8行 (超长)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setAddPanelOpen(false)}>取消</Button>
|
||||
<Button onClick={handleAddPanel}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加
|
||||
</Button>
|
||||
<Button onClick={handleAddPanel}>添加</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 数据源管理对话框 */}
|
||||
{/* 数据源对话框 */}
|
||||
<Dialog open={datasourceOpen} onOpenChange={setDatasourceOpen}>
|
||||
<DialogContent className="max-w-3xl">
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>数据源管理</DialogTitle>
|
||||
<DialogDescription>管理监控数据源配置</DialogDescription>
|
||||
<DialogTitle>管理数据源</DialogTitle>
|
||||
<DialogDescription>配置 Prometheus / Zabbix / SkyWalking 等外部数据源连接</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="list">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="list">数据源列表</TabsTrigger>
|
||||
<TabsTrigger value="add">添加数据源</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="list" className="space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{datasources.map((ds) => (
|
||||
<Card key={ds.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{getDatasourceIcon(ds.type)}
|
||||
<div>
|
||||
<p className="font-medium">{ds.name}</p>
|
||||
<p className="text-sm text-muted-foreground">{ds.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{getDatasourceStatusBadge(ds.status)}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="add" className="space-y-4">
|
||||
<div>
|
||||
<Label>名称</Label>
|
||||
<Input
|
||||
value={newDatasource.name}
|
||||
onChange={(e) => setNewDatasource({ ...newDatasource, name: e.target.value })}
|
||||
placeholder="数据源名称"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>类型</Label>
|
||||
<Select
|
||||
value={newDatasource.type}
|
||||
onValueChange={(v) => setNewDatasource({ ...newDatasource, type: v as any })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="prometheus">Prometheus</SelectItem>
|
||||
<SelectItem value="zabbix">Zabbix</SelectItem>
|
||||
<SelectItem value="skywalking">SkyWalking</SelectItem>
|
||||
<SelectItem value="influxdb">InfluxDB</SelectItem>
|
||||
<SelectItem value="elasticsearch">Elasticsearch</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>地址</Label>
|
||||
<Input
|
||||
value={newDatasource.url}
|
||||
onChange={(e) => setNewDatasource({ ...newDatasource, url: e.target.value })}
|
||||
placeholder="http://localhost:9090"
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={handleAddDatasource} className="w-full">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
添加数据源
|
||||
</Button>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<div className="space-y-3">
|
||||
{datasources.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">暂无数据源配置</p>
|
||||
) : (
|
||||
datasources.map(ds => (
|
||||
<div key={ds.id} className="flex items-center justify-between p-3 rounded-lg bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2">
|
||||
{getDatasourceIcon(ds.type)}
|
||||
<div>
|
||||
<p className="text-sm font-medium">{ds.name}</p>
|
||||
<p className="text-xs text-muted-foreground">{ds.url}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
variant={ds.status === 'connected' ? 'default' : 'secondary'}
|
||||
className={ds.status === 'connected' ? 'bg-green-500' : ds.status === 'error' ? 'bg-red-500' : ''}
|
||||
>
|
||||
{ds.status === 'connected' ? '已连接' : ds.status === 'error' ? '错误' : '已断开'}
|
||||
</Badge>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Separator className="my-2" />
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<Label>数据源名称</Label>
|
||||
<Input
|
||||
value={newDatasource.name}
|
||||
onChange={(e) => setNewDatasource({ ...newDatasource, name: e.target.value })}
|
||||
placeholder="My Prometheus"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>类型</Label>
|
||||
<Select
|
||||
value={newDatasource.type}
|
||||
onValueChange={(v) => setNewDatasource({ ...newDatasource, type: v as any })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="prometheus">Prometheus</SelectItem>
|
||||
<SelectItem value="zabbix">Zabbix</SelectItem>
|
||||
<SelectItem value="skywalking">SkyWalking</SelectItem>
|
||||
<SelectItem value="influxdb">InfluxDB</SelectItem>
|
||||
<SelectItem value="elasticsearch">Elasticsearch</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div>
|
||||
<Label>URL</Label>
|
||||
<Input
|
||||
value={newDatasource.url}
|
||||
onChange={(e) => setNewDatasource({ ...newDatasource, url: e.target.value })}
|
||||
placeholder="http://prometheus:9090"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDatasourceOpen(false)}>关闭</Button>
|
||||
<Button onClick={handleAddDatasource}>添加数据源</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user