Files
CDNManager/apps/web/src/routes/_auth/nodes.tsx
T
Denozordec 8700dc2957
quality / commitlint (push) Skipped
quality / changes (push) Successful in 5s
quality / docker-check (push) Skipped
CD / update-wiki (push) Successful in 6s
quality / web (push) Successful in 53s
quality / api (push) Successful in 49s
CD / quality (push) Successful in 1m57s
CD / publish (push) Successful in 27s
Enhance CDN Manager: Add Cloudflare API token support, update documentation, and introduce new routes for managing nodes, aliases, and topology. Improve UI components and status badges for better user experience.
2026-09-04 12:49:51 +07:00

484 lines
15 KiB
TypeScript

import { createFileRoute, Link } from '@tanstack/react-router'
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import type { ColumnDef } from '@tanstack/react-table'
import type { Filter, FilterFieldConfig } from '@/components/reui/filters'
import {
CloudIcon,
MapPinIcon,
PlusIcon,
RefreshCwIcon,
ServerIcon,
} from 'lucide-react'
import { toast } from 'sonner'
import type { Node, NodeRole } from '@cdnmanager/shared'
import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header'
import { ResourcePage } from '@/components/reui-kit'
import { StatusBadge } from '@/components/status-badge'
import { FormSheet } from '@/components/form-sheet'
import { ConfirmDialog } from '@/components/confirm-dialog'
import { Button } from '@cdnmanager/ui/components/button'
import { Input } from '@cdnmanager/ui/components/input'
import { Label } from '@cdnmanager/ui/components/label'
import { SelectField } from '@/components/select-field'
import { queryClient } from '@/lib/query-client'
import {
createNode,
locationsQueryOptions,
nodesQueryOptions,
patchNode,
previewHostname,
removeNode,
zonesQueryOptions,
} from '@/queries/fleet'
export const Route = createFileRoute('/_auth/nodes')({
loader: () =>
Promise.all([
queryClient.ensureQueryData(nodesQueryOptions()),
queryClient.ensureQueryData(locationsQueryOptions()),
queryClient.ensureQueryData(zonesQueryOptions()),
]),
component: NodesPage,
})
const formSchema = z.object({
zoneId: z.string().min(1, 'Выберите зону'),
locationId: z.string().min(1, 'Выберите локацию'),
role: z.enum(['hub', 'gw', 'edge', 'ix']),
indexNum: z.number().int().min(1).max(99),
ipv4: z.string().min(7),
ipv6: z.string().optional(),
providerTag: z.string().optional(),
notes: z.string().optional(),
hostname: z.string().optional(),
})
type FormValues = z.infer<typeof formSchema>
const ROLES: { value: NodeRole; label: string }[] = [
{ value: 'hub', label: 'hub' },
{ value: 'gw', label: 'gw' },
{ value: 'edge', label: 'edge' },
{ value: 'ix', label: 'ix' },
]
function NodesPage() {
const qc = useQueryClient()
const { data: nodes = [], isLoading, isError, error, refetch } = useQuery(
nodesQueryOptions(),
)
const { data: locations = [] } = useQuery(locationsQueryOptions())
const { data: zones = [] } = useQuery(zonesQueryOptions())
const [filters, setFilters] = useState<Filter[]>([])
const [sheetOpen, setSheetOpen] = useState(false)
const [editing, setEditing] = useState<Node | null>(null)
const [deleteId, setDeleteId] = useState<string | null>(null)
const [preview, setPreview] = useState('')
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
zoneId: '',
locationId: '',
role: 'gw',
indexNum: 1,
ipv4: '',
ipv6: '',
providerTag: '',
notes: '',
hostname: '',
},
})
const watchZone = form.watch('zoneId')
const watchLoc = form.watch('locationId')
const watchRole = form.watch('role')
const watchIndex = form.watch('indexNum')
const watchProvider = form.watch('providerTag')
async function refreshPreview() {
if (!watchZone || !watchLoc || !watchRole) return
try {
const res = await previewHostname({
zoneId: watchZone,
locationId: watchLoc,
role: watchRole,
indexNum: Number(watchIndex) || 1,
providerTag: watchProvider || undefined,
})
setPreview(res.hostname)
} catch {
setPreview('')
}
}
const saveMutation = useMutation({
mutationFn: async (values: FormValues) => {
if (editing) {
return patchNode(editing.id, {
locationId: values.locationId,
role: values.role,
indexNum: values.indexNum,
ipv4: values.ipv4,
ipv6: values.ipv6 || null,
providerTag: values.providerTag || null,
notes: values.notes || null,
hostname: values.hostname || undefined,
})
}
return createNode({
zoneId: values.zoneId,
locationId: values.locationId,
role: values.role,
indexNum: values.indexNum,
ipv4: values.ipv4,
ipv6: values.ipv6 || null,
providerTag: values.providerTag || null,
notes: values.notes || null,
hostname: values.hostname || undefined,
})
},
onSuccess: () => {
toast.success(editing ? 'Нода обновлена' : 'Нода создана')
setSheetOpen(false)
setEditing(null)
form.reset()
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => removeNode(id),
onSuccess: () => {
toast.success('Нода удалена')
setDeleteId(null)
void qc.invalidateQueries({ queryKey: ['fleet'] })
},
onError: (err: Error) => toast.error(err.message),
})
const filterFields: FilterFieldConfig[] = useMemo(
() => [
{
key: 'q',
label: 'Поиск',
type: 'text',
placeholder: 'hostname / provider',
},
{
key: 'locationCode',
label: 'Локация',
type: 'select',
options: locations.map((l) => ({ value: l.code, label: l.code })),
},
{
key: 'role',
label: 'Роль',
type: 'select',
options: ROLES.map((r) => ({ value: r.value, label: r.label })),
},
{
key: 'syncStatus',
label: 'Sync',
type: 'select',
options: [
{ value: 'ok', label: 'ok' },
{ value: 'drift', label: 'drift' },
{ value: 'missing', label: 'missing' },
{ value: 'pending', label: 'pending' },
{ value: 'error', label: 'error' },
],
},
],
[locations],
)
const columns: ColumnDef<Node, unknown>[] = [
{
accessorKey: 'hostname',
header: 'FQDN',
cell: ({ row }) => (
<div className="flex items-center gap-2">
<ServerIcon className="text-muted-foreground size-4 shrink-0" />
<span className="font-medium">{row.original.hostname}</span>
</div>
),
},
{
accessorKey: 'locationCode',
header: 'Локация',
cell: ({ row }) => (
<span className="flex items-center gap-1.5 text-sm">
<MapPinIcon className="size-3.5" />
{row.original.locationCode ?? '—'}
</span>
),
},
{
accessorKey: 'role',
header: 'Роль',
},
{
id: 'ip',
header: 'IPv4 / IPv6',
cell: ({ row }) => {
const v4 = row.original.addresses.find((a) => a.family === 'v4')?.ip
const v6 = row.original.addresses.find((a) => a.family === 'v6')?.ip
return (
<div className="flex flex-col gap-0.5 font-mono text-xs tabular-nums">
<span>{v4 ?? '—'}</span>
{v6 ? <span className="text-muted-foreground">{v6}</span> : null}
</div>
)
},
},
{
accessorKey: 'providerTag',
header: 'Provider',
cell: ({ row }) => row.original.providerTag || '—',
},
{
accessorKey: 'syncStatus',
header: 'Sync',
cell: ({ row }) => <StatusBadge status={row.original.syncStatus} />,
},
{
accessorKey: 'aliasCount',
header: 'CNAME→',
cell: ({ row }) => (
<span className="tabular-nums">{row.original.aliasCount ?? 0}</span>
),
},
{
id: 'actions',
header: '',
cell: ({ row }) => (
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
const n = row.original
setEditing(n)
form.reset({
zoneId: n.zoneId,
locationId: n.locationId,
role: n.role as NodeRole,
indexNum: n.indexNum,
ipv4: n.addresses.find((a) => a.family === 'v4')?.ip ?? '',
ipv6: n.addresses.find((a) => a.family === 'v6')?.ip ?? '',
providerTag: n.providerTag ?? '',
notes: n.notes ?? '',
hostname: n.hostname,
})
setPreview(n.hostname)
setSheetOpen(true)
}}
>
Изменить
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteId(row.original.id)}
>
Удалить
</Button>
</div>
),
},
]
return (
<PageShell>
<PageHeader
title="Ноды"
description="Канонические хосты A/AAAA (железо CHR/VPS)"
actions={
<Button
size="sm"
onClick={() => {
setEditing(null)
form.reset({
zoneId: zones[0]?.id ?? '',
locationId: locations[0]?.id ?? '',
role: 'gw',
indexNum: 1,
ipv4: '',
ipv6: '',
providerTag: '',
notes: '',
hostname: '',
})
setPreview('')
setSheetOpen(true)
void refreshPreview()
}}
disabled={zones.length === 0}
>
<PlusIcon data-icon="inline-start" />
Добавить ноду
</Button>
}
/>
{zones.length === 0 ? (
<p className="text-muted-foreground text-sm">
Сначала добавьте зону на странице{' '}
<Link to="/zones" className="text-primary underline">
Зоны / Sync
</Link>
.
</p>
) : null}
<ResourcePage
title="Инвентарь нод"
description="Desired-state канонических FQDN"
hideHeader
filterFields={filterFields}
filters={filters}
onFiltersChange={setFilters}
onClearFilters={() => setFilters([])}
getFilterFieldValue={(item, field) => {
if (field === 'q') return `${item.hostname} ${item.providerTag ?? ''}`
if (field === 'locationCode') return item.locationCode
return (item as Record<string, unknown>)[field]
}}
columns={columns}
data={nodes}
getRowId={(r) => r.id}
isLoading={isLoading}
isError={isError}
error={error}
onRetry={() => refetch()}
emptyState={{
title: 'Нет нод',
description: 'Создайте первую каноническую ноду флота',
}}
/>
<FormSheet
open={sheetOpen}
onOpenChange={setSheetOpen}
title={editing ? 'Изменить ноду' : 'Новая нода'}
description="Имя собирается по шаблону зоны: {loc}-{role}{nn}.{zone}"
form={form}
onSubmit={async (v) => {
await saveMutation.mutateAsync(v)
}}
footer={
<Button type="submit" disabled={saveMutation.isPending}>
{saveMutation.isPending ? 'Сохранение…' : 'Сохранить'}
</Button>
}
>
<div className="flex flex-col gap-4">
{!editing ? (
<div className="flex flex-col gap-2">
<Label>Зона</Label>
<SelectField
value={form.watch('zoneId')}
onValueChange={(v) => {
form.setValue('zoneId', v ?? '')
void refreshPreview()
}}
placeholder="Зона"
options={zones.map((z) => ({ value: z.id, label: z.name }))}
/>
</div>
) : null}
<div className="flex flex-col gap-2">
<Label>Локация</Label>
<SelectField
value={form.watch('locationId')}
onValueChange={(v) => {
form.setValue('locationId', v ?? '')
void refreshPreview()
}}
placeholder="Локация"
options={locations.map((l) => ({
value: l.id,
label: `${l.code}${l.name}`,
}))}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="flex flex-col gap-2">
<Label>Роль</Label>
<SelectField
value={form.watch('role')}
onValueChange={(v) => {
form.setValue('role', (v as NodeRole) ?? 'gw')
void refreshPreview()
}}
options={ROLES.map((r) => ({ value: r.value, label: r.label }))}
/>
</div>
<div className="flex flex-col gap-2">
<Label>Индекс</Label>
<Input
type="number"
min={1}
max={99}
{...form.register('indexNum', { valueAsNumber: true })}
onBlur={() => void refreshPreview()}
/>
</div>
</div>
<div className="bg-muted/40 flex items-center gap-2 rounded-lg border px-3 py-2 text-sm">
<CloudIcon className="size-4 shrink-0" />
<span className="text-muted-foreground">Preview:</span>
<code className="font-medium">{preview || '—'}</code>
<Button
type="button"
variant="ghost"
size="sm"
className="ml-auto"
onClick={() => void refreshPreview()}
>
<RefreshCwIcon className="size-3.5" />
</Button>
</div>
<div className="flex flex-col gap-2">
<Label>IPv4</Label>
<Input {...form.register('ipv4')} placeholder="198.51.100.10" />
</div>
<div className="flex flex-col gap-2">
<Label>IPv6 (опц.)</Label>
<Input {...form.register('ipv6')} placeholder="2001:db8::10" />
</div>
<div className="flex flex-col gap-2">
<Label>Provider tag</Label>
<Input {...form.register('providerTag')} placeholder="ih / vv" />
</div>
<div className="flex flex-col gap-2">
<Label>Заметки</Label>
<Input {...form.register('notes')} />
</div>
</div>
</FormSheet>
<ConfirmDialog
open={Boolean(deleteId)}
onOpenChange={(o) => !o && setDeleteId(null)}
title="Удалить ноду?"
description="Алиасы, указывающие на ноду, должны быть удалены или переназначены заранее."
confirmLabel="Удалить"
onConfirm={() => deleteId && deleteMutation.mutate(deleteId)}
/>
</PageShell>
)
}