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.
261 lines
8.4 KiB
TypeScript
261 lines
8.4 KiB
TypeScript
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>
|
||
)
|
||
}
|