feat: redesign settings page with structured controls
Replace raw JSON editing on the settings page with validated BIRD-specific fields and a guided key/value section so operators can update configuration safely without JSON syntax errors. Made-with: Cursor
This commit is contained in:
@@ -8,22 +8,163 @@
|
||||
import { Card, CardContent, CardHeader, CardTitle, 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 { Textarea } from '$lib/components/ui/textarea/index.js';
|
||||
import { toast } from 'svelte-sonner';
|
||||
import Save from '@lucide/svelte/icons/save';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import SettingsIcon from '@lucide/svelte/icons/settings';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
let token = $state('');
|
||||
let apiSettings = $state<AppSettings | null>(null);
|
||||
let settingsJson = $state('');
|
||||
let loadingSettings = $state(false);
|
||||
let savingSettings = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
if (browser) {
|
||||
token = localStorage.getItem(TOKEN_STORAGE_KEY) ?? '';
|
||||
}
|
||||
let knownFields = $state({
|
||||
bird_router_id: '',
|
||||
bird_local_ipv4: '',
|
||||
bird_local_ipv6: '',
|
||||
bird_local_asn: '',
|
||||
bird_bgp_source_ipv4: '',
|
||||
bird_bgp_source_ipv6: ''
|
||||
});
|
||||
let additionalSettings = $state<Array<{ id: number; key: string; value: string }>>([]);
|
||||
let additionalIdCounter = $state(1);
|
||||
|
||||
type KnownFieldKey =
|
||||
| 'bird_router_id'
|
||||
| 'bird_local_ipv4'
|
||||
| 'bird_local_ipv6'
|
||||
| 'bird_local_asn'
|
||||
| 'bird_bgp_source_ipv4'
|
||||
| 'bird_bgp_source_ipv6';
|
||||
|
||||
const knownFieldKeys: KnownFieldKey[] = [
|
||||
'bird_router_id',
|
||||
'bird_local_ipv4',
|
||||
'bird_local_ipv6',
|
||||
'bird_local_asn',
|
||||
'bird_bgp_source_ipv4',
|
||||
'bird_bgp_source_ipv6'
|
||||
];
|
||||
|
||||
function isValidIPv4(value: string): boolean {
|
||||
const parts = value.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
for (const part of parts) {
|
||||
if (!/^\d{1,3}$/.test(part)) return false;
|
||||
if (part.length > 1 && part.startsWith('0')) return false;
|
||||
const n = Number(part);
|
||||
if (!Number.isInteger(n) || n < 0 || n > 255) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function isValidIPv6(value: string): boolean {
|
||||
if (!/^[0-9A-Fa-f:.]+$/.test(value)) return false;
|
||||
if ((value.match(/::/g) ?? []).length > 1) return false;
|
||||
|
||||
const hasCompression = value.includes('::');
|
||||
const [leftRaw, rightRaw = ''] = value.split('::');
|
||||
const left = leftRaw === '' ? [] : leftRaw.split(':');
|
||||
const right = rightRaw === '' ? [] : rightRaw.split(':');
|
||||
|
||||
if (left.some((part) => part === '') || right.some((part) => part === '')) return false;
|
||||
|
||||
let segments = [...left, ...right];
|
||||
let ipv4TailSegments = 0;
|
||||
const lastSegment = segments.at(-1);
|
||||
if (lastSegment && lastSegment.includes('.')) {
|
||||
if (!isValidIPv4(lastSegment)) return false;
|
||||
segments = segments.slice(0, -1);
|
||||
ipv4TailSegments = 2;
|
||||
}
|
||||
|
||||
for (const segment of segments) {
|
||||
if (!/^[0-9A-Fa-f]{1,4}$/.test(segment)) return false;
|
||||
}
|
||||
|
||||
const totalSegments = segments.length + ipv4TailSegments;
|
||||
if (hasCompression) return totalSegments < 8;
|
||||
return totalSegments === 8;
|
||||
}
|
||||
|
||||
function isPositiveInt(value: string): boolean {
|
||||
return /^[1-9]\d*$/.test(value);
|
||||
}
|
||||
|
||||
let knownFieldErrors = $derived.by(() => {
|
||||
const errors: Record<KnownFieldKey, string> = {
|
||||
bird_router_id: '',
|
||||
bird_local_ipv4: '',
|
||||
bird_local_ipv6: '',
|
||||
bird_local_asn: '',
|
||||
bird_bgp_source_ipv4: '',
|
||||
bird_bgp_source_ipv6: ''
|
||||
};
|
||||
|
||||
const routerId = knownFields.bird_router_id.trim();
|
||||
if (routerId && !isValidIPv4(routerId)) errors.bird_router_id = 'Введите корректный IPv4 адрес';
|
||||
|
||||
const localV4 = knownFields.bird_local_ipv4.trim();
|
||||
if (localV4 && !isValidIPv4(localV4)) errors.bird_local_ipv4 = 'Введите корректный IPv4 адрес';
|
||||
|
||||
const localV6 = knownFields.bird_local_ipv6.trim();
|
||||
if (localV6 && !isValidIPv6(localV6)) errors.bird_local_ipv6 = 'Введите корректный IPv6 адрес';
|
||||
|
||||
const asn = knownFields.bird_local_asn.trim();
|
||||
if (asn && !isPositiveInt(asn)) errors.bird_local_asn = 'ASN должен быть целым числом больше 0';
|
||||
|
||||
const bgpV4 = knownFields.bird_bgp_source_ipv4.trim();
|
||||
if (bgpV4 && !isValidIPv4(bgpV4)) errors.bird_bgp_source_ipv4 = 'Введите корректный IPv4 адрес';
|
||||
|
||||
const bgpV6 = knownFields.bird_bgp_source_ipv6.trim();
|
||||
if (bgpV6 && !isValidIPv6(bgpV6)) errors.bird_bgp_source_ipv6 = 'Введите корректный IPv6 адрес';
|
||||
|
||||
return errors;
|
||||
});
|
||||
|
||||
let hasValidationErrors = $derived(
|
||||
knownFieldKeys.some((key) => Boolean(knownFieldErrors[key]))
|
||||
);
|
||||
|
||||
function addAdditionalSetting() {
|
||||
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
|
||||
}
|
||||
|
||||
function removeAdditionalSetting(id: number) {
|
||||
additionalSettings = additionalSettings.filter((entry) => entry.id !== id);
|
||||
}
|
||||
|
||||
function resetFormFromApi(settings: AppSettings) {
|
||||
const parsedKnown: Record<KnownFieldKey, string> = {
|
||||
bird_router_id: '',
|
||||
bird_local_ipv4: '',
|
||||
bird_local_ipv6: '',
|
||||
bird_local_asn: '',
|
||||
bird_bgp_source_ipv4: '',
|
||||
bird_bgp_source_ipv6: ''
|
||||
};
|
||||
const parsedAdditional: Array<{ id: number; key: string; value: string }> = [];
|
||||
|
||||
for (const [key, value] of Object.entries(settings as Record<string, unknown>)) {
|
||||
if (knownFieldKeys.includes(key as KnownFieldKey)) {
|
||||
if (key === 'bird_local_asn') {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) parsedKnown.bird_local_asn = String(value);
|
||||
else if (typeof value === 'string') parsedKnown.bird_local_asn = value;
|
||||
} else if (typeof value === 'string') {
|
||||
parsedKnown[key as KnownFieldKey] = value;
|
||||
}
|
||||
} else {
|
||||
parsedAdditional.push({
|
||||
id: additionalIdCounter++,
|
||||
key,
|
||||
value: typeof value === 'string' ? value : String(value)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
knownFields = parsedKnown;
|
||||
additionalSettings = parsedAdditional;
|
||||
}
|
||||
|
||||
function saveToken() {
|
||||
if (!browser) return;
|
||||
@@ -38,7 +179,7 @@
|
||||
try {
|
||||
const s = await apiJSON<AppSettings>('/v1/settings');
|
||||
apiSettings = s;
|
||||
settingsJson = JSON.stringify(s, null, 2);
|
||||
resetFormFromApi(s);
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : String(e));
|
||||
} finally {
|
||||
@@ -46,11 +187,37 @@
|
||||
}
|
||||
}
|
||||
|
||||
let canSaveSettings = $derived.by(() => {
|
||||
if (loadingSettings || savingSettings || hasValidationErrors) return false;
|
||||
|
||||
const hasKnownValues = knownFieldKeys.some((key) => {
|
||||
const value = knownFields[key].trim();
|
||||
return value !== '' && !knownFieldErrors[key];
|
||||
});
|
||||
const hasAdditionalValues = additionalSettings.some((entry) => entry.key.trim() !== '');
|
||||
|
||||
return hasKnownValues || hasAdditionalValues;
|
||||
});
|
||||
|
||||
async function saveApiSettings() {
|
||||
if (!canSaveSettings) return;
|
||||
|
||||
const payload: Record<string, string | number> = {};
|
||||
for (const key of knownFieldKeys) {
|
||||
const value = knownFields[key].trim();
|
||||
if (!value || knownFieldErrors[key]) continue;
|
||||
if (key === 'bird_local_asn') payload[key] = Number(value);
|
||||
else payload[key] = value;
|
||||
}
|
||||
for (const entry of additionalSettings) {
|
||||
const key = entry.key.trim();
|
||||
if (!key) continue;
|
||||
payload[key] = entry.value;
|
||||
}
|
||||
|
||||
savingSettings = true;
|
||||
try {
|
||||
const parsed = JSON.parse(settingsJson);
|
||||
await apiMutate('/v1/settings', 'PATCH', parsed);
|
||||
await apiMutate('/v1/settings', 'PATCH', payload);
|
||||
toast.success('Настройки сохранены');
|
||||
await loadApiSettings();
|
||||
} catch (e) {
|
||||
@@ -60,7 +227,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(loadApiSettings);
|
||||
onMount(() => {
|
||||
if (browser) {
|
||||
token = localStorage.getItem(TOKEN_STORAGE_KEY) ?? '';
|
||||
}
|
||||
void loadApiSettings();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto max-w-2xl space-y-6">
|
||||
@@ -105,33 +277,113 @@
|
||||
<CardTitle class="text-base">Настройки системы (API)</CardTitle>
|
||||
<CardDescription>
|
||||
<code class="text-xs">GET/PATCH /v1/settings</code> — глобальные параметры control plane (хранятся в БД).
|
||||
Требуется роль operator. Для BIRD, например:
|
||||
<code class="bg-muted rounded px-1 py-0.5 text-xs">bird_local_ipv4</code>,
|
||||
<code class="bg-muted rounded px-1 py-0.5 text-xs">bird_bgp_source_ipv4</code> (опционально — задаёт BIRD
|
||||
<code class="text-xs">router id</code>).
|
||||
Требуется роль 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-2">
|
||||
<Label for="settings-json">JSON настроек</Label>
|
||||
<p class="text-muted-foreground text-xs leading-relaxed">
|
||||
Должен быть строго валидный JSON: ключи и строки в двойных кавычках, без точки с запятой. Пример:
|
||||
<code class="bg-muted mt-1 block rounded px-2 py-1 font-mono"
|
||||
>{`{"bird_router_id": "203.0.113.1", "bird_local_asn": 65001}`}</code
|
||||
>
|
||||
</p>
|
||||
<Textarea
|
||||
id="settings-json"
|
||||
bind:value={settingsJson}
|
||||
rows={12}
|
||||
class="font-mono text-xs"
|
||||
spellcheck={false}
|
||||
/>
|
||||
<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">
|
||||
<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>
|
||||
<Button onclick={saveApiSettings} disabled={savingSettings}>
|
||||
|
||||
{#if hasValidationErrors}
|
||||
<p class="text-sm text-red-600">
|
||||
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button onclick={saveApiSettings} disabled={!canSaveSettings}>
|
||||
<Save />
|
||||
{savingSettings ? 'Сохранение…' : 'Применить настройки'}
|
||||
</Button>
|
||||
|
||||
Reference in New Issue
Block a user