Files
EvoBGP/apps/web/src/components/modules/module-cdn-source-dialog.tsx
T
Denozordec 452f6b2db0 feat: refactor components to utilize SelectField for improved UI consistency
Updated various components to replace traditional select implementations with the new SelectField component. This change enhances the user interface by providing a more consistent layout and improved accessibility. Additionally, refactored the dashboard and operations pages to utilize DataGridCard for better organization of content, streamlining the overall user experience.
2026-07-09 11:53:43 +07:00

261 lines
8.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useEffect, useMemo, useState } from 'react'
import { toast } from 'sonner'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@evobgp/ui/components/dialog'
import { Input } from '@evobgp/ui/components/input'
import { Label } from '@evobgp/ui/components/label'
import { SelectField } from '@/components/select-field'
import { Button } from '@evobgp/ui/components/button'
import { LoadingButton } from '@/components/loading-button'
import { CommunitySelect } from '@/components/modules/community-select'
import { ApiError, apiMutate } from '@/lib/api-client'
import { normalizeCdnSourceKind } from '@/lib/modules/helpers'
import type { BgpCommunity, CdnPreviewResponse, CdnSource, CdnSourceCreate } from '@/types/api'
const CDN_KIND_ITEMS = [
{ value: 'plaintext', label: 'plaintext' },
{ value: 'json', label: 'json' },
] as const
interface ModuleCdnSourceDialogProps {
open: boolean
moduleId: string
edit: CdnSource | null
communities: BgpCommunity[]
onOpenChange: (open: boolean) => void
onSaved: () => void | Promise<void>
}
type CdnForm = CdnSourceCreate & { refresh_interval_sec?: number | null }
export function ModuleCdnSourceDialog({
open,
moduleId,
edit,
communities,
onOpenChange,
onSaved,
}: ModuleCdnSourceDialogProps) {
const [saving, setSaving] = useState(false)
const [previewLoading, setPreviewLoading] = useState(false)
const [previewItems, setPreviewItems] = useState<string[]>([])
const [previewTotal, setPreviewTotal] = useState(0)
const [previewTruncated, setPreviewTruncated] = useState(false)
const [previewError, setPreviewError] = useState<string | null>(null)
const [previewOk, setPreviewOk] = useState(false)
const [form, setForm] = useState<CdnForm>({
url: '',
source_kind: 'plaintext',
prefix_path: '',
community_id: null,
})
const kindItems = useMemo(() => [...CDN_KIND_ITEMS], [])
function clearPreview() {
setPreviewLoading(false)
setPreviewItems([])
setPreviewTotal(0)
setPreviewTruncated(false)
setPreviewError(null)
setPreviewOk(false)
}
useEffect(() => {
if (!open) {
clearPreview()
return
}
setForm(
edit
? {
url: edit.url,
source_kind: normalizeCdnSourceKind(edit.source_kind),
prefix_path: edit.prefix_path ?? '',
community_id: edit.community_id,
refresh_interval_sec: edit.refresh_interval_sec,
}
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null },
)
clearPreview()
}, [open, edit])
async function previewCdn() {
const urlTrim = form.url.trim()
if (!urlTrim) {
toast.error('Укажите URL')
return
}
setPreviewLoading(true)
setPreviewError(null)
setPreviewOk(false)
try {
const res = await apiMutate<CdnPreviewResponse>(
`/v1/modules/${moduleId}/cdn-sources/preview`,
'POST',
{
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? '',
},
)
setPreviewItems(res.items)
setPreviewTotal(res.total)
setPreviewTruncated(res.truncated)
setPreviewOk(true)
} catch (e) {
setPreviewError(e instanceof ApiError ? e.message : String(e))
setPreviewItems([])
setPreviewTotal(0)
setPreviewTruncated(false)
setPreviewOk(false)
} finally {
setPreviewLoading(false)
}
}
async function save() {
const urlTrim = form.url.trim()
if (!urlTrim) {
toast.error('Укажите URL')
return
}
setSaving(true)
try {
const body = {
...form,
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? '',
}
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body)
toast.success('Источник обновлён')
} else {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body)
toast.success('Источник добавлен')
}
clearPreview()
onOpenChange(false)
await onSaved()
} catch (e) {
toast.error(e instanceof ApiError ? e.message : String(e))
} finally {
setSaving(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-url">URL</Label>
<Input
id="cdn-url"
placeholder="https://example.com/list.txt"
value={form.url}
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
/>
</div>
<SelectField
id="cdn-kind"
label="Тип источника"
items={kindItems}
value={form.source_kind}
onValueChange={(v) => v && setForm((s) => ({ ...s, source_kind: v }))}
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
id="cdn-prefix-path"
placeholder="напр. prefixes[] или data.items[].cidr"
value={form.prefix_path ?? ''}
onChange={(e) => setForm((s) => ({ ...s, prefix_path: e.target.value }))}
/>
{form.source_kind === 'json' && !form.prefix_path?.trim() ? (
<p className="text-xs text-muted-foreground">
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
</p>
) : null}
</div>
<CommunitySelect
id="cdn-comm"
label="Community"
value={form.community_id ?? null}
onValueChange={(v) => setForm((s) => ({ ...s, community_id: v }))}
communities={communities}
nullable
/>
<div className="flex flex-col gap-1.5">
<Label htmlFor="cdn-interval">Интервал обновления (сек)</Label>
<Input
id="cdn-interval"
type="number"
placeholder="3600"
value={form.refresh_interval_sec ?? ''}
onChange={(e) =>
setForm((s) => ({
...s,
refresh_interval_sec: e.target.value ? Number(e.target.value) : null,
}))
}
/>
</div>
<div className="flex flex-col gap-2 rounded-lg border border-border p-3">
<div className="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onClick={() => void previewCdn()}
disabled={previewLoading}
>
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
</Button>
{previewError ? (
<span className="text-sm text-destructive">{previewError}</span>
) : previewOk ? (
<span className="text-sm text-muted-foreground">
Всего: {previewTotal}
{previewTruncated ? (
<span className="text-amber-600 dark:text-amber-500"> (обрезано)</span>
) : null}
</span>
) : null}
</div>
{previewItems.length > 0 ? (
<ul className="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
{previewItems.map((item, i) => (
<li key={`${i}-${item}`} className="py-0.5">
{item}
</li>
))}
</ul>
) : null}
</div>
</div>
<DialogFooter>
<LoadingButton variant="outline" onClick={() => onOpenChange(false)}>
Отмена
</LoadingButton>
<LoadingButton loading={saving} onClick={() => void save()}>
{edit ? 'Сохранить' : 'Добавить'}
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
)
}