feat: improve peer speaker assignment UX in network settings

Replace manual speaker id input with a validated speaker selector and clearer speaker labels so operators can configure peers with fewer input mistakes and better context.

Made-with: Cursor
This commit is contained in:
Denozordec
2026-04-07 21:08:00 +07:00
parent 2da6d49368
commit f877ec89a7
+46 -6
View File
@@ -14,6 +14,12 @@
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js'; import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js'; import { Label } from '$lib/components/ui/label/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '$lib/components/ui/select/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
import { import {
Dialog, Dialog,
@@ -73,6 +79,12 @@
let speakerForm = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' }); let speakerForm = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
let speakerSaving = $state(false); let speakerSaving = $state(false);
let applyingId = $state<string | null>(null); let applyingId = $state<string | null>(null);
const speakerById = $derived.by(() => new Map(speakers.map((s) => [s.id, s])));
function speakerLabelById(id: string | null | undefined) {
if (!id) return '—';
return speakerById.get(id)?.endpoint ?? id;
}
async function loadAll() { async function loadAll() {
peersLoading = true; peersLoading = true;
@@ -123,8 +135,12 @@
} }
} }
async function savePeer() { async function savePeer() {
if (!peerForm.neighbor || !peerForm.remote_asn) { if (!peerForm.neighbor.trim()) {
toast.error('Укажите адрес и ASN'); toast.error('Укажите адрес соседа');
return;
}
if (!peerForm.remote_asn || peerForm.remote_asn <= 0) {
toast.error('Remote ASN должен быть больше 0');
return; return;
} }
peerSaving = true; peerSaving = true;
@@ -271,7 +287,7 @@
<TableCell> <TableCell>
<Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge> <Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge>
</TableCell> </TableCell>
<TableCell class="text-muted-foreground font-mono text-xs">{p.bgp_speaker_id ? p.bgp_speaker_id.slice(0, 8) + '…' : '—'}</TableCell> <TableCell class="text-muted-foreground text-xs">{speakerLabelById(p.bgp_speaker_id)}</TableCell>
<TableCell> <TableCell>
<div class="flex gap-1"> <div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openPeerEdit(p)}><Pencil class="size-3.5" /></Button> <Button variant="ghost" size="icon-sm" onclick={() => openPeerEdit(p)}><Pencil class="size-3.5" /></Button>
@@ -325,7 +341,13 @@
<TableCell class="font-mono text-xs text-muted-foreground">{s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'}</TableCell> <TableCell class="font-mono text-xs text-muted-foreground">{s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'}</TableCell>
<TableCell> <TableCell>
<div class="flex gap-1"> <div class="flex gap-1">
<Button variant="outline" size="xs" onclick={() => applySpeaker(s.id)} disabled={applyingId === s.id}> <Button
variant="outline"
size="xs"
title="Запустить применение ревизии на спикере"
onclick={() => applySpeaker(s.id)}
disabled={applyingId === s.id}
>
<Play class="size-3" /> <Play class="size-3" />
{applyingId === s.id ? 'Apply…' : 'Apply'} {applyingId === s.id ? 'Apply…' : 'Apply'}
</Button> </Button>
@@ -365,8 +387,26 @@
<Input id="p-asn" type="number" placeholder="65000" bind:value={peerForm.remote_asn} /> <Input id="p-asn" type="number" placeholder="65000" bind:value={peerForm.remote_asn} />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<Label for="p-speaker">Спикер (ID, опционально)</Label> <Label for="p-speaker">Спикер (опционально)</Label>
<Input id="p-speaker" placeholder="speaker-id" bind:value={peerForm.bgp_speaker_id} /> <Select
type="single"
value={peerForm.bgp_speaker_id ?? ''}
onValueChange={(v) => {
peerForm = { ...peerForm, bgp_speaker_id: v || null };
}}
>
<SelectTrigger id="p-speaker" class="w-full">
{peerForm.bgp_speaker_id
? speakerLabelById(peerForm.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>
</div> </div>
<div <div
class="border-border bg-muted/30 flex flex-row items-center justify-between gap-4 rounded-lg border p-3" class="border-border bg-muted/30 flex flex-row items-center justify-between gap-4 rounded-lg border p-3"