fix(frontend): 数据源编辑弹框去掉测试连接

测试结果用 toast 会跑到弹框外,改成保存后在列表页点测试;弹框只留取消/保存。
This commit is contained in:
Codex committed 2026-10-28 21:51:33 +08:00
1 parent c1e21c20d8
commit 4f75ef8578
1 file changed
+288
@@ -0,0 +1,288 @@
import { memo, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Database, Globe, Loader2, Lock, Pencil, Plus, Server, Trash2, Zap } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { EmptyState } from '@/components/EmptyState'
import { clusterApi } from '@/api'
import { logsApi, type LogSource } from '@/api/logs'
import type { Cluster } from '@/types'
type SourceForm = Partial<LogSource> & { basicPass?: string; bearerToken?: string }
const emptyForm: SourceForm = { name: '', targetType: 'url', url: '', authType: 'none', access: [] }
/** 数据源管理 — 行卡片列表 + 新建/编辑 Dialog(平台部署的托管源只读,手动源可编辑/删除) */
export const SourcesManager = memo(function SourcesManager({ sources, onSaved }: {
sources: LogSource[]
onSaved: () => void
}) {
const { t } = useTranslation('logs')
const [clusters, setClusters] = useState<Cluster[]>([])
const [form, setForm] = useState<SourceForm | null>(null)
const [saving, setSaving] = useState(false)
const [testing, setTesting] = useState(false)
const [deleteTarget, setDeleteTarget] = useState<LogSource | null>(null)
useEffect(() => {
clusterApi.list({ page: 1, pageSize: 100 }).then(result => setClusters(result.list)).catch(() => setClusters([]))
}, [])
const addressOf = (source: Partial<LogSource>) => {
if (source.targetType === 'url') return source.url ?? ''
const clusterName = clusters.find(cluster => cluster.id === source.clusterID)?.name
return [clusterName, source.namespace, `${source.service || 'loki'}:${source.port || 3100}`].filter(Boolean).join(' / ')
}
const typeText = (source: Partial<LogSource>) => source.targetType === 'url' ? t('source.targetUrl') : t('source.targetClusterSvc')
const authText = (source: Partial<LogSource>) =>
source.authType === 'basic' ? t('source.authBasic')
: source.authType === 'bearer' ? t('source.authBearer')
: source.authType === 'headers' ? t('source.authHeaders')
: t('source.authNone')
function test(data: Partial<LogSource> & { basicPass?: string; bearerToken?: string }) {
setTesting(true)
logsApi.testSource(data)
.then(result => { if (result.success) toast.info(result.message); else toast.error(result.message) })
.catch(err => toast.error(err instanceof Error ? err.message : t('source.test')))
.finally(() => setTesting(false))
}
function save() {
if (!form || !form.name?.trim()) return
setSaving(true)
const action = form.id ? logsApi.updateSource(form.id, form) : logsApi.createSource(form)
action
.then(() => { setForm(null); onSaved() })
.catch(err => toast.error(err instanceof Error ? err.message : t('source.saveFailed')))
.finally(() => setSaving(false))
}
function remove() {
if (!deleteTarget) return
setSaving(true)
logsApi.deleteSource(deleteTarget.id)
.then(() => { setDeleteTarget(null); if (form?.id === deleteTarget.id) setForm(null); onSaved() })
.catch(err => toast.error(err instanceof Error ? err.message : t('source.saveFailed')))
.finally(() => setSaving(false))
}
const labelClass = 'text-sm font-medium'
return <section className="space-y-4" aria-label={t('source.sectionAria')}>
<p className="text-xs text-muted-foreground">{t('source.hint')}</p>
<Card className="border-border">
<CardHeader className="pb-3">
<div className="flex flex-wrap items-center justify-between gap-2">
<CardTitle className="flex items-center gap-2 text-base">
<Database className="size-4" />
{t('source.heading')}
<span className="text-sm font-normal text-muted-foreground">{t('source.countLabel', { count: sources.length })}</span>
</CardTitle>
<Button size="sm" onClick={() => setForm({ ...emptyForm })}>
<Plus className="mr-1 size-4" />
{t('source.create')}
</Button>
</div>
</CardHeader>
<CardContent className="pt-0">
{sources.length === 0 ? (
<EmptyState icon={Database} title={t('source.emptyTitle')} description={t('source.emptyDesc')} />
) : (
<div className="divide-y divide-border">
{sources.map(source => {
const managed = source.managed
const Icon = source.targetType === 'url' ? Globe : Server
return (
<div key={source.id} className="flex items-center justify-between gap-3 py-3 first:pt-0 last:pb-0">
<div className="flex min-w-0 items-center gap-3">
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-info/10 text-info">
<Icon className="size-4" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="truncate font-medium">{source.name}</span>
{source.isDefault && <Badge variant="secondary">{t('source.defaultBadge')}</Badge>}
{managed && (
<Badge variant="outline" className="gap-1 text-muted-foreground">
<Lock className="size-3" />
{t('source.managedBadge')}
</Badge>
)}
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{typeText(source)} · {addressOf(source)} · {authText(source)}
</p>
</div>
</div>
<div className="flex shrink-0 items-center gap-2">
<Button variant="outline" size="sm" disabled={testing} onClick={() => test(source)}>
{testing ? <Loader2 className="mr-1 size-3.5 animate-spin" /> : <Zap className="mr-1 size-3.5" />}
{testing ? t('source.testing') : t('source.test')}
</Button>
{!managed && (
<Button variant="outline" size="sm" onClick={() => setForm({ ...source, headers: Object.fromEntries((source.headerNames ?? []).map(name => [name, ''])) })}>
<Pencil className="mr-1 size-3.5" />
{t('source.edit')}
</Button>
)}
{!managed && (
<Button
variant="outline"
size="sm"
className="text-destructive hover:bg-destructive/10"
aria-label={t('source.deleteAria', { name: source.name })}
onClick={() => setDeleteTarget(source)}
>
<Trash2 className="size-3.5" />
</Button>
)}
</div>
</div>
)
})}
</div>
)}
</CardContent>
</Card>
{/* 新建 / 编辑弹窗 */}
<Dialog open={form !== null} onOpenChange={open => { if (!open && !saving) setForm(null) }}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{form?.id ? `${t('source.editTitle')} · ${form.name}` : t('source.createTitle')}</DialogTitle>
</DialogHeader>
{form && (
<div className="grid gap-4 sm:grid-cols-2">
<label className={labelClass}>
{t('source.nameLabel')}
<Input className="mt-1" value={form.name ?? ''} onChange={event => setForm({ ...form, name: event.target.value })} placeholder={t('source.namePlaceholder')} />
</label>
<div>
<span className={labelClass}>{t('source.typeLabel')}</span>
<div className="mt-1 flex gap-2">
<button type="button" aria-pressed={form.targetType === 'url'} className={`h-9 rounded-md border px-3 text-sm transition-colors ${form.targetType === 'url' ? 'border-primary bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:text-foreground'}`} onClick={() => setForm({ ...form, targetType: 'url' })}>{t('source.targetUrl')}</button>
<button type="button" aria-pressed={form.targetType === 'cluster-svc'} className={`h-9 rounded-md border px-3 text-sm transition-colors ${form.targetType === 'cluster-svc' ? 'border-primary bg-primary/10 font-medium text-primary' : 'text-muted-foreground hover:text-foreground'}`} onClick={() => setForm({ ...form, targetType: 'cluster-svc', service: form.service || 'loki', port: form.port || 3100 })}>{t('source.targetClusterSvc')}</button>
</div>
</div>
{form.targetType === 'url' ? (
<label className={`${labelClass} sm:col-span-2`}>
{t('source.addressLabel')}
<Input className="mt-1" value={form.url ?? ''} onChange={event => setForm({ ...form, url: event.target.value })} placeholder="http://10.0.0.8:3100" />
</label>
) : (
<>
<label className={labelClass}>
{t('source.clusterLabel')}
<select className="mt-1 block h-9 w-full rounded-md border bg-background px-3 text-sm" value={form.clusterID ?? ''} onChange={event => setForm({ ...form, clusterID: Number(event.target.value) })}>
<option value="">{t('source.clusterPlaceholder')}</option>
{clusters.map(cluster => <option key={cluster.id} value={cluster.id}>{cluster.name}</option>)}
</select>
</label>
<label className={labelClass}>
{t('source.namespaceLabel')}
<Input className="mt-1" value={form.namespace ?? ''} onChange={event => setForm({ ...form, namespace: event.target.value })} placeholder="log" />
</label>
<label className={labelClass}>
{t('source.serviceLabel')}
<Input className="mt-1" value={form.service ?? ''} onChange={event => setForm({ ...form, service: event.target.value })} placeholder="loki" />
</label>
<label className={labelClass}>
{t('source.portLabel')}
<Input className="mt-1" type="number" value={form.port ?? 3100} onChange={event => setForm({ ...form, port: Number(event.target.value) })} />
</label>
</>
)}
<label className={labelClass}>
{t('source.authLabel')}
<select className="mt-1 block h-9 w-full rounded-md border bg-background px-3 text-sm" value={form.authType ?? 'none'} onChange={event => setForm({ ...form, authType: event.target.value })}>
<option value="none">{t('source.authNone')}</option>
<option value="basic">{t('source.authBasic')}</option>
<option value="bearer">{t('source.authBearer')}</option>
<option value="headers">{t('source.authHeaders')}</option>
</select>
</label>
{form.authType === 'basic' && (
<>
<label className={labelClass}>
{t('source.usernameLabel')}
<Input className="mt-1" value={form.basicUser ?? ''} onChange={event => setForm({ ...form, basicUser: event.target.value })} />
</label>
<label className={labelClass}>
{t('source.passwordLabel')}
<Input className="mt-1" type="password" value={form.basicPass ?? ''} onChange={event => setForm({ ...form, basicPass: event.target.value })} />
</label>
</>
)}
{form.authType === 'bearer' && (
<label className={labelClass}>
{t('source.tokenLabel')}
<Input className="mt-1" type="password" value={form.bearerToken ?? ''} onChange={event => setForm({ ...form, bearerToken: event.target.value })} />
</label>
)}
{form.authType === 'headers' && (
<label className={`${labelClass} sm:col-span-2`}>
{t('source.headersLabel')}
<Textarea
key={form.id ?? 'new'}
className="mt-1 font-mono text-sm"
rows={3}
spellCheck={false}
defaultValue={JSON.stringify(form.headers ?? {}, null, 2)}
onBlur={event => {
try {
const headers = JSON.parse(event.target.value) as Record<string, string>
if (!headers || Array.isArray(headers) || typeof headers !== 'object' || Object.values(headers).some(value => typeof value !== 'string')) throw Error()
setForm(current => ({ ...current!, headers }))
} catch {
toast.error(t('source.headersInvalid'))
}
}}
placeholder='{"X-Scope-OrgID":"tenant"}'
/>
</label>
)}
<div className="flex items-center gap-6 sm:col-span-2">
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={!!form.skipTLSVerify} onCheckedChange={checked => setForm({ ...form, skipTLSVerify: checked === true })} />
{t('source.skipTLS')}
</label>
<label className="flex items-center gap-2 text-sm">
<Checkbox checked={!!form.isDefault} onCheckedChange={checked => setForm({ ...form, isDefault: checked === true })} />
{t('source.setDefault')}
</label>
</div>
</div>
)}
<DialogFooter className="gap-2 sm:justify-between">
{form?.id ? (
<Button variant="outline" className="text-destructive hover:bg-destructive/10" disabled={saving} onClick={() => setDeleteTarget(sources.find(source => source.id === form.id) ?? null)}>
<Trash2 className="mr-1 size-4" />
{t('source.remove')}
</Button>
) : <span />}
<div className="flex gap-2">
<Button variant="outline" disabled={saving} onClick={() => setForm(null)}>{t('source.cancel')}</Button>
<Button disabled={saving || !form?.name?.trim()} onClick={save}>{t('source.save')}</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={deleteTarget !== null}
onOpenChange={open => { if (!open) setDeleteTarget(null) }}
name={deleteTarget?.name}
title={t('source.deleteTitle')}
description={t('source.deleteDesc', { name: deleteTarget?.name ?? '' })}
loading={saving}
onConfirm={() => void remove()}
/>
</section>
})