feat: enhance settings page layout and section management
CI / changes (push) Successful in 7s
CI / openapi (push) Has been skipped
CI / go (push) Has been skipped
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, , evobgp-web) (push) Successful in 1m5s
CI / docker-web (deploy/docker/evobgp-web/Dockerfile, evobgp-all, evobgp-web-all) (push) Successful in 1m7s
CI / docker-bird (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / docker-go-prime (push) Has been skipped
CI / docker-go (deploy/docker/evobgp-agent/Dockerfile, , evobgp-agent) (push) Has been skipped
CI / docker-go (evobgp-all, 1, deploy/docker/gobinary/Dockerfile, , evobgp-all) (push) Has been skipped
CI / docker-go (evobgp-api, 1, deploy/docker/gobinary/Dockerfile, , evobgp-api) (push) Has been skipped
CI / docker-go (evobgp-deploy, 0, deploy/docker/gobinary/Dockerfile, , evobgp-deploy) (push) Has been skipped
CI / docker-go (evobgp-ingest, 0, deploy/docker/gobinary/Dockerfile, , evobgp-ingest) (push) Has been skipped
CI / docker-go (evobgp-node, 0, deploy/docker/gobinary/Dockerfile, , evobgp-node) (push) Has been skipped
CI / docker-go (evobgp-render, 0, deploy/docker/gobinary/Dockerfile, , evobgp-render) (push) Has been skipped
CI / docker-go (evobgp-scheduler, 0, deploy/docker/gobinary/Dockerfile, , evobgp-scheduler) (push) Has been skipped

Redesigned the settings page to improve user experience by introducing a section-based layout for managing API keys and BIRD parameters. Added a new type for settings sections and implemented dynamic content rendering based on the selected section. Updated descriptions for clarity and improved the overall structure of the settings interface.
This commit is contained in:
Denozordec
2026-04-08 14:46:56 +07:00
parent 1386b63db2
commit d91667f698
+204 -162
View File
@@ -5,7 +5,7 @@
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { AppSettings } from '$lib/api/types.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '$lib/components/ui/card/index.js';
import { Card, CardContent, CardHeader, CardDescription } from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import { toast } from 'svelte-sonner';
@@ -39,6 +39,8 @@
| 'bird_bgp_source_ipv6'
| 'revision_retention_minutes';
type SettingsSection = 'token' | 'bird' | 'revisions' | 'additional';
const knownFieldKeys: KnownFieldKey[] = [
'bird_router_id',
'bird_local_ipv4',
@@ -49,6 +51,15 @@
'revision_retention_minutes'
];
let activeSection = $state<SettingsSection>('token');
const sectionItems: Array<{ id: SettingsSection; label: string; description: string }> = [
{ id: 'token', label: 'API-ключ', description: 'Авторизация в UI' },
{ id: 'bird', label: 'BIRD', description: 'Сетевые параметры' },
{ id: 'revisions', label: 'Ревизии', description: 'Хранение истории' },
{ id: 'additional', label: 'Дополнительно', description: 'Ключ-значение' }
];
function isValidIPv4(value: string): boolean {
const parts = value.split('.');
if (parts.length !== 4) return false;
@@ -257,7 +268,7 @@
});
</script>
<div class="mx-auto max-w-2xl space-y-6">
<div class="mx-auto max-w-5xl space-y-6">
<div class="flex items-start gap-3">
<div
class="bg-muted text-muted-foreground flex size-11 shrink-0 items-center justify-center rounded-xl"
@@ -267,175 +278,206 @@
</div>
<div class="min-w-0">
<h1 class="text-2xl font-semibold tracking-tight">Настройки</h1>
<p class="text-muted-foreground mt-1 text-sm">Токен доступа и параметры API.</p>
<p class="text-muted-foreground mt-1 text-sm">
Управление токеном доступа и глобальными параметрами control plane.
</p>
</div>
</div>
<!-- Token -->
<Card>
<CardHeader>
<CardTitle class="text-base">API-ключ</CardTitle>
<CardDescription>
Bearer-токен хранится только в localStorage браузера. Для локального демо с
<code class="bg-muted rounded px-1 py-0.5 text-xs">EVOBGP_DEV_INSECURE=1</code>
используйте токен <code class="bg-muted rounded px-1 py-0.5 text-xs">dev</code>.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
<div class="space-y-2">
<Label for="token">Токен</Label>
<Input id="token" type="password" autocomplete="off" bind:value={token} placeholder="Bearer …" />
</div>
<Button onclick={saveToken}>
<Save />
Сохранить токен
</Button>
</CardContent>
</Card>
<CardContent class="p-4 md:p-6">
<div class="grid gap-6 md:grid-cols-[220px_1fr]">
<div class="space-y-1">
{#each sectionItems as section (section.id)}
<button
type="button"
class={[
'w-full rounded-lg border px-3 py-2 text-left transition-colors',
activeSection === section.id
? 'border-primary bg-muted text-foreground'
: 'border-transparent text-muted-foreground hover:border-border hover:bg-muted/70 hover:text-foreground'
]}
onclick={() => (activeSection = section.id)}
>
<div class="text-sm font-medium">{section.label}</div>
<div class="text-xs opacity-80">{section.description}</div>
</button>
{/each}
</div>
<!-- API Settings -->
<Card>
<CardHeader>
<CardTitle class="text-base">Настройки системы (API)</CardTitle>
<div class="border-border rounded-xl border p-4 md:p-5">
{#if activeSection === 'token'}
<div class="space-y-4">
<div class="space-y-1">
<h2 class="text-base font-semibold">API-ключ</h2>
<p class="text-muted-foreground text-sm">
Bearer-токен хранится только в localStorage браузера. Для локального демо с
<code class="bg-muted rounded px-1 py-0.5 text-xs">EVOBGP_DEV_INSECURE=1</code>
используйте токен <code class="bg-muted rounded px-1 py-0.5 text-xs">dev</code>.
</p>
</div>
<div class="space-y-2">
<Label for="token">Токен</Label>
<Input id="token" type="password" autocomplete="off" bind:value={token} placeholder="Bearer …" />
</div>
<Button onclick={saveToken}>
<Save />
Сохранить токен
</Button>
</div>
{:else if loadingSettings}
<p class="text-muted-foreground text-sm">Загрузка…</p>
{:else if apiSettings === null}
<Button variant="outline" onclick={loadApiSettings}>Загрузить настройки</Button>
{:else}
<div class="space-y-5">
{#if activeSection === 'bird'}
<div class="space-y-3">
<h2 class="text-base font-semibold">Параметры BIRD</h2>
<div class="space-y-2">
<Label for="bird-router-id">Router ID (bird_router_id)</Label>
<Input id="bird-router-id" bind:value={knownFields.bird_router_id} placeholder="203.0.113.1" />
{#if knownFieldErrors.bird_router_id}
<p class="text-sm text-red-600">{knownFieldErrors.bird_router_id}</p>
{/if}
</div>
<div class="space-y-2">
<Label for="bird-local-ipv4">Локальный IPv4 (bird_local_ipv4)</Label>
<Input id="bird-local-ipv4" bind:value={knownFields.bird_local_ipv4} placeholder="198.51.100.10" />
{#if knownFieldErrors.bird_local_ipv4}
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_ipv4}</p>
{/if}
</div>
<div class="space-y-2">
<Label for="bird-local-ipv6">Локальный IPv6 (bird_local_ipv6)</Label>
<Input id="bird-local-ipv6" bind:value={knownFields.bird_local_ipv6} placeholder="2001:db8::10" />
{#if knownFieldErrors.bird_local_ipv6}
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_ipv6}</p>
{/if}
</div>
<div class="space-y-2">
<Label for="bird-local-asn">Локальный ASN (bird_local_asn)</Label>
<Input
id="bird-local-asn"
type="number"
min="1"
bind:value={knownFields.bird_local_asn}
placeholder="65001"
/>
{#if knownFieldErrors.bird_local_asn}
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_asn}</p>
{/if}
</div>
<div class="space-y-2">
<Label for="bird-bgp-source-ipv4">BGP source IPv4 (bird_bgp_source_ipv4)</Label>
<Input
id="bird-bgp-source-ipv4"
bind:value={knownFields.bird_bgp_source_ipv4}
placeholder="198.51.100.11"
/>
{#if knownFieldErrors.bird_bgp_source_ipv4}
<p class="text-sm text-red-600">{knownFieldErrors.bird_bgp_source_ipv4}</p>
{/if}
</div>
<div class="space-y-2">
<Label for="bird-bgp-source-ipv6">BGP source IPv6 (bird_bgp_source_ipv6)</Label>
<Input
id="bird-bgp-source-ipv6"
bind:value={knownFields.bird_bgp_source_ipv6}
placeholder="2001:db8::11"
/>
{#if knownFieldErrors.bird_bgp_source_ipv6}
<p class="text-sm text-red-600">{knownFieldErrors.bird_bgp_source_ipv6}</p>
{/if}
</div>
</div>
{:else if activeSection === 'revisions'}
<div class="space-y-3">
<h2 class="text-base font-semibold">Управление ревизиями</h2>
<div class="space-y-2">
<Label for="revision-retention-minutes">
Время жизни ревизий, мин (revision_retention_minutes)
</Label>
<Input
id="revision-retention-minutes"
type="number"
min="15"
max="43200"
bind:value={knownFields.revision_retention_minutes}
placeholder="43200"
/>
{#if knownFieldErrors.revision_retention_minutes}
<p class="text-sm text-red-600">{knownFieldErrors.revision_retention_minutes}</p>
{/if}
<p class="text-muted-foreground text-sm">
Старые ревизии удаляются автоматически. Последняя раскатанная ревизия не удаляется.
</p>
</div>
</div>
{:else if activeSection === 'additional'}
<div class="space-y-3">
<div class="flex items-center justify-between">
<h2 class="text-base font-semibold">Дополнительные настройки (KV)</h2>
<Button variant="outline" size="sm" onclick={addAdditionalSetting}>
<Plus class="size-4" />
Добавить строку
</Button>
</div>
{#if additionalSettings.length === 0}
<p class="text-muted-foreground text-sm">Нет дополнительных параметров.</p>
{/if}
<div class="space-y-2">
{#each additionalSettings as entry (entry.id)}
<div class="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_auto]">
<Input bind:value={entry.key} placeholder="Ключ (например, bird_log_level)" />
<Input bind:value={entry.value} placeholder="Значение (строка)" />
<Button
variant="ghost"
size="icon"
aria-label="Удалить строку"
onclick={() => removeAdditionalSetting(entry.id)}
>
<Trash2 class="size-4" />
</Button>
</div>
{/each}
</div>
</div>
{/if}
{#if hasValidationErrors}
<p class="text-sm text-red-600">
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
</p>
{/if}
<div class="pt-2">
<Button onclick={saveApiSettings} disabled={!canSaveSettings}>
<Save />
{savingSettings ? 'Сохранение…' : 'Применить настройки'}
</Button>
</div>
</div>
{/if}
</div>
</div>
</CardContent>
<CardHeader class="pt-0">
<CardDescription>
<code class="text-xs">GET/PATCH /v1/settings</code> — глобальные параметры control plane (хранятся в БД).
Требуется роль operator.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
{#if loadingSettings}
<p class="text-muted-foreground text-sm">Загрузка…</p>
{:else if apiSettings !== null}
<div class="space-y-5">
<div class="space-y-3">
<h3 class="text-sm font-medium">Параметры BIRD</h3>
<div class="space-y-2">
<Label for="bird-router-id">Router ID (bird_router_id)</Label>
<Input id="bird-router-id" bind:value={knownFields.bird_router_id} placeholder="203.0.113.1" />
{#if knownFieldErrors.bird_router_id}
<p class="text-sm text-red-600">{knownFieldErrors.bird_router_id}</p>
{/if}
</div>
<div class="space-y-2">
<Label for="bird-local-ipv4">Локальный IPv4 (bird_local_ipv4)</Label>
<Input id="bird-local-ipv4" bind:value={knownFields.bird_local_ipv4} placeholder="198.51.100.10" />
{#if knownFieldErrors.bird_local_ipv4}
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_ipv4}</p>
{/if}
</div>
<div class="space-y-2">
<Label for="bird-local-ipv6">Локальный IPv6 (bird_local_ipv6)</Label>
<Input id="bird-local-ipv6" bind:value={knownFields.bird_local_ipv6} placeholder="2001:db8::10" />
{#if knownFieldErrors.bird_local_ipv6}
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_ipv6}</p>
{/if}
</div>
<div class="space-y-2">
<Label for="bird-local-asn">Локальный ASN (bird_local_asn)</Label>
<Input id="bird-local-asn" type="number" min="1" bind:value={knownFields.bird_local_asn} placeholder="65001" />
{#if knownFieldErrors.bird_local_asn}
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_asn}</p>
{/if}
</div>
<div class="space-y-2">
<Label for="bird-bgp-source-ipv4">BGP source IPv4 (bird_bgp_source_ipv4)</Label>
<Input
id="bird-bgp-source-ipv4"
bind:value={knownFields.bird_bgp_source_ipv4}
placeholder="198.51.100.11"
/>
{#if knownFieldErrors.bird_bgp_source_ipv4}
<p class="text-sm text-red-600">{knownFieldErrors.bird_bgp_source_ipv4}</p>
{/if}
</div>
<div class="space-y-2">
<Label for="bird-bgp-source-ipv6">BGP source IPv6 (bird_bgp_source_ipv6)</Label>
<Input
id="bird-bgp-source-ipv6"
bind:value={knownFields.bird_bgp_source_ipv6}
placeholder="2001:db8::11"
/>
{#if knownFieldErrors.bird_bgp_source_ipv6}
<p class="text-sm text-red-600">{knownFieldErrors.bird_bgp_source_ipv6}</p>
{/if}
</div>
</div>
<div class="space-y-3">
<h3 class="text-sm font-medium">Управление ревизиями</h3>
<div class="space-y-2">
<Label for="revision-retention-minutes">
Время жизни ревизий, мин (revision_retention_minutes)
</Label>
<Input
id="revision-retention-minutes"
type="number"
min="15"
max="43200"
bind:value={knownFields.revision_retention_minutes}
placeholder="43200"
/>
{#if knownFieldErrors.revision_retention_minutes}
<p class="text-sm text-red-600">{knownFieldErrors.revision_retention_minutes}</p>
{/if}
<p class="text-muted-foreground text-sm">
Старые ревизии удаляются автоматически. Последняя раскатанная ревизия не удаляется.
</p>
</div>
</div>
<div class="space-y-3">
<div class="flex items-center justify-between">
<h3 class="text-sm font-medium">Дополнительные настройки (KV)</h3>
<Button variant="outline" size="sm" onclick={addAdditionalSetting}>
<Plus class="size-4" />
Добавить строку
</Button>
</div>
{#if additionalSettings.length === 0}
<p class="text-muted-foreground text-sm">Нет дополнительных параметров.</p>
{/if}
<div class="space-y-2">
{#each additionalSettings as entry (entry.id)}
<div class="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_auto]">
<Input bind:value={entry.key} placeholder="Ключ (например, bird_log_level)" />
<Input bind:value={entry.value} placeholder="Значение (строка)" />
<Button
variant="ghost"
size="icon"
aria-label="Удалить строку"
onclick={() => removeAdditionalSetting(entry.id)}
>
<Trash2 class="size-4" />
</Button>
</div>
{/each}
</div>
</div>
</div>
{#if hasValidationErrors}
<p class="text-sm text-red-600">
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
</p>
{/if}
<Button onclick={saveApiSettings} disabled={!canSaveSettings}>
<Save />
{savingSettings ? 'Сохранение…' : 'Применить настройки'}
</Button>
{:else}
<Button variant="outline" onclick={loadApiSettings}>Загрузить настройки</Button>
{/if}
</CardContent>
</Card>
</div>