feat: Prometheus数据源管理 — 参考Grafana界面CRUD

- 后端: PrometheusDataSource model (22字段,含Basic Auth/TLS/Headers/Alerting)
- 后端: 8 API (CRUD + 设默认 + 连接测试 + 审计)
- 前端: 数据源管理页 — 表格列表 + Grafana风格配置Dialog
- 前端: 侧边栏「监控中心→数据源」菜单项
- 功能: Save & test 连接测试(含Basic Auth + Skip TLS)

Author: Logan.Li
This commit is contained in:
attacker committed 2026-06-18 14:03:00 +00:00
1 parent 1726ad65ac
commit 2596bbc789
3 files changed
+832

No files matched your search

@@ -91,6 +91,7 @@ const menuItems: MenuItem[] = [
{ id: 'metrics', name: '指标监控', icon: Cpu, path: '/metrics' },
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' },
{ id: 'alert-center', name: '告警服务', icon: Bell, path: '/alert-center' },
{ id: 'datasources', name: '数据源', icon: Database, path: '/datasources' },
],
},
{
@@ -0,0 +1,829 @@
// Prometheus 数据源管理 — 参考 Grafana 界面设计 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
import {
Database, Plus, Edit, Trash2, Star, RefreshCw, CheckCircle, XCircle,
Shield, Globe, Clock, Wrench, Settings, Key, Link, X, ChevronDown, ChevronUp,
Plug, AlertTriangle
} 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 { Separator } from '@/components/ui/separator';
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter
} from '@/components/ui/dialog';
import {
Select, SelectContent, SelectItem, SelectTrigger, SelectValue
} from '@/components/ui/select';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { promDatasourceApi } from '@/services/api';
interface PromDS {
id: number;
name: string;
url: string;
access: string;
basic_auth: boolean;
basic_auth_user: string;
basic_auth_pass: string;
with_credentials: boolean;
tls_client_auth: boolean;
with_ca_cert: boolean;
ca_cert: string;
skip_tls_verify: boolean;
custom_headers: string;
enable_alerting: boolean;
alertmanager_ds: string;
scrape_interval: number;
query_timeout: number;
http_method: string;
disable_metrics_lookup: boolean;
custom_query_params: string;
is_default: boolean;
status: number;
created_at: string;
updated_at: string;
}
interface HeaderEntry {
header: string;
value: string;
}
const defaultForm = {
name: '',
url: '',
access: 'server',
http_method: 'POST',
basic_auth: false,
basic_auth_user: '',
basic_auth_pass: '',
with_credentials: false,
tls_client_auth: false,
with_ca_cert: false,
ca_cert: '',
skip_tls_verify: false,
custom_headers_json: '',
enable_alerting: true,
alertmanager_ds: '',
scrape_interval: 15,
query_timeout: 60,
disable_metrics_lookup: false,
custom_query_params: '',
is_default: false,
};
export default function PrometheusDatasourceManager() {
const _confirm = useConfirm();
const [sources, setSources] = useState<PromDS[]>([]);
const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState({ ...defaultForm });
// 自定义 HTTP Headers(动态键值对)
const [headers, setHeaders] = useState<HeaderEntry[]>([]);
// 折叠面板
const [showAuth, setShowAuth] = useState(false);
const [showTLS, setShowTLS] = useState(false);
const [showAlerting, setShowAlerting] = useState(false);
const [showMisc, setShowMisc] = useState(false);
// 连接测试
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{ success: boolean; message: string; target_count?: number } | null>(null);
const fetchList = useCallback(async () => {
try {
const res: any = await promDatasourceApi.list();
setSources(res?.data || res || []);
} catch {
toast.error('获取数据源列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchList(); }, [fetchList]);
// ==================== 表单操作 ====================
const openCreate = () => {
setEditingId(null);
setForm({ ...defaultForm });
setHeaders([]);
setTestResult(null);
setShowAuth(false);
setShowTLS(false);
setShowAlerting(false);
setShowMisc(false);
setDialogOpen(true);
};
const openEdit = (ds: PromDS) => {
setEditingId(ds.id);
setForm({
name: ds.name,
url: ds.url,
access: ds.access || 'server',
http_method: ds.http_method || 'POST',
basic_auth: ds.basic_auth || false,
basic_auth_user: ds.basic_auth_user || '',
basic_auth_pass: ds.basic_auth_pass || '',
with_credentials: ds.with_credentials || false,
tls_client_auth: ds.tls_client_auth || false,
with_ca_cert: ds.with_ca_cert || false,
ca_cert: ds.ca_cert || '',
skip_tls_verify: ds.skip_tls_verify || false,
custom_headers_json: ds.custom_headers || '',
enable_alerting: ds.enable_alerting !== false,
alertmanager_ds: ds.alertmanager_ds || '',
scrape_interval: ds.scrape_interval || 15,
query_timeout: ds.query_timeout || 60,
disable_metrics_lookup: ds.disable_metrics_lookup || false,
custom_query_params: ds.custom_query_params || '',
is_default: ds.is_default || false,
});
// 解析 custom_headers JSON
try {
const parsed = JSON.parse(ds.custom_headers || '[]');
setHeaders(Array.isArray(parsed) ? parsed : []);
} catch {
setHeaders([]);
}
setTestResult(null);
setDialogOpen(true);
};
const updateForm = (field: string, value: any) => {
setForm(prev => ({ ...prev, [field]: value }));
};
// ==================== HTTP Headers ====================
const addHeader = () => {
setHeaders(prev => [...prev, { header: '', value: '' }]);
};
const removeHeader = (idx: number) => {
setHeaders(prev => prev.filter((_, i) => i !== idx));
};
const updateHeader = (idx: number, field: 'header' | 'value', val: string) => {
setHeaders(prev => {
const next = [...prev];
next[idx] = { ...next[idx], [field]: val };
return next;
});
};
// ==================== CRUD ====================
const handleSave = async () => {
if (!form.name.trim()) { toast.error('请输入数据源名称'); return; }
if (!form.url.trim()) { toast.error('请输入 Prometheus URL'); return; }
const payload: any = {
name: form.name.trim(),
url: form.url.trim(),
access: form.access,
http_method: form.http_method,
basic_auth: form.basic_auth,
basic_auth_user: form.basic_auth_user,
basic_auth_pass: form.basic_auth_pass,
with_credentials: form.with_credentials,
tls_client_auth: form.tls_client_auth,
with_ca_cert: form.with_ca_cert,
ca_cert: form.ca_cert,
skip_tls_verify: form.skip_tls_verify,
custom_headers: JSON.stringify(headers),
enable_alerting: form.enable_alerting,
alertmanager_ds: form.alertmanager_ds,
scrape_interval: form.scrape_interval,
query_timeout: form.query_timeout,
disable_metrics_lookup: form.disable_metrics_lookup,
custom_query_params: form.custom_query_params,
is_default: form.is_default,
};
try {
if (editingId) {
await promDatasourceApi.update(editingId, payload);
toast.success('数据源更新成功');
} else {
await promDatasourceApi.create(payload);
toast.success('数据源创建成功');
}
setDialogOpen(false);
fetchList();
} catch (err: any) {
toast.error(err?.message || '操作失败');
}
};
const handleDelete = async (ds: PromDS) => {
const ok = await _confirm({
title: '删除数据源',
description: `确定删除数据源「${ds.name}」吗?此操作不可撤销。`,
variant: 'destructive',
});
if (!ok) return;
try {
await promDatasourceApi.delete(ds.id);
toast.success('已删除');
fetchList();
} catch {
toast.error('删除失败');
}
};
const handleSetDefault = async (ds: PromDS) => {
try {
await promDatasourceApi.setDefault(ds.id);
toast.success(`已将「${ds.name}」设为默认数据源`);
fetchList();
} catch {
toast.error('设置失败');
}
};
// ==================== 连接测试 ====================
const handleTestConnection = async () => {
if (!form.url.trim()) { toast.error('请先输入 URL'); return; }
setTesting(true);
setTestResult(null);
try {
const res: any = await promDatasourceApi.testConnection({
url: form.url.trim(),
basic_auth: form.basic_auth,
basic_auth_user: form.basic_auth_user,
basic_auth_pass: form.basic_auth_pass,
skip_tls_verify: form.skip_tls_verify,
});
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="p-6 space-y-6 max-w-7xl mx-auto">
{/* 页头 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">数据源管理</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">
Prometheus 数据源配置,用于指标监控与监控大屏
</p>
</div>
<Button onClick={openCreate} className="gap-2">
<Plus className="w-4 h-4" />
添加数据源
</Button>
</div>
{/* 数据源列表 */}
<Card className="border-[var(--dg-border)] bg-[var(--dg-bg-secondary)]">
<CardHeader className="pb-2">
<CardTitle className="text-base flex items-center gap-2">
<Database className="w-4 h-4 text-blue-400" />
已配置的数据源
<Badge variant="secondary" className="ml-2">{sources.length}</Badge>
</CardTitle>
</CardHeader>
<CardContent className="p-0">
{sources.length === 0 ? (
<div className="py-12 text-center text-[var(--dg-text-secondary)]">
<Database className="w-12 h-12 mx-auto mb-3 opacity-30" />
<p className="text-sm">暂无数据源</p>
<p className="text-xs mt-1">点击「添加数据源」开始配置</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b border-[var(--dg-border)]">
<tr className="text-xs text-[var(--dg-text-secondary)]">
<th className="text-left py-3 px-4 font-medium">名称</th>
<th className="text-left py-3 px-4 font-medium">URL</th>
<th className="text-left py-3 px-4 font-medium w-20">默认</th>
<th className="text-left py-3 px-4 font-medium w-16">状态</th>
<th className="text-right py-3 px-4 font-medium w-36">操作</th>
</tr>
</thead>
<tbody>
{sources.map(ds => (
<tr
key={ds.id}
className="border-b border-[var(--dg-border)]/50 hover:bg-[var(--dg-bg-tertiary)]/50 transition-colors"
>
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<img
src="https://cdn.jsdelivr.net/gh/walkxcode/dashboard-icons@master/png/prometheus.png"
alt="Prometheus"
className="w-5 h-5 rounded"
onError={(e) => { (e.target as HTMLImageElement).style.display = 'none'; }}
/>
<div>
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{ds.name}</span>
{ds.basic_auth && <Key className="w-3 h-3 inline ml-1.5 text-amber-400" />}
</div>
</div>
</td>
<td className="py-3 px-4">
<code className="text-xs text-blue-300 bg-slate-800/50 px-2 py-0.5 rounded max-w-[320px] block truncate"
title={ds.url}>
{ds.url}
</code>
</td>
<td className="py-3 px-4">
{ds.is_default ? (
<Badge variant="default" className="bg-amber-500/15 text-amber-400 border-amber-500/30 text-xs">
<Star className="w-3 h-3 mr-1" /> 默认
</Badge>
) : (
<span className="text-xs text-[var(--dg-text-secondary)]">--</span>
)}
</td>
<td className="py-3 px-4">
{ds.status === 1 ? (
<Badge variant="default" className="bg-emerald-500/15 text-emerald-400 border-emerald-500/30 text-xs">
启用
</Badge>
) : (
<Badge variant="secondary" className="text-xs">禁用</Badge>
)}
</td>
<td className="py-3 px-4">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost" size="sm"
className="h-8 px-2"
onClick={() => handleTestQuick(ds)}
title="测试连接"
>
<Plug className="w-3.5 h-3.5" />
</Button>
{!ds.is_default && (
<Button
variant="ghost" size="sm"
className="h-8 px-2 text-amber-400 hover:text-amber-300"
onClick={() => handleSetDefault(ds)}
title="设为默认"
>
<Star className="w-3.5 h-3.5" />
</Button>
)}
<Button
variant="ghost" size="sm"
className="h-8 px-2"
onClick={() => openEdit(ds)}
>
<Edit className="w-3.5 h-3.5" />
</Button>
<Button
variant="ghost" size="sm"
className="h-8 px-2 text-red-400 hover:text-red-300"
onClick={() => handleDelete(ds)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* ==================== 添加/编辑 Dialog ==================== */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-lg">
<Database className="w-5 h-5 text-orange-400" />
{editingId ? '编辑数据源' : '添加数据源'}
</DialogTitle>
<DialogDescription>
Prometheus 数据源配置 · 参考 Grafana 数据源管理
</DialogDescription>
</DialogHeader>
<div className="space-y-5 py-2">
{/* === Name & Default === */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ds-name" className="text-sm">Name <span className="text-red-400">*</span></Label>
<Input
id="ds-name"
placeholder="Prometheus 数据源名称"
value={form.name}
onChange={e => updateForm('name', e.target.value)}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]"
/>
</div>
<div className="flex items-end pb-2">
<div className="flex items-center gap-3">
<Switch
id="ds-default"
checked={form.is_default}
onCheckedChange={v => updateForm('is_default', v)}
/>
<Label htmlFor="ds-default" className="text-sm cursor-pointer">Default</Label>
</div>
</div>
</div>
{/* === HTTP 设置 === */}
<div className="space-y-3 bg-[var(--dg-bg-tertiary)]/50 rounded-lg p-4 border border-[var(--dg-border)]/50">
<h4 className="text-sm font-medium flex items-center gap-2 text-[var(--dg-text-primary)]">
<Globe className="w-4 h-4" /> HTTP
</h4>
<div className="space-y-2">
<Label htmlFor="ds-url" className="text-sm">URL <span className="text-red-400">*</span></Label>
<div className="flex gap-2">
<Select value={form.access} onValueChange={v => updateForm('access', v)}>
<SelectTrigger className="w-32 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="server">Server</SelectItem>
<SelectItem value="browser">Browser</SelectItem>
</SelectContent>
</Select>
<Input
id="ds-url"
placeholder="http://localhost:9090"
value={form.url}
onChange={e => updateForm('url', e.target.value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] font-mono text-sm"
/>
</div>
</div>
<div className="text-xs text-[var(--dg-text-secondary)]">
Access: <span className="text-[var(--dg-text-primary)]">{form.access === 'server' ? 'Server (default)' : 'Browser'}</span>
</div>
</div>
{/* === Auth 折叠 === */}
<div className="border border-[var(--dg-border)]/50 rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 bg-[var(--dg-bg-tertiary)]/30 hover:bg-[var(--dg-bg-tertiary)]/60 transition-colors"
onClick={() => setShowAuth(!showAuth)}
>
<span className="text-sm font-medium flex items-center gap-2">
<Shield className="w-4 h-4" /> Auth
</span>
{showAuth ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showAuth && (
<div className="p-4 space-y-4 border-t border-[var(--dg-border)]/50">
{/* Basic Auth */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch
id="ds-basic-auth"
checked={form.basic_auth}
onCheckedChange={v => updateForm('basic_auth', v)}
/>
<Label htmlFor="ds-basic-auth" className="text-sm cursor-pointer">Basic auth</Label>
</div>
{form.basic_auth && (
<div className="grid grid-cols-2 gap-3 ml-9">
<Input
placeholder="用户名"
value={form.basic_auth_user}
onChange={e => updateForm('basic_auth_user', e.target.value)}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm"
/>
<Input
type="password"
placeholder="密码"
value={form.basic_auth_pass}
onChange={e => updateForm('basic_auth_pass', e.target.value)}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm"
/>
</div>
)}
</div>
{/* With Credentials */}
<div className="flex items-center gap-3">
<Switch
id="ds-with-creds"
checked={form.with_credentials}
onCheckedChange={v => updateForm('with_credentials', v)}
/>
<Label htmlFor="ds-with-creds" className="text-sm cursor-pointer">With Credentials</Label>
</div>
{/* TLS Client Auth */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<Switch
id="ds-tls-auth"
checked={form.tls_client_auth}
onCheckedChange={v => updateForm('tls_client_auth', v)}
/>
<Label htmlFor="ds-tls-auth" className="text-sm cursor-pointer">TLS Client Auth</Label>
</div>
{form.tls_client_auth && (
<div className="ml-9 space-y-3">
<div className="flex items-center gap-3">
<Switch
id="ds-ca-cert"
checked={form.with_ca_cert}
onCheckedChange={v => updateForm('with_ca_cert', v)}
/>
<Label htmlFor="ds-ca-cert" className="text-sm cursor-pointer">With CA Cert</Label>
</div>
{form.with_ca_cert && (
<textarea
placeholder="PEM 格式的 CA 证书内容..."
value={form.ca_cert}
onChange={e => updateForm('ca_cert', e.target.value)}
rows={4}
className="w-full bg-[var(--dg-bg-tertiary)] border border-[var(--dg-border)] rounded-md p-3 text-xs font-mono text-[var(--dg-text-primary)] resize-y"
/>
)}
</div>
)}
</div>
{/* Skip TLS Verify */}
<div className="flex items-center gap-3">
<Switch
id="ds-skip-tls"
checked={form.skip_tls_verify}
onCheckedChange={v => updateForm('skip_tls_verify', v)}
/>
<Label htmlFor="ds-skip-tls" className="text-sm cursor-pointer">Skip TLS Verify</Label>
</div>
{/* Forward OAuth Identity */}
<div className="text-xs text-[var(--dg-text-secondary)] italic">
Forward OAuth Identity
</div>
</div>
)}
</div>
{/* === Custom HTTP Headers === */}
<div className="space-y-3 bg-[var(--dg-bg-tertiary)]/30 rounded-lg p-4 border border-[var(--dg-border)]/50">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium flex items-center gap-2 text-[var(--dg-text-primary)]">
<Wrench className="w-4 h-4" /> Custom HTTP Headers
</h4>
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={addHeader}>
<Plus className="w-3 h-3 mr-1" /> Add header
</Button>
</div>
{headers.length === 0 ? (
<p className="text-xs text-[var(--dg-text-secondary)] italic">暂无自定义 Header</p>
) : (
<div className="space-y-2">
{headers.map((h, idx) => (
<div key={idx} className="flex items-center gap-2">
<Input
placeholder="Header"
value={h.header}
onChange={e => updateHeader(idx, 'header', e.target.value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm h-8"
/>
<Input
placeholder="Value"
value={h.value}
onChange={e => updateHeader(idx, 'value', e.target.value)}
className="flex-1 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm h-8"
/>
<Button
variant="ghost" size="sm" className="h-8 w-8 p-0 text-red-400 hover:text-red-300"
onClick={() => removeHeader(idx)}
>
<X className="w-3.5 h-3.5" />
</Button>
</div>
))}
</div>
)}
</div>
{/* === Alerting 折叠 === */}
<div className="border border-[var(--dg-border)]/50 rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 bg-[var(--dg-bg-tertiary)]/30 hover:bg-[var(--dg-bg-tertiary)]/60 transition-colors"
onClick={() => setShowAlerting(!showAlerting)}
>
<span className="text-sm font-medium flex items-center gap-2">
<AlertTriangle className="w-4 h-4" /> Alerting
</span>
{showAlerting ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showAlerting && (
<div className="p-4 space-y-4 border-t border-[var(--dg-border)]/50">
<div className="flex items-center gap-3">
<Switch
id="ds-enable-alerting"
checked={form.enable_alerting}
onCheckedChange={v => updateForm('enable_alerting', v)}
/>
<Label htmlFor="ds-enable-alerting" className="text-sm cursor-pointer">
Manage alerts via Alerting UI
</Label>
</div>
<div className="space-y-2">
<Label className="text-sm text-[var(--dg-text-secondary)]">Alertmanager data source</Label>
<Select value={form.alertmanager_ds} onValueChange={v => updateForm('alertmanager_ds', v)}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm">
<SelectValue placeholder="Choose" />
</SelectTrigger>
<SelectContent>
<SelectItem value="">无</SelectItem>
{sources.filter(s => s.id !== editingId).map(s => (
<SelectItem key={s.id} value={String(s.id)}>{s.name}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
)}
</div>
{/* === 采集与查询 === */}
<div className="grid grid-cols-2 gap-4 bg-[var(--dg-bg-tertiary)]/30 rounded-lg p-4 border border-[var(--dg-border)]/50">
<div className="space-y-2">
<Label className="text-sm flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" /> Scrape interval
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={form.scrape_interval}
onChange={e => updateForm('scrape_interval', Number(e.target.value))}
className="w-20 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm text-center"
/>
<span className="text-xs text-[var(--dg-text-secondary)]">秒</span>
</div>
</div>
<div className="space-y-2">
<Label className="text-sm flex items-center gap-1.5">
<Clock className="w-3.5 h-3.5" /> Query timeout
</Label>
<div className="flex items-center gap-2">
<Input
type="number"
min={1}
value={form.query_timeout}
onChange={e => updateForm('query_timeout', Number(e.target.value))}
className="w-20 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm text-center"
/>
<span className="text-xs text-[var(--dg-text-secondary)]">秒</span>
</div>
</div>
</div>
{/* === HTTP Method === */}
<div className="space-y-2">
<Label className="text-sm">HTTP Method</Label>
<Select value={form.http_method} onValueChange={v => updateForm('http_method', v)}>
<SelectTrigger className="w-32 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="POST">POST</SelectItem>
<SelectItem value="GET">GET</SelectItem>
</SelectContent>
</Select>
</div>
{/* === Misc 折叠 === */}
<div className="border border-[var(--dg-border)]/50 rounded-lg overflow-hidden">
<button
type="button"
className="w-full flex items-center justify-between px-4 py-3 bg-[var(--dg-bg-tertiary)]/30 hover:bg-[var(--dg-bg-tertiary)]/60 transition-colors"
onClick={() => setShowMisc(!showMisc)}
>
<span className="text-sm font-medium flex items-center gap-2">
<Settings className="w-4 h-4" /> Misc
</span>
{showMisc ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />}
</button>
{showMisc && (
<div className="p-4 space-y-4 border-t border-[var(--dg-border)]/50">
<div className="flex items-center gap-3">
<Switch
id="ds-disable-lookup"
checked={form.disable_metrics_lookup}
onCheckedChange={v => updateForm('disable_metrics_lookup', v)}
/>
<Label htmlFor="ds-disable-lookup" className="text-sm cursor-pointer">
Disable metrics lookup
</Label>
</div>
<div className="space-y-2">
<Label className="text-sm text-[var(--dg-text-secondary)]">Custom query parameters</Label>
<Input
placeholder='例: {"timeout": "30s"}'
value={form.custom_query_params}
onChange={e => updateForm('custom_query_params', e.target.value)}
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-sm font-mono"
/>
</div>
</div>
)}
</div>
{/* 连接测试结果 */}
{testResult && (
<div className={`rounded-lg p-3 flex items-center gap-2 text-sm ${
testResult.success
? 'bg-emerald-500/10 border border-emerald-500/30 text-emerald-400'
: 'bg-red-500/10 border border-red-500/30 text-red-400'
}`}>
{testResult.success
? <CheckCircle className="w-4 h-4 shrink-0" />
: <XCircle className="w-4 h-4 shrink-0" />
}
<span>{testResult.message}</span>
{testResult.target_count !== undefined && (
<Badge variant="secondary" className="ml-auto text-xs">
{testResult.target_count} targets
</Badge>
)}
</div>
)}
</div>
{/* Footer */}
<DialogFooter className="flex items-center gap-2 pt-2 border-t border-[var(--dg-border)]/50">
<Button
variant="outline"
onClick={handleTestConnection}
disabled={testing || !form.url.trim()}
className="gap-2"
>
{testing ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<Plug className="w-4 h-4" />
)}
Save & test
</Button>
<div className="flex-1" />
<Button variant="ghost" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button onClick={handleSave}>
{editingId ? '更新' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
// 快速测试连接(列表中的测试按钮)
async function handleTestQuick(ds: PromDS) {
try {
const res: any = await promDatasourceApi.testConnection({
url: ds.url,
basic_auth: ds.basic_auth,
basic_auth_user: ds.basic_auth_user,
basic_auth_pass: ds.basic_auth_pass,
skip_tls_verify: ds.skip_tls_verify,
});
const data = res?.data || res;
if (data?.success) {
toast.success(`连接成功 · ${data.target_count || 0} targets`);
} else {
toast.error(data?.message || '连接失败');
}
} catch (err: any) {
toast.error(err?.message || '测试失败');
}
}