quality / commitlint (push) Skipped
quality / changes (push) Successful in 9s
quality / docker-check (push) Skipped
quality / openapi (push) Successful in 39s
quality / web (push) Successful in 1m29s
quality / go (push) Successful in 1m3s
quality / bird2 (push) Successful in 15s
CD / quality (push) Successful in 3m43s
CD / publish (push) Failing after 8m29s
Единый kitDataGridTableLayout и ResourcePage/FrameDataGrid на всех списках. Календарь задач переведён на EventCalendar, lookup — на cascader, у операций появился вид доски. Удалены settings-7 и самописные DataGridSection/toolbar. Co-authored-by: Cursor <[email protected]>
272 lines
8.5 KiB
TypeScript
272 lines
8.5 KiB
TypeScript
import { useMemo, useState } from 'react'
|
|
import { CheckIcon, Network, SearchIcon, XIcon } from 'lucide-react'
|
|
|
|
import { Button } from '@evobgp/ui/components/button'
|
|
import { Input } from '@evobgp/ui/components/input'
|
|
import { Label } from '@evobgp/ui/components/label'
|
|
|
|
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
import { DataGridMonoCell, DataGridNameCell } from '@/components/data-grid-cell'
|
|
import { FormDrawer } from '@/components/form-drawer'
|
|
import { LoadingButton } from '@/components/loading-button'
|
|
import { SelectField } from '@/components/select-field'
|
|
import { StatusBadge } from '@/components/status-badge'
|
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
import type { FilterField, FilterQuery } from '@/components/reui/filters/filters-types'
|
|
import {
|
|
ResourcePage,
|
|
createTextFilterQuery,
|
|
type DataGridColumnDef,
|
|
} from '@/components/reui-kit'
|
|
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
|
import {
|
|
useApproveDiscoveredPeerMutation,
|
|
useRejectDiscoveredPeerMutation,
|
|
} from '@/queries/network'
|
|
import type { PeerDiscoveryRow, SpeakerRow } from '@/types/api'
|
|
|
|
/**
|
|
* Pending BGP peer discoveries — approve / reject.
|
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
|
* @see https://reui.io/preview/base/components/c-empty-1
|
|
*/
|
|
|
|
function speakerLabel(s: SpeakerRow): string {
|
|
if (s.role === 'master') {
|
|
const host = s.agent_domain ?? s.endpoint
|
|
return host ? `Плоскость · ${host}` : 'Плоскость управления'
|
|
}
|
|
return s.agent_domain ?? s.endpoint ?? `${s.id.slice(0, 8)}…`
|
|
}
|
|
|
|
const filterFields: FilterField[] = [
|
|
{
|
|
id: 'neighbor',
|
|
label: 'Сосед',
|
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
|
type: 'text',
|
|
placeholder: 'IP или ID соседа…',
|
|
},
|
|
]
|
|
|
|
interface NetworkDiscoveredPeersCardProps {
|
|
items: PeerDiscoveryRow[]
|
|
speakers: SpeakerRow[]
|
|
isLoading: boolean
|
|
isError: boolean
|
|
error: unknown
|
|
onRetry: () => void
|
|
}
|
|
|
|
export function NetworkDiscoveredPeersCard({
|
|
items,
|
|
speakers,
|
|
isLoading,
|
|
isError,
|
|
error,
|
|
onRetry,
|
|
}: NetworkDiscoveredPeersCardProps) {
|
|
const approveMutation = useApproveDiscoveredPeerMutation()
|
|
const rejectMutation = useRejectDiscoveredPeerMutation()
|
|
const [filterQuery, setFilterQuery] = useState<FilterQuery>(() =>
|
|
createTextFilterQuery('neighbor'),
|
|
)
|
|
const [approveTarget, setApproveTarget] = useState<PeerDiscoveryRow | null>(null)
|
|
const [rejectTarget, setRejectTarget] = useState<PeerDiscoveryRow | null>(null)
|
|
const [name, setName] = useState('')
|
|
const [speakerId, setSpeakerId] = useState<string | null>(null)
|
|
|
|
const speakerItems = useMemo(
|
|
() => [
|
|
{ value: '', label: 'Авто (с ноды обнаружения)' },
|
|
...speakers.map((s) => ({ value: s.id, label: speakerLabel(s) })),
|
|
],
|
|
[speakers],
|
|
)
|
|
|
|
const columns = useMemo<DataGridColumnDef<PeerDiscoveryRow>[]>(
|
|
() => [
|
|
{
|
|
id: 'neighbor_id',
|
|
accessorFn: (row) => row.neighbor_id || row.neighbor,
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="ID соседа" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<DataGridNameCell
|
|
icon={Network}
|
|
title={row.original.neighbor_id || '—'}
|
|
subtitle={row.original.neighbor}
|
|
/>
|
|
),
|
|
meta: { headerTitle: 'ID соседа' },
|
|
},
|
|
{
|
|
accessorKey: 'remote_asn',
|
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
|
cell: ({ row }) => (
|
|
<DataGridMonoCell className="text-xs">{row.original.remote_asn || '—'}</DataGridMonoCell>
|
|
),
|
|
meta: { headerTitle: 'ASN' },
|
|
},
|
|
{
|
|
accessorKey: 'session_state',
|
|
header: ({ column }) => (
|
|
<DataGridColumnHeader column={column} title="Состояние" />
|
|
),
|
|
cell: ({ row }) => (
|
|
<StatusBadge
|
|
status={row.original.session_state ?? '—'}
|
|
label={bgpSessionStateRu(row.original.session_state)}
|
|
/>
|
|
),
|
|
meta: { headerTitle: 'Состояние' },
|
|
},
|
|
{
|
|
id: 'actions',
|
|
enableSorting: false,
|
|
header: () => <span className="sr-only">Действия</span>,
|
|
cell: ({ row }) => (
|
|
<div className="flex items-center justify-end gap-1">
|
|
<Button
|
|
size="sm"
|
|
type="button"
|
|
variant="outline"
|
|
onClick={() => {
|
|
setApproveTarget(row.original)
|
|
setName('')
|
|
setSpeakerId(row.original.speaker_id ?? null)
|
|
}}
|
|
>
|
|
<CheckIcon />
|
|
Одобрить
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
type="button"
|
|
variant="ghost"
|
|
onClick={() => setRejectTarget(row.original)}
|
|
>
|
|
<XIcon />
|
|
Отклонить
|
|
</Button>
|
|
</div>
|
|
),
|
|
},
|
|
],
|
|
[],
|
|
)
|
|
|
|
function getFilterFieldValue(item: PeerDiscoveryRow, field: string): unknown {
|
|
if (field === 'neighbor') {
|
|
return `${item.neighbor_id ?? ''} ${item.neighbor}`
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
async function confirmApprove() {
|
|
if (!approveTarget) return
|
|
await approveMutation.mutateAsync({
|
|
id: approveTarget.id,
|
|
body: {
|
|
name: name.trim() || undefined,
|
|
bgp_speaker_id: speakerId || null,
|
|
},
|
|
})
|
|
setApproveTarget(null)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<ResourcePage
|
|
title="На одобрение"
|
|
description="Новые BGP-клиенты, подключившиеся к dynamic listener (карантин без export)"
|
|
filterFields={filterFields}
|
|
filterQuery={filterQuery}
|
|
onFilterQueryChange={setFilterQuery}
|
|
onClearFilters={() => setFilterQuery(createTextFilterQuery('neighbor'))}
|
|
getFilterFieldValue={getFilterFieldValue}
|
|
columns={columns}
|
|
data={items}
|
|
getRowId={(row) => row.id}
|
|
isLoading={isLoading}
|
|
isError={isError}
|
|
error={error instanceof Error ? error : null}
|
|
onRetry={onRetry}
|
|
pinLastColumn
|
|
emptyState={{
|
|
title: 'Нет ожидающих пиров',
|
|
description:
|
|
'Включите peer discovery в параметрах BIRD и задайте CIDR-диапазоны. Новые сессии появятся здесь.',
|
|
}}
|
|
/>
|
|
|
|
<FormDrawer
|
|
open={!!approveTarget}
|
|
onOpenChange={(open) => {
|
|
if (!open) setApproveTarget(null)
|
|
}}
|
|
title="Одобрить пира"
|
|
description={
|
|
approveTarget
|
|
? `ID соседа ${approveTarget.neighbor_id || '—'} · ${approveTarget.neighbor} AS${approveTarget.remote_asn ?? '?'}`
|
|
: undefined
|
|
}
|
|
className="sm:max-w-sm"
|
|
footer={
|
|
<>
|
|
<Button variant="outline" type="button" onClick={() => setApproveTarget(null)}>
|
|
Отмена
|
|
</Button>
|
|
<LoadingButton
|
|
type="button"
|
|
loading={approveMutation.isPending}
|
|
onClick={() => void confirmApprove()}
|
|
>
|
|
Одобрить
|
|
</LoadingButton>
|
|
</>
|
|
}
|
|
>
|
|
<div className="flex flex-col gap-2">
|
|
<Label htmlFor="disc-name">Имя (опционально)</Label>
|
|
<Input
|
|
id="disc-name"
|
|
value={name}
|
|
onChange={(e) => setName(e.target.value)}
|
|
placeholder="client-edge-1"
|
|
/>
|
|
</div>
|
|
<SelectField
|
|
id="disc-speaker"
|
|
label="Спикер"
|
|
items={speakerItems}
|
|
value={speakerId ?? ''}
|
|
onValueChange={(v) => setSpeakerId(v || null)}
|
|
placeholder="Авто"
|
|
/>
|
|
</FormDrawer>
|
|
|
|
<ConfirmDialog
|
|
open={!!rejectTarget}
|
|
onOpenChange={(open) => {
|
|
if (!open) setRejectTarget(null)
|
|
}}
|
|
title="Отклонить пира?"
|
|
description={
|
|
rejectTarget
|
|
? `${rejectTarget.neighbor_id || rejectTarget.neighbor} больше не будет появляться в списке.`
|
|
: undefined
|
|
}
|
|
confirmLabel="Отклонить"
|
|
destructive
|
|
confirmLoading={rejectMutation.isPending}
|
|
onConfirm={() => {
|
|
if (!rejectTarget) return
|
|
void rejectMutation.mutateAsync(rejectTarget.id).then(() => setRejectTarget(null))
|
|
}}
|
|
/>
|
|
</>
|
|
)
|
|
}
|