diff --git a/web/src/routes/settings/+page.svelte b/web/src/routes/settings/+page.svelte index dbd993e..d34df00 100644 --- a/web/src/routes/settings/+page.svelte +++ b/web/src/routes/settings/+page.svelte @@ -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(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>([]); + 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 = { + 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 = { + 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)) { + 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('/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 = {}; + 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(); + });
@@ -105,33 +277,113 @@ Настройки системы (API) GET/PATCH /v1/settings — глобальные параметры control plane (хранятся в БД). - Требуется роль operator. Для BIRD, например: - bird_local_ipv4, - bird_bgp_source_ipv4 (опционально — задаёт BIRD - router id). + Требуется роль operator. {#if loadingSettings}

Загрузка…

{:else if apiSettings !== null} -
- -

- Должен быть строго валидный JSON: ключи и строки в двойных кавычках, без точки с запятой. Пример: - {`{"bird_router_id": "203.0.113.1", "bird_local_asn": 65001}`} -

-