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
484 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|