refactor(frontend): P1-P2 续 — API 子模块化、大组件拆分、Query 铺开

- api/server/ 与 api/cloud/ 子目录拆分,保持 @/api 导入不变
- hooks/api 新增 useCredentials、usePromDatasources,增强 useServers
- 拆分 CredentialManager、DeploymentWorkload、PrometheusDatasourceManager
- ServerManager、CredentialManager、PromDs 接入 TanStack Query
This commit is contained in:
Logan.Li committed 2026-06-26 00:22:50 +08:00
1 parent f45e203e21
commit 480e677042
32 files changed
+2280 -1940

No files matched your search

@@ -1,247 +1,49 @@
// Prometheus 数据源管理 — 参考 Grafana 界面设计 // Logan.Li
import { useState, useEffect, useCallback } from 'react';
// Prometheus 数据源管理 — 列表编排器 // Logan.Li
import { useState, useEffect } from 'react';
import {
Database,
Plus,
Edit,
Trash2,
Star,
RefreshCw,
CheckCircle,
XCircle,
Shield,
Globe,
Clock,
Wrench,
Settings,
Key,
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 {
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 '@/api';
import { usePromDatasources, usePromDatasourceMutations } from '@/hooks/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,
};
import type { PromDS } from './prometheus-ds/types';
import { handleTestQuick } from './prometheus-ds/helpers';
import PromDsFormDialog from './prometheus-ds/PromDsFormDialog';
export default function PrometheusDatasourceManager() {
const _confirm = useConfirm();
const [sources, setSources] = useState<PromDS[]>([]);
const [loading, setLoading] = useState(true);
const { data: sources = [], isLoading, isError } = usePromDatasources();
const { deleteMutation, setDefaultMutation } = usePromDatasourceMutations();
const [dialogOpen, setDialogOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState({ ...defaultForm });
const [editingDs, setEditingDs] = useState<PromDS | null>(null);
// 自定义 HTTP Headers(动态键值对)
const [headers, setHeaders] = useState<HeaderEntry[]>([]);
// 折叠面板
const [showAuth, setShowAuth] = useState(false);
const [, 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: Record<string, any> = await promDatasourceApi.list();
setSources(res?.data || res || []);
} catch {
toast.error('获取数据源列表失败');
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchList(); }, [fetchList]);
// ==================== 表单操作 ====================
useEffect(() => {
if (isError) toast.error('获取数据源列表失败');
}, [isError]);
const openCreate = () => {
setEditingId(null);
setForm({ ...defaultForm });
setHeaders([]);
setTestResult(null);
setShowAuth(false);
setShowTLS(false);
setShowAlerting(false);
setShowMisc(false);
setEditingDs(null);
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);
setEditingDs(ds);
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: Record<string, 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: '删除数据源',
@@ -250,9 +52,8 @@ export default function PrometheusDatasourceManager() {
});
if (!ok) return;
try {
await promDatasourceApi.delete(ds.id);
await deleteMutation.mutateAsync(ds.id);
toast.success('已删除');
fetchList();
} catch {
toast.error('删除失败');
}
@@ -260,40 +61,14 @@ export default function PrometheusDatasourceManager() {
const handleSetDefault = async (ds: PromDS) => {
try {
await promDatasourceApi.setDefault(ds.id);
await setDefaultMutation.mutateAsync(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: Record<string, 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) {
if (isLoading) {
return (
<div className="flex items-center justify-center h-96">
<RefreshCw className="animate-spin w-8 h-8 text-[var(--dg-text-secondary)]" />
@@ -434,413 +209,12 @@ export default function PrometheusDatasourceManager() {
</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="__none">无</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>
<PromDsFormDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
editingDs={editingDs}
sources={sources}
/>
</div>
);
}
// 快速测试连接(列表中的测试按钮)
async function handleTestQuick(ds: PromDS) {
try {
const res: Record<string, 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 || '测试失败');
}
}
}
@@ -0,0 +1,527 @@
// Prometheus 数据源添加/编辑弹窗 // Logan.Li
import { useState, useEffect } from 'react';
import {
Database,
Plus,
RefreshCw,
CheckCircle,
XCircle,
Shield,
Globe,
Clock,
Wrench,
Settings,
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 { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
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 { usePromDatasourceMutations } from '@/hooks/api';
import type { PromDS, HeaderEntry, PromDsForm } from './types';
import { defaultForm } from './types';
import { dsToForm, parseHeaders, buildPayload, testPromConnection, type TestConnectionResult } from './helpers';
interface PromDsFormDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
editingDs: PromDS | null;
sources: PromDS[];
}
export default function PromDsFormDialog({
open,
onOpenChange,
editingDs,
sources,
}: PromDsFormDialogProps) {
const { createMutation, updateMutation } = usePromDatasourceMutations();
const editingId = editingDs?.id ?? null;
const [form, setForm] = useState<PromDsForm>({ ...defaultForm });
const [headers, setHeaders] = useState<HeaderEntry[]>([]);
const [showAuth, setShowAuth] = useState(false);
const [, setShowTLS] = useState(false);
const [showAlerting, setShowAlerting] = useState(false);
const [showMisc, setShowMisc] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestConnectionResult | null>(null);
// 打开弹窗时初始化表单
useEffect(() => {
if (!open) return;
if (editingDs) {
setForm(dsToForm(editingDs));
setHeaders(parseHeaders(editingDs.custom_headers));
} else {
setForm({ ...defaultForm });
setHeaders([]);
}
setTestResult(null);
setShowAuth(false);
setShowTLS(false);
setShowAlerting(false);
setShowMisc(false);
}, [open, editingDs]);
const updateForm = (field: keyof PromDsForm, value: PromDsForm[keyof PromDsForm]) => {
setForm(prev => ({ ...prev, [field]: value }));
};
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;
});
};
const handleSave = async () => {
if (!form.name.trim()) { toast.error('请输入数据源名称'); return; }
if (!form.url.trim()) { toast.error('请输入 Prometheus URL'); return; }
const payload = buildPayload(form, headers);
try {
if (editingId) {
await updateMutation.mutateAsync({ id: editingId, data: payload });
toast.success('数据源更新成功');
} else {
await createMutation.mutateAsync(payload);
toast.success('数据源创建成功');
}
onOpenChange(false);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '操作失败';
toast.error(message);
}
};
const handleTestConnection = async () => {
if (!form.url.trim()) { toast.error('请先输入 URL'); return; }
setTesting(true);
setTestResult(null);
try {
const data = await testPromConnection({
url: form.url,
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,
});
setTestResult(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '测试失败';
setTestResult({ success: false, message });
} finally {
setTesting(false);
}
};
const saving = createMutation.isPending || updateMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<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">
<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>
<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>
<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>
<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>
<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="__none">无</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>
<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={() => onOpenChange(false)}>
取消
</Button>
<Button onClick={handleSave} disabled={saving}>
{editingId ? '更新' : '保存'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,115 @@
// Prometheus 数据源辅助函数 // Logan.Li
import { toast } from 'sonner';
import { promDatasourceApi } from '@/api';
import type { PromDS, HeaderEntry, PromDsForm } from './types';
export type TestConnectionParams = {
url: string;
basic_auth: boolean;
basic_auth_user: string;
basic_auth_pass: string;
skip_tls_verify: boolean;
};
export type TestConnectionResult = {
success: boolean;
message: string;
target_count?: number;
};
/** 从数据源记录构建表单初始值 */
export function dsToForm(ds: PromDS): PromDsForm {
return {
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 为键值对列表 */
export function parseHeaders(json: string): HeaderEntry[] {
try {
const parsed = JSON.parse(json || '[]');
return Array.isArray(parsed) ? parsed : [];
} catch {
return [];
}
}
/** 构建保存请求的 payload */
export function buildPayload(form: PromDsForm, headers: HeaderEntry[]): Record<string, unknown> {
return {
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,
};
}
/** 测试 Prometheus 连接(表单与列表共用) */
export async function testPromConnection(params: TestConnectionParams): Promise<TestConnectionResult> {
const res: Record<string, unknown> = await promDatasourceApi.testConnection({
url: params.url.trim(),
basic_auth: params.basic_auth,
basic_auth_user: params.basic_auth_user,
basic_auth_pass: params.basic_auth_pass,
skip_tls_verify: params.skip_tls_verify,
});
const data = (res?.data || res) as TestConnectionResult;
return data;
}
/** 列表行快速测试连接 */
export async function handleTestQuick(ds: PromDS): Promise<void> {
try {
const data = await testPromConnection({
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,
});
if (data?.success) {
toast.success(`连接成功 · ${data.target_count || 0} targets`);
} else {
toast.error(data?.message || '连接失败');
}
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '测试失败';
toast.error(message);
}
}
@@ -0,0 +1,58 @@
// Prometheus 数据源类型定义 // Logan.Li
export 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;
}
export interface HeaderEntry {
header: string;
value: string;
}
export 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 type PromDsForm = typeof defaultForm;