Files
EvoBGP/web/src/lib/components/network/NetworkPeersCard.svelte
T
Denozordec c66cc9317d
CI / changes (push) Successful in 6s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m42s
feat(web): refactor directories and network pages with improved state management and UI components
- Updated imports to utilize core UI components for better maintainability.
- Refactored state management for communities and DoH profiles, enhancing loading and error handling.
- Introduced derived states for KPI cards, providing better insights into community and DoH profile counts.
- Streamlined loading functions to improve data fetching efficiency and user experience.
- Enhanced UI layout with new icons and improved component structure for clarity.
2026-05-20 14:13:32 +07:00

291 lines
9.0 KiB
Svelte

<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { PeerRow, BgpPeerCreate, SpeakerRow } from '$lib/api/types.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { Switch } from '$lib/ui/core/switch/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
items: PeerRow[];
speakers: SpeakerRow[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let {
items,
speakers,
loading = false,
initialLoading = false,
error = null,
onRefresh
}: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<PeerRow | null>(null);
let form = $state<BgpPeerCreate & { bgp_speaker_id?: string | null }>({
name: '',
neighbor: '',
remote_asn: 0,
bgp_speaker_id: null,
enabled: true
});
let saving = $state(false);
let toggleId = $state<string | null>(null);
const speakerById = $derived.by(() => new Map(speakers.map((s) => [s.id, s])));
const columns = [
{ id: 'name', label: 'Имя', sortable: true, sortValue: (p: PeerRow) => p.name ?? '' },
{ id: 'neighbor', label: 'Адрес', sortable: true, sortValue: (p: PeerRow) => p.neighbor },
{
id: 'remote_asn',
label: 'Remote ASN',
sortable: true,
sortValue: (p: PeerRow) => p.remote_asn ?? 0
},
{ id: 'enabled', label: 'Вкл.', class: 'w-[4.5rem] text-center' },
{ id: 'session_state', label: 'Состояние сессии' },
{ id: 'speaker', label: 'Спикер' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function speakerLabelById(id: string | null | undefined) {
if (!id) return '—';
return speakerById.get(id)?.endpoint ?? id;
}
function sessionBadge(state: string) {
if (state === 'Established') return 'default';
if (state === 'Active' || state === 'Connect') return 'secondary';
return 'outline';
}
function openCreate() {
editTarget = null;
form = { name: '', neighbor: '', remote_asn: 0, bgp_speaker_id: null, enabled: true };
dialogOpen = true;
}
function openEdit(p: PeerRow) {
editTarget = p;
form = {
name: p.name ?? '',
neighbor: p.neighbor,
remote_asn: p.remote_asn ?? 0,
bgp_speaker_id: p.bgp_speaker_id,
enabled: p.enabled !== false
};
dialogOpen = true;
}
function requestDelete(p: PeerRow) {
void confirm({
title: 'Удалить пира?',
description: p.neighbor,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/peers/${p.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Пир удалён');
await onRefresh();
}
});
}
async function setEnabled(p: PeerRow, enabled: boolean) {
toggleId = p.id;
try {
await apiMutate(`/v1/peers/${p.id}`, 'PATCH', { enabled });
notify.success(enabled ? 'Пир включён' : 'Пир отключён');
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
toggleId = null;
}
}
async function save() {
if (!form.neighbor.trim()) {
notify.error('Укажите адрес соседа');
return;
}
if (!form.remote_asn || form.remote_asn <= 0) {
notify.error('Remote ASN должен быть больше 0');
return;
}
saving = true;
try {
if (editTarget) {
await apiMutate(`/v1/peers/${editTarget.id}`, 'PATCH', form);
notify.success('Пир обновлён');
} else {
await apiMutate('/v1/peers', 'POST', {
...form,
enabled: form.enabled !== false
});
notify.success('Пир создан');
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">BGP-пиры</CardTitle>
<CardDescription>Настройка BGP-соседей и привязка к спикерам</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(p) => p.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет BGP-пиров"
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
>
{#snippet cell({ row: p, column })}
{#if column.id === 'name'}
<span>{p.name?.trim() || '—'}</span>
{:else if column.id === 'neighbor'}
<span class="font-mono text-sm">{p.neighbor}</span>
{:else if column.id === 'remote_asn'}
<span class="font-mono text-sm">{p.remote_asn ?? '—'}</span>
{:else if column.id === 'enabled'}
<div class="flex justify-center">
<Switch
size="sm"
checked={p.enabled !== false}
disabled={loading || toggleId === p.id}
onCheckedChange={(v) => setEnabled(p, v)}
/>
</div>
{:else if column.id === 'session_state'}
<Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge>
{:else if column.id === 'speaker'}
<span class="text-xs text-muted-foreground">{speakerLabelById(p.bgp_speaker_id)}</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(p)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(p)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать пира' : 'Новый пир'}</DialogTitle>
<DialogDescription>BGP-сосед для установки сессии</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<FormField label="Имя пира (опционально)" id="p-name">
<AppInput id="p-name" placeholder="Core-RTR-1" bind:value={form.name} />
</FormField>
<FormField label="Адрес соседа" id="p-neighbor" required>
<AppInput id="p-neighbor" placeholder="192.0.2.1" bind:value={form.neighbor} />
</FormField>
<FormField label="Remote ASN" id="p-asn" required>
<AppInput id="p-asn" type="number" placeholder="65000" bind:value={form.remote_asn} />
</FormField>
<FormField label="Спикер (опционально)" id="p-speaker">
<Select
type="single"
value={form.bgp_speaker_id ?? ''}
onValueChange={(v) => {
form = { ...form, bgp_speaker_id: v || null };
}}
>
<SelectTrigger id="p-speaker" class="w-full">
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Не выбрано</SelectItem>
{#each speakers as s (s.id)}
<SelectItem value={s.id}>{s.endpoint} ({s.id.slice(0, 8)}…)</SelectItem>
{/each}
</SelectContent>
</Select>
</FormField>
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="p-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Выключенный пир не попадает в конфиг BIRD до следующей ревизии.
</p>
</div>
<Switch
id="p-enabled"
class="shrink-0"
checked={form.enabled !== false}
onCheckedChange={(v) => {
form = { ...form, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>