使用 i18n.t() 全局方式(避免 hook 定位问题): - 288 目标文件中 135 个成功转换 - 2228 处中文替换为 i18n.t() 调用 - 1006 个新翻译 key 添加到 zh-CN.json + en-US.json - 词典总量: 3554 keys (22 namespaces) - tsc 零错误 + Vite 构建成功 - 6 个含代码示例/数据字符串的文件安全回退
416 lines
18 KiB
TypeScript
416 lines
18 KiB
TypeScript
// Zabbix 数据源管理 — 参照 Grafana Zabbix 数据源配置界面 // Logan.Li
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
|
|
Database,
|
|
Plus,
|
|
Trash2,
|
|
Star,
|
|
RefreshCw,
|
|
Plug,
|
|
Shield,
|
|
Globe,
|
|
Settings,
|
|
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} 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 { toast } from 'sonner';
|
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
|
import { zabbixDatasourceApi } from '@/api';
|
|
import type { JsonData } from '@/types/common';
|
|
import i18n from '@/i18n';
|
|
|
|
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: JsonData = await zabbixDatasourceApi.list();
|
|
setSources(res?.data || res || []);
|
|
} catch {
|
|
toast.error(i18n.t('monitor.zabbixdatasourc.s45f81749'));
|
|
} 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: string | number | boolean) => {
|
|
setForm(prev => ({ ...prev, [field]: value }));
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!form.name.trim()) { toast.error(i18n.t('monitor.promDs.nameRequired')); return; }
|
|
if (!form.url.trim()) { toast.error(i18n.t('monitor.zabbixdatasourc.s2ba385d6')); return; }
|
|
if (!form.username.trim()) { toast.error(i18n.t('pipeline.usernameRequired')); return; }
|
|
if (!form.password.trim()) { toast.error(i18n.t('login.passwordRequired')); return; }
|
|
|
|
try {
|
|
if (editingId) {
|
|
await zabbixDatasourceApi.update(editingId, form);
|
|
toast.success(i18n.t('monitor.zabbixdatasourc.s8dfdd2f4'));
|
|
} else {
|
|
await zabbixDatasourceApi.create(form);
|
|
toast.success(i18n.t('monitor.zabbixdatasourc.sa6def01f'));
|
|
}
|
|
setDialogOpen(false);
|
|
fetchList();
|
|
} catch (err) {
|
|
toast.error(err?.message || i18n.t('cloud.operationFailed'));
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (ds: ZabbixDS) => {
|
|
const ok = await _confirm({
|
|
title: i18n.t('monitor.deleteDatasource'),
|
|
description: `确定删除「${ds.name}」?`,
|
|
variant: 'destructive',
|
|
});
|
|
if (!ok) return;
|
|
try {
|
|
await zabbixDatasourceApi.delete(ds.id);
|
|
toast.success(i18n.t('monitor.silenceRules.deleted'));
|
|
fetchList();
|
|
} catch { toast.error(i18n.t('kubernetes.msg.deleteFailed2')); }
|
|
};
|
|
|
|
const handleSetDefault = async (ds: ZabbixDS) => {
|
|
try {
|
|
await zabbixDatasourceApi.setDefault(ds.id);
|
|
toast.success(`已将「${ds.name}」设为默认`);
|
|
fetchList();
|
|
} catch { toast.error(i18n.t('monitor.zabbixdatasourc.s9f96034c')); }
|
|
};
|
|
|
|
const handleTest = async () => {
|
|
if (!form.url.trim() || !form.username.trim()) {
|
|
toast.error(i18n.t('monitor.zabbixdatasourc.s1d3562b7'));
|
|
return;
|
|
}
|
|
setTesting(true);
|
|
setTestResult(null);
|
|
try {
|
|
const res: JsonData = await zabbixDatasourceApi.testConnection({
|
|
url: form.url.trim(),
|
|
username: form.username.trim(),
|
|
password: form.password || '',
|
|
});
|
|
const data = res?.data || res;
|
|
setTestResult(data);
|
|
} catch (err) {
|
|
setTestResult({ success: false, message: err?.message || i18n.t('monitor.alertChannels.testFailed') });
|
|
} 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)]"> {i18n.t('monitor.zabbixdatasourc.s79e929d8')} </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"> {i18n.t('monitor.zabbixdatasourc.sc9c9bd18')} </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-[var(--dg-bg-active)]'}`} />
|
|
<span className="font-medium text-[var(--dg-text-primary)]">{ds.name}</span>
|
|
{ds.is_default && <Badge className="bg-amber-500/20 dg-text-yellow text-[10px]"><Star className="w-3 h-3 mr-0.5" /> {i18n.t('k8s.storage.default')} </Badge>}
|
|
{ds.status === 1
|
|
? <Badge className="bg-emerald-500/20 dg-text-green text-[10px]"> {i18n.t('system.userActive')} </Badge>
|
|
: <Badge className="bg-[var(--dg-bg-active)]/20 dg-text-secondary text-[10px]"> {i18n.t('system.userInactive')} </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]"> {i18n.t('monitor.zabbixdatasourc.s85260abd')} </Badge>}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{!ds.is_default && (
|
|
<Button variant="ghost" size="sm" className="h-7 text-xs dg-text-yellow" 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 dg-text-red" 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 dg-text-orange" />
|
|
{editingId ? i18n.t('monitor.zabbixdatasourc.sc1c87ed6') : i18n.t('monitor.zabbixdatasourc.sa77d384f')}
|
|
</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)]"> {i18n.t('deploy.form.nameStar')} </Label>
|
|
<Input className="mt-1 h-9 text-sm bg-[var(--dg-bg-primary)]" placeholder={i18n.t('monitor.zabbixdatasourc.s9f1e6a92')}
|
|
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"> {i18n.t('aiops.llmSetDefault')} </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={i18n.t('system.userNameRequired')}
|
|
value={form.username} onChange={e => updateForm('username', e.target.value)} />
|
|
<Input className="h-9 text-sm bg-[var(--dg-bg-primary)]" type="password" placeholder={i18n.t('system.passwordRequired')}
|
|
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> {i18n.t('monitor.zabbixdatasourc.sdda36edb')} </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"> {i18n.t('monitor.zabbixdatasourc.s413ec771')} </Label>
|
|
<Switch checked={form.disable_acknowledges} onCheckedChange={v => updateForm('disable_acknowledges', v)} />
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs"> {i18n.t('monitor.zabbixdatasourc.s6d4d6f81')} </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 dg-text-green' : 'bg-red-500/10 dg-text-red'
|
|
}`}>
|
|
{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)}> {i18n.t('rbac.jobRole.cancel')} </Button>
|
|
<Button size="sm" onClick={handleSave}> {i18n.t('task.script.save')} </Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|