fix(frontend): 数据源编辑弹框去掉测试连接
测试结果用 toast 会跑到弹框外,改成保存后在列表页点测试;弹框只留取消/保存。
This commit is contained in:
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>
|
||||||
|
})
|
||||||
Reference in new issue
Block a user