批量转换脚本引入的语法错误已回退,保留稳定的 i18n 转换: - 翻译词典: 22 namespaces, 3600+ keys - 已接入: 145+ 文件 (Dashboard/Login/K8s 主页面/Monitor/System/Deploy) - 语言切换器在 Header 中可用 (🇨🇳/🇺🇸) - tsc 零错误, Vite 构建成功
314 lines
14 KiB
TypeScript
314 lines
14 KiB
TypeScript
// LogDatasourceDialog — 添加/编辑 Loki / ES 数据源 // Logan.Li
|
|
import { useState, useEffect } from 'react';
|
|
import {
|
|
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import {
|
|
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
|
} from '@/components/ui/select';
|
|
import { Loader2, Plug, RefreshCw } from 'lucide-react';
|
|
import { useToast } from '@/hooks/use-toast';
|
|
import { logCenterApi, type LogDataSource, type TestConnectionResult } from '@/api';
|
|
import type { LogEngine } from './log-center-types';
|
|
|
|
export function LogDatasourceDialog({
|
|
open, onOpenChange, engine, editing, onSaved,
|
|
}: {
|
|
open: boolean;
|
|
onOpenChange: (v: boolean) => void;
|
|
engine: LogEngine;
|
|
editing?: LogDataSource | null;
|
|
onSaved: () => void;
|
|
}) {
|
|
const { toast } = useToast();
|
|
const [name, setName] = useState('');
|
|
const [url, setUrl] = useState('');
|
|
const [defaultIndex, setDefaultIndex] = useState('logstash-*');
|
|
const [authType, setAuthType] = useState<LogDataSource['auth_type']>('none');
|
|
const [user, setUser] = useState('');
|
|
const [pass, setPass] = useState('');
|
|
const [token, setToken] = useState('');
|
|
const [headers, setHeaders] = useState<{ name: string; value: string }[]>([]);
|
|
const [skipTLS, setSkipTLS] = useState(false);
|
|
const [isDefault, setIsDefault] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
const [testing, setTesting] = useState(false);
|
|
// 索引下拉: ES 索引列表 + 加载状态 // Logan.Li
|
|
const [indices, setIndices] = useState<string[]>([]);
|
|
const [loadingIndices, setLoadingIndices] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
if (editing) {
|
|
setName(editing.name);
|
|
setUrl(editing.url);
|
|
setDefaultIndex(editing.default_index || 'logstash-*');
|
|
// 兼容旧版:未设置 auth_type 但 basic_auth=true 时回退为 basic // Logan.Li
|
|
const initialAuth = editing.auth_type || (editing.basic_auth ? 'basic' : 'none');
|
|
setAuthType(initialAuth);
|
|
setUser(editing.basic_auth_user || '');
|
|
setPass('');
|
|
setToken(editing.token || '');
|
|
try {
|
|
setHeaders(editing.headers ? JSON.parse(editing.headers) : []);
|
|
} catch {
|
|
setHeaders([]);
|
|
}
|
|
setSkipTLS(!!editing.skip_tls_verify);
|
|
setIsDefault(!!editing.is_default);
|
|
// 编辑 ES 源时预加载索引列表
|
|
if (editing.type === 'elasticsearch' && editing.url) {
|
|
fetchIndices(editing);
|
|
}
|
|
} else {
|
|
const suffix = Math.floor(Math.random() * 1000).toString().padStart(3, '0');
|
|
setName(engine === 'loki' ? `Loki-${suffix}` : `Elasticsearch-${suffix}`);
|
|
setUrl(engine === 'loki' ? 'http://localhost:3100' : 'http://localhost:9200');
|
|
setDefaultIndex('logstash-*');
|
|
setAuthType('none');
|
|
setUser('');
|
|
setPass('');
|
|
setToken('');
|
|
setHeaders([]);
|
|
setSkipTLS(false);
|
|
setIsDefault(false);
|
|
setIndices([]);
|
|
}
|
|
}, [open, editing, engine]);
|
|
|
|
const fetchIndices = async (ds: LogDataSource) => {
|
|
if (!ds.url || ds.type !== 'elasticsearch') return;
|
|
setLoadingIndices(true);
|
|
try {
|
|
const result = await logCenterApi.listIndices({
|
|
url: ds.url,
|
|
auth_type: ds.auth_type || 'none',
|
|
basic_auth: ds.auth_type === 'basic' ? 'true' : 'false',
|
|
basic_auth_user: ds.basic_auth_user || '',
|
|
basic_auth_pass: ds.basic_auth_pass || '',
|
|
token: ds.token || '',
|
|
headers: ds.headers || '',
|
|
skip_tls_verify: ds.skip_tls_verify ? 'true' : 'false',
|
|
});
|
|
setIndices(Array.isArray(result) ? result : []);
|
|
} catch {
|
|
// 静默失败,仍可手动输入
|
|
setIndices([]);
|
|
} finally {
|
|
setLoadingIndices(false);
|
|
}
|
|
};
|
|
|
|
const payload = () => ({
|
|
name: name.trim(),
|
|
type: engine,
|
|
url: url.trim(),
|
|
auth_type: authType,
|
|
basic_auth: authType === 'basic',
|
|
basic_auth_user: authType === 'basic' ? user : '',
|
|
basic_auth_pass: authType === 'basic' ? pass : '',
|
|
token: authType === 'bearer' ? token : '',
|
|
headers: authType === 'custom' ? JSON.stringify(headers.filter(h => h.name)) : '[]',
|
|
skip_tls_verify: skipTLS,
|
|
default_index: engine === 'elasticsearch' ? defaultIndex : undefined,
|
|
is_default: isDefault,
|
|
});
|
|
|
|
const handleTest = async () => {
|
|
setTesting(true);
|
|
try {
|
|
// 编辑已保存数据源时通过 datasource_id 测试,避免密码在前端丢失。// Logan.Li
|
|
const data = editing
|
|
? { datasource_id: editing.id }
|
|
: payload();
|
|
const result: TestConnectionResult = await logCenterApi.testDatasource(data);
|
|
if (result.status === 'ok') {
|
|
toast({ title: '连接成功' });
|
|
} else {
|
|
toast({ title: '连接失败', description: result.error || '未知错误', variant: 'destructive' });
|
|
}
|
|
} catch (e: any) {
|
|
toast({ title: '连接失败', description: e?.message || String(e), variant: 'destructive' });
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!name.trim() || !url.trim()) return;
|
|
setSaving(true);
|
|
try {
|
|
if (editing) {
|
|
await logCenterApi.updateDatasource(editing.id, payload());
|
|
} else {
|
|
await logCenterApi.createDatasource(payload());
|
|
}
|
|
toast({ title: '已保存' });
|
|
onOpenChange(false);
|
|
onSaved();
|
|
} catch (e: any) {
|
|
toast({ title: '保存失败', description: e?.message || String(e), variant: 'destructive' });
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] max-w-md max-h-[85vh] overflow-y-auto">
|
|
<DialogHeader>
|
|
<DialogTitle className="text-[var(--dg-text-primary)]">
|
|
{editing ? '编辑' : '添加'}{engine === 'loki' ? ' Loki' : ' Elasticsearch'} 数据源
|
|
</DialogTitle>
|
|
<DialogDescription className="text-[var(--dg-text-muted)]">
|
|
仅保存连接信息,日志本体存储在您的 Loki / ES 集群
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3 py-1">
|
|
<div>
|
|
<Label className="text-xs text-[var(--dg-text-muted)]">名称</Label>
|
|
<Input value={name} onChange={e => setName(e.target.value)} className="mt-1 h-9 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-xs text-[var(--dg-text-muted)]">地址</Label>
|
|
<Input value={url} onChange={e => setUrl(e.target.value)} placeholder="http://host:port" className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
|
</div>
|
|
{engine === 'elasticsearch' && (
|
|
<div>
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs text-[var(--dg-text-muted)]">默认索引</Label>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-6 px-1.5 text-[10px] text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
|
|
onClick={() => fetchIndices({
|
|
id: editing?.id || 0,
|
|
name: name.trim(),
|
|
type: engine,
|
|
url: url.trim(),
|
|
auth_type: authType,
|
|
basic_auth: authType === 'basic',
|
|
basic_auth_user: user,
|
|
basic_auth_pass: pass,
|
|
token,
|
|
headers: JSON.stringify(headers.filter(h => h.name)),
|
|
skip_tls_verify: skipTLS,
|
|
default_index: defaultIndex,
|
|
is_default: isDefault,
|
|
} as LogDataSource)}
|
|
disabled={!url || loadingIndices}
|
|
>
|
|
{loadingIndices ? (
|
|
<Loader2 className="h-3 w-3 animate-spin mr-0.5" />
|
|
) : (
|
|
<RefreshCw className="h-3 w-3 mr-0.5" />
|
|
)}
|
|
获取索引
|
|
</Button>
|
|
</div>
|
|
<Input value={defaultIndex} onChange={e => setDefaultIndex(e.target.value)} placeholder="索引名或通配符,如 .monitoring-es-7-*" className="mt-1 h-9 font-mono text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
|
{/* 已获取索引快捷选择(支持通配) // Logan.Li */}
|
|
{indices.length > 0 && (
|
|
<div className="mt-2 flex flex-wrap gap-1 max-h-24 overflow-auto">
|
|
{indices.slice(0, 30).map((idx) => (
|
|
<button
|
|
key={idx}
|
|
type="button"
|
|
onClick={() => setDefaultIndex(idx)}
|
|
className="px-1.5 py-0.5 rounded text-[10px] font-mono border transition-colors hover:opacity-80"
|
|
style={{ background: 'var(--dg-bg-tertiary)', borderColor: 'var(--dg-border)', color: 'var(--dg-text-secondary)' }}
|
|
title={idx}
|
|
>
|
|
{idx.length > 28 ? `${idx.slice(0, 28)}...` : idx}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
{/* 常见通配模式 // Logan.Li */}
|
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
{['.monitoring-es-7-*', 'logstash-*', 'filebeat-*', 'metrics-*', '*-2026.06.*'].map((pattern) => (
|
|
<button
|
|
key={pattern}
|
|
type="button"
|
|
onClick={() => setDefaultIndex(pattern)}
|
|
className="px-1.5 py-0.5 rounded text-[10px] font-mono border transition-colors hover:opacity-80"
|
|
style={{ background: 'rgba(34,197,94,0.08)', borderColor: 'rgba(34,197,94,0.25)', color: '#22c55e' }}
|
|
>
|
|
{pattern}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<Label className="text-xs text-[var(--dg-text-muted)]">认证方式</Label>
|
|
<Select value={authType} onValueChange={(v) => setAuthType(v as LogDataSource['auth_type'])}>
|
|
<SelectTrigger className="mt-1 h-9 w-full bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-xs">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent className="bg-[var(--dg-bg-secondary)] border-[var(--dg-border)]">
|
|
<SelectItem value="none" className="text-xs">无认证</SelectItem>
|
|
<SelectItem value="basic" className="text-xs">Basic Auth</SelectItem>
|
|
<SelectItem value="bearer" className="text-xs">Bearer Token</SelectItem>
|
|
<SelectItem value="custom" className="text-xs">自定义 Headers</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
{authType === 'basic' && (
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<Input value={user} onChange={e => setUser(e.target.value)} placeholder="用户名" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
|
<Input type="password" value={pass} onChange={e => setPass(e.target.value)} placeholder="密码" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
|
</div>
|
|
)}
|
|
{authType === 'bearer' && (
|
|
<div>
|
|
<Input type="password" value={token} onChange={e => setToken(e.target.value)} placeholder="Bearer Token" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
|
</div>
|
|
)}
|
|
{authType === 'custom' && (
|
|
<div className="space-y-2">
|
|
{headers.map((h, i) => (
|
|
<div key={i} className="grid grid-cols-2 gap-2">
|
|
<Input value={h.name} onChange={e => {
|
|
const next = [...headers];
|
|
next[i].name = e.target.value;
|
|
setHeaders(next);
|
|
}} placeholder="Header 名,如 X-Scope-OrgID" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
|
<Input value={h.value} onChange={e => {
|
|
const next = [...headers];
|
|
next[i].value = e.target.value;
|
|
setHeaders(next);
|
|
}} placeholder="Header 值" className="h-9 text-xs bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)]" />
|
|
</div>
|
|
))}
|
|
<Button variant="outline" size="sm" className="h-7 text-xs border-[var(--dg-border)]" onClick={() => setHeaders([...headers, { name: '', value: '' }])}>添加 Header</Button>
|
|
</div>
|
|
)}
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs text-[var(--dg-text-muted)]">跳过 TLS 校验</Label>
|
|
<Switch checked={skipTLS} onCheckedChange={setSkipTLS} />
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<Label className="text-xs text-[var(--dg-text-muted)]">设为默认</Label>
|
|
<Switch checked={isDefault} onCheckedChange={setIsDefault} />
|
|
</div>
|
|
</div>
|
|
<DialogFooter className="gap-2">
|
|
<Button variant="outline" size="sm" onClick={handleTest} disabled={testing}>
|
|
{testing ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Plug className="h-4 w-4 mr-1" />}
|
|
测试
|
|
</Button>
|
|
<Button size="sm" onClick={handleSave} disabled={saving}>
|
|
{saving && <Loader2 className="h-4 w-4 animate-spin mr-1" />}
|
|
保存
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|