feat: Zabbix 数据源管理 — 完整 CRUD + 双 Tab 数据源页面

后端:
- models/zabbix_datasource.go: Zabbix 数据源 model (15字段, 参照 Grafana)
- handlers/zabbix_datasource.go: CRUD + 测试连接 (调用 Zabbix API Auth)
- routes.go: /zabbix-datasources 路由组
- database.go: AutoMigrate

前端:
- api.ts: zabbixDatasourceApi
- ZabbixDatasourceManager.tsx: 表单 (URL/用户名/密码/Trends设置/DB直连)
- DatasourcePage.tsx: Prometheus + Zabbix 双 Tab 包装
- App.tsx: /datasources 路由更新
This commit is contained in:
attacker committed 2026-06-18 14:14:40 +00:00
1 parent d2201fc8c0
commit c51d0b8b8b
8 files changed
+761 -2

No files matched your search

@@ -0,0 +1,400 @@
// Zabbix 数据源管理 — 参照 Grafana Zabbix 数据源配置界面 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import {
Database, Plus, Trash2, Star, RefreshCw, Plug,
Shield, Globe, Settings, Key, ChevronDown, ChevronUp,
Edit, Clock, CheckCircle2, XCircle
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
} from '@/components/ui/dialog';
import { Separator } from '@/components/ui/separator';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { zabbixDatasourceApi } from '@/services/api';
interface ZabbixDS {
id: number;
name: string;
url: string;
username: string;
password: string;
trends_after: number;
trends_range: number;
trends_cache_ttl: number;
trends_timeout: number;
direct_db: boolean;
disable_acknowledges: boolean;
disable_data_alignment: boolean;
is_default: boolean;
status: number;
created_at: string;
}
const defaultForm = {
name: '', url: '', username: '', password: '',
trends_after: 0, trends_range: 0, trends_cache_ttl: 3600, trends_timeout: 30,
direct_db: false, disable_acknowledges: false, disable_data_alignment: false,
is_default: false,
};
export default function ZabbixDatasourceManager() {
const _confirm = useConfirm();
const [sources, setSources] = useState<ZabbixDS[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState({ ...defaultForm });
const [showAuth, setShowAuth] = useState(false);
const [showTrends, setShowTrends] = useState(false);
const [showOther, setShowOther] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string } | null>(null);
const fetchList = useCallback(async () => {
try {
const res: any = await zabbixDatasourceApi.list();
setSources(res?.data || res || []);
} catch {
toast.error('获取数据源列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchList(); }, [fetchList]);
const openCreate = () => {
setEditingId(null);
setForm({ ...defaultForm });
setTestResult(null);
setShowAuth(false); setShowTrends(false); setShowOther(false);
setDialogOpen(true);
};
const openEdit = (ds: ZabbixDS) => {
setEditingId(ds.id);
setForm({
name: ds.name, url: ds.url,
username: ds.username || '', password: ds.password || '',
trends_after: ds.trends_after || 0, trends_range: ds.trends_range || 0,
trends_cache_ttl: ds.trends_cache_ttl || 3600, trends_timeout: ds.trends_timeout || 30,
direct_db: ds.direct_db || false,
disable_acknowledges: ds.disable_acknowledges || false,
disable_data_alignment: ds.disable_data_alignment || false,
is_default: ds.is_default || false,
});
setTestResult(null);
setDialogOpen(true);
};
const updateForm = (field: string, value: any) => {
setForm(prev => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
if (!form.name.trim()) { toast.error('请输入数据源名称'); return; }
if (!form.url.trim()) { toast.error('请输入 Zabbix API URL'); return; }
if (!form.username.trim()) { toast.error('请输入用户名'); return; }
if (!form.password.trim()) { toast.error('请输入密码'); return; }
try {
if (editingId) {
await zabbixDatasourceApi.update(editingId, form);
toast.success('数据源已更新');
} else {
await zabbixDatasourceApi.create(form);
toast.success('数据源已创建');
}
setDialogOpen(false);
fetchList();
} catch (err: any) {
toast.error(err?.message || '操作失败');
}
};
const handleDelete = async (ds: ZabbixDS) => {
const ok = await _confirm({
title: '删除数据源',
description: `确定删除「${ds.name}」?`,
variant: 'destructive',
});
if (!ok) return;
try {
await zabbixDatasourceApi.delete(ds.id);
toast.success('已删除');
fetchList();
} catch { toast.error('删除失败'); }
};
const handleSetDefault = async (ds: ZabbixDS) => {
try {
await zabbixDatasourceApi.setDefault(ds.id);
toast.success(`已将「${ds.name}」设为默认`);
fetchList();
} catch { toast.error('设置失败'); }
};
const handleTest = async () => {
if (!form.url.trim() || !form.username.trim()) {
toast.error('请填写 URL 和用户名');
return;
}
setTesting(true);
setTestResult(null);
try {
const res: any = await zabbixDatasourceApi.testConnection({
url: form.url.trim(),
username: form.username.trim(),
password: form.password || '',
});
const data = res?.data || res;
setTestResult(data);
} catch (err: any) {
setTestResult({ success: false, message: err?.message || '测试失败' });
} finally {
setTesting(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-96">
<RefreshCw className="animate-spin w-8 h-8 text-[var(--dg-text-secondary)]" />
</div>
);
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)] flex items-center gap-2">
<img
src="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/zabbix.png"
className="w-5 h-5 rounded"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
Zabbix 数据源
</h2>
<p className="text-xs text-[var(--dg-text-muted)]">配置 Zabbix API 连接,获取主机监控数据</p>
</div>
<Button onClick={openCreate} size="sm">
<Plus className="w-4 h-4 mr-1" />添加
</Button>
</div>
{sources.length === 0 ? (
<Card className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<CardContent className="py-12 text-center text-[var(--dg-text-muted)]">
<Database className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无 Zabbix 数据源</p>
<Button size="sm" className="mt-4" onClick={openCreate}>
<Plus className="w-4 h-4 mr-1" />添加数据源
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-3">
{sources.map(ds => (
<Card key={ds.id} className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] hover:border-orange-500/30 transition-colors">
<CardContent className="p-4">
<div className="flex items-start justify-between">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<div className={`w-2 h-2 rounded-full ${ds.status === 1 ? 'bg-emerald-400' : 'bg-slate-500'}`} />
<span className="font-medium text-[var(--dg-text-primary)]">{ds.name}</span>
{ds.is_default && <Badge className="bg-amber-500/20 text-amber-400 text-[10px]"><Star className="w-3 h-3 mr-0.5" />默认</Badge>}
{ds.status === 1
? <Badge className="bg-emerald-500/20 text-emerald-400 text-[10px]">启用</Badge>
: <Badge className="bg-slate-500/20 text-slate-400 text-[10px]">禁用</Badge>}
</div>
<div className="text-xs text-[var(--dg-text-muted)] space-x-3">
<code className="text-orange-300">{ds.url}</code>
<span>用户: {ds.username}</span>
{ds.direct_db && <Badge variant="outline" className="text-[10px]">直连DB</Badge>}
</div>
</div>
<div className="flex items-center gap-1">
{!ds.is_default && (
<Button variant="ghost" size="sm" className="h-7 text-xs text-amber-400" onClick={() => handleSetDefault(ds)}>
<Star className="w-3 h-3 mr-0.5" />默认
</Button>
)}
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => openEdit(ds)}>
<Edit className="w-3 h-3 mr-0.5" />编辑
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs text-red-400" onClick={() => handleDelete(ds)}>
<Trash2 className="w-3 h-3" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* 编辑 Dialog — 参照 Grafana Zabbix 数据源配置 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-xl max-h-[85vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
<Database className="h-5 w-5 text-orange-400" />
{editingId ? '编辑 Zabbix 数据源' : '新建 Zabbix 数据源'}
</DialogTitle>
<DialogDescription>
配置 Zabbix API 连接,用于主机监控与告警数据获取
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{/* Name + Default */}
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs text-[var(--dg-text-secondary)]">名称 *</Label>
<Input className="mt-1 h-9 text-sm bg-[var(--dg-bg-primary)]" placeholder="如:生产 Zabbix"
value={form.name} onChange={e => updateForm('name', e.target.value)} />
</div>
<div className="flex items-end pb-1">
<div className="flex items-center gap-2">
<Switch checked={form.is_default} onCheckedChange={v => updateForm('is_default', v)} />
<Label className="text-xs cursor-pointer">设为默认</Label>
</div>
</div>
</div>
{/* HTTP + Auth */}
<div className="border border-[var(--dg-border)] rounded-lg p-3 space-y-3">
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-primary)]">
<Globe className="w-4 h-4" /> HTTP
</div>
<div>
<Label className="text-xs text-[var(--dg-text-secondary)]">URL *</Label>
<Input className="mt-1 h-9 text-sm font-mono bg-[var(--dg-bg-primary)]"
placeholder="http://zabbix.example.com/api_jsonrpc.php"
value={form.url} onChange={e => updateForm('url', e.target.value)} />
</div>
</div>
{/* API Details — 折叠 */}
<div className="border border-[var(--dg-border)] rounded-lg overflow-hidden">
<button className="w-full flex items-center justify-between px-3 py-2.5 text-sm hover:bg-[var(--dg-bg-tertiary)]"
onClick={() => setShowAuth(!showAuth)}>
<div className="flex items-center gap-2">
<Shield className="w-4 h-4" />
<span>Zabbix API 认证</span>
</div>
{showAuth ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showAuth && (
<div className="px-3 pb-3 space-y-3">
<Input className="h-9 text-sm bg-[var(--dg-bg-primary)]" placeholder="用户名 *"
value={form.username} onChange={e => updateForm('username', e.target.value)} />
<Input className="h-9 text-sm bg-[var(--dg-bg-primary)]" type="password" placeholder="密码 *"
value={form.password} onChange={e => updateForm('password', e.target.value)} />
</div>
)}
</div>
{/* Trends — 折叠 */}
<div className="border border-[var(--dg-border)] rounded-lg overflow-hidden">
<button className="w-full flex items-center justify-between px-3 py-2.5 text-sm hover:bg-[var(--dg-bg-tertiary)]"
onClick={() => setShowTrends(!showTrends)}>
<div className="flex items-center gap-2">
<Clock className="w-4 h-4" />
<span>Trends 设置</span>
</div>
{showTrends ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showTrends && (
<div className="px-3 pb-3 space-y-3">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs text-[var(--dg-text-secondary)]">After (天)</Label>
<Input className="mt-1 h-9 text-sm bg-[var(--dg-bg-primary)]" type="number" min={0}
value={form.trends_after} onChange={e => updateForm('trends_after', parseInt(e.target.value) || 0)} />
</div>
<div>
<Label className="text-xs text-[var(--dg-text-secondary)]">Range (秒)</Label>
<Input className="mt-1 h-9 text-sm bg-[var(--dg-bg-primary)]" type="number" min={0}
value={form.trends_range} onChange={e => updateForm('trends_range', parseInt(e.target.value) || 0)} />
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-xs text-[var(--dg-text-secondary)]">Cache TTL (秒)</Label>
<Input className="mt-1 h-9 text-sm bg-[var(--dg-bg-primary)]" type="number" min={0}
value={form.trends_cache_ttl} onChange={e => updateForm('trends_cache_ttl', parseInt(e.target.value) || 3600)} />
</div>
<div>
<Label className="text-xs text-[var(--dg-text-secondary)]">Timeout (秒)</Label>
<Input className="mt-1 h-9 text-sm bg-[var(--dg-bg-primary)]" type="number" min={1}
value={form.trends_timeout} onChange={e => updateForm('trends_timeout', parseInt(e.target.value) || 30)} />
</div>
</div>
</div>
)}
</div>
{/* 其他设置 — 折叠 */}
<div className="border border-[var(--dg-border)] rounded-lg overflow-hidden">
<button className="w-full flex items-center justify-between px-3 py-2.5 text-sm hover:bg-[var(--dg-bg-tertiary)]"
onClick={() => setShowOther(!showOther)}>
<div className="flex items-center gap-2">
<Settings className="w-4 h-4" />
<span>其他设置</span>
</div>
{showOther ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showOther && (
<div className="px-3 pb-3 space-y-3">
<div className="flex items-center justify-between">
<Label className="text-xs">Direct DB Connection</Label>
<Switch checked={form.direct_db} onCheckedChange={v => updateForm('direct_db', v)} />
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">只读用户禁用确认</Label>
<Switch checked={form.disable_acknowledges} onCheckedChange={v => updateForm('disable_acknowledges', v)} />
</div>
<div className="flex items-center justify-between">
<Label className="text-xs">禁用数据对齐</Label>
<Switch checked={form.disable_data_alignment} onCheckedChange={v => updateForm('disable_data_alignment', v)} />
</div>
</div>
)}
</div>
{/* 测试结果 */}
{testResult && (
<div className={`flex items-center gap-2 p-2.5 rounded text-sm ${
testResult.success ? 'bg-emerald-500/10 text-emerald-400' : 'bg-red-500/10 text-red-400'
}`}>
{testResult.success ? <CheckCircle2 className="h-4 w-4" /> : <XCircle className="h-4 w-4" />}
{testResult.message}
</div>
)}
</div>
<DialogFooter className="gap-2">
<Button variant="outline" size="sm" onClick={handleTest} disabled={testing}>
<Plug className={`w-4 h-4 mr-1 ${testing ? 'animate-pulse' : ''}`} />
测试连接
</Button>
<div className="flex-1" />
<Button variant="ghost" size="sm" onClick={() => setDialogOpen(false)}>取消</Button>
<Button size="sm" onClick={handleSave}>保存</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}