fix(web): align settings with shadcn notify and superforms

WEB-07: notify вместо svelte-sonner; ui/core imports; WEB-05: zod + superforms + FormField для полей настроек.
Co-authored-by: Cursor <[email protected]>
This commit is contained in:
Denozordec
2026-05-20 14:50:58 +07:00
co-authored by Cursor
parent 33fe8fdd18
commit e06ee880b2
4 changed files with 174 additions and 185 deletions
+2 -3
View File
@@ -14,7 +14,8 @@
"svelte-sonner": "^1.1.0", "svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2", "tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
}, },
"devDependencies": { "devDependencies": {
"@internationalized/date": "^3.12.0", "@internationalized/date": "^3.12.0",
@@ -3137,9 +3138,7 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true,
"funding": { "funding": {
"url": "https://github.com/sponsors/colinhacks" "url": "https://github.com/sponsors/colinhacks"
} }
+2 -1
View File
@@ -39,6 +39,7 @@
"svelte-sonner": "^1.1.0", "svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0", "tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2", "tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
} }
} }
@@ -0,0 +1,81 @@
import { z } from 'zod';
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;
}
const optionalIPv4 = (label: string) =>
z.string().refine((v) => v.trim() === '' || isValidIPv4(v.trim()), {
message: `Введите корректный IPv4 адрес (${label})`
});
const optionalIPv6 = (label: string) =>
z.string().refine((v) => v.trim() === '' || isValidIPv6(v.trim()), {
message: `Введите корректный IPv6 адрес (${label})`
});
export const settingsKnownSchema = z.object({
bird_router_id: optionalIPv4('router id'),
bird_local_ipv4: optionalIPv4('local IPv4'),
bird_local_ipv6: optionalIPv6('local IPv6'),
bird_local_asn: z
.string()
.refine((v) => v.trim() === '' || /^[1-9]\d*$/.test(v.trim()), {
message: 'ASN должен быть целым числом больше 0'
}),
bird_bgp_source_ipv4: optionalIPv4('BGP source IPv4'),
bird_bgp_source_ipv6: optionalIPv6('BGP source IPv6'),
revision_retention_minutes: z.string().refine(
(v) => {
const s = v.trim();
if (s === '') return true;
const ttl = Number(s);
return /^\d+$/.test(s) && Number.isInteger(ttl) && ttl >= 15 && ttl <= 43200;
},
{ message: 'TTL ревизий должен быть целым числом от 15 до 43200 минут' }
)
});
export type SettingsKnownForm = z.infer<typeof settingsKnownSchema>;
export const emptySettingsKnownForm = (): SettingsKnownForm => ({
bird_router_id: '',
bird_local_ipv4: '',
bird_local_ipv6: '',
bird_local_asn: '',
bird_bgp_source_ipv4: '',
bird_bgp_source_ipv6: '',
revision_retention_minutes: ''
});
+89 -181
View File
@@ -1,14 +1,22 @@
<script lang="ts"> <script lang="ts">
import { browser } from '$app/environment'; import { browser } from '$app/environment';
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { defaults, superForm } from 'sveltekit-superforms';
import { zod4 } from 'sveltekit-superforms/adapters';
import { TOKEN_STORAGE_KEY } from '$lib/api/client.js'; import { TOKEN_STORAGE_KEY } from '$lib/api/client.js';
import { apiJSON, apiMutate } from '$lib/api/client.js'; import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { AppSettings } from '$lib/api/types.js'; import type { AppSettings } from '$lib/api/types.js';
import { Button } from '$lib/components/ui/button/index.js'; import {
import { Card, CardContent, CardHeader, CardDescription } from '$lib/components/ui/card/index.js'; emptySettingsKnownForm,
import { Input } from '$lib/components/ui/input/index.js'; settingsKnownSchema,
import { Label } from '$lib/components/ui/label/index.js'; type SettingsKnownForm
import { toast } from 'svelte-sonner'; } from '$lib/settings/settings-known.schema.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Card, CardContent, CardHeader, CardDescription } from '$lib/ui/core/card/index.js';
import { Input } from '$lib/ui/core/input/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Save from '@lucide/svelte/icons/save'; import Save from '@lucide/svelte/icons/save';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import SettingsIcon from '@lucide/svelte/icons/settings'; import SettingsIcon from '@lucide/svelte/icons/settings';
@@ -19,26 +27,10 @@
let apiSettings = $state<AppSettings | null>(null); let apiSettings = $state<AppSettings | null>(null);
let loadingSettings = $state(false); let loadingSettings = $state(false);
let savingSettings = $state(false); let savingSettings = $state(false);
let knownFields = $state({
bird_router_id: '',
bird_local_ipv4: '',
bird_local_ipv6: '',
bird_local_asn: '',
bird_bgp_source_ipv4: '',
bird_bgp_source_ipv6: '',
revision_retention_minutes: ''
});
let additionalSettings = $state<Array<{ id: number; key: string; value: string }>>([]); let additionalSettings = $state<Array<{ id: number; key: string; value: string }>>([]);
let additionalIdCounter = $state(1); let additionalIdCounter = $state(1);
type KnownFieldKey = type KnownFieldKey = keyof SettingsKnownForm;
| 'bird_router_id'
| 'bird_local_ipv4'
| 'bird_local_ipv6'
| 'bird_local_asn'
| 'bird_bgp_source_ipv4'
| 'bird_bgp_source_ipv6'
| 'revision_retention_minutes';
type SettingsSection = 'token' | 'bird' | 'revisions' | 'additional'; type SettingsSection = 'token' | 'bird' | 'revisions' | 'additional';
@@ -52,6 +44,15 @@
'revision_retention_minutes' 'revision_retention_minutes'
]; ];
const { form, errors, reset, validateForm } = superForm(
defaults(emptySettingsKnownForm(), zod4(settingsKnownSchema)),
{
validators: zod4(settingsKnownSchema),
SPA: true,
dataType: 'json'
}
);
let activeSection = $state<SettingsSection>('token'); let activeSection = $state<SettingsSection>('token');
const sectionItems: Array<{ id: SettingsSection; label: string; description: string }> = [ const sectionItems: Array<{ id: SettingsSection; label: string; description: string }> = [
@@ -61,98 +62,7 @@
{ id: 'additional', label: 'Дополнительно', description: 'Ключ-значение' } { id: 'additional', label: 'Дополнительно', description: 'Ключ-значение' }
]; ];
function isValidIPv4(value: string): boolean { let hasValidationErrors = $derived(knownFieldKeys.some((key) => Boolean($errors[key]?.length)));
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: '',
revision_retention_minutes: ''
};
const routerId = String(knownFields.bird_router_id ?? '').trim();
if (routerId && !isValidIPv4(routerId)) errors.bird_router_id = 'Введите корректный IPv4 адрес';
const localV4 = String(knownFields.bird_local_ipv4 ?? '').trim();
if (localV4 && !isValidIPv4(localV4)) errors.bird_local_ipv4 = 'Введите корректный IPv4 адрес';
const localV6 = String(knownFields.bird_local_ipv6 ?? '').trim();
if (localV6 && !isValidIPv6(localV6)) errors.bird_local_ipv6 = 'Введите корректный IPv6 адрес';
const asn = String(knownFields.bird_local_asn ?? '').trim();
if (asn && !isPositiveInt(asn)) errors.bird_local_asn = 'ASN должен быть целым числом больше 0';
const bgpV4 = String(knownFields.bird_bgp_source_ipv4 ?? '').trim();
if (bgpV4 && !isValidIPv4(bgpV4)) errors.bird_bgp_source_ipv4 = 'Введите корректный IPv4 адрес';
const bgpV6 = String(knownFields.bird_bgp_source_ipv6 ?? '').trim();
if (bgpV6 && !isValidIPv6(bgpV6)) errors.bird_bgp_source_ipv6 = 'Введите корректный IPv6 адрес';
const revisionRetentionMinutes = String(knownFields.revision_retention_minutes ?? '').trim();
if (revisionRetentionMinutes) {
const ttl = Number(revisionRetentionMinutes);
if (
!/^\d+$/.test(revisionRetentionMinutes) ||
!Number.isInteger(ttl) ||
ttl < 15 ||
ttl > 43200
) {
errors.revision_retention_minutes =
'TTL ревизий должен быть целым числом от 15 до 43200 минут';
}
}
return errors;
});
let hasValidationErrors = $derived(knownFieldKeys.some((key) => Boolean(knownFieldErrors[key])));
function addAdditionalSetting() { function addAdditionalSetting() {
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' }); additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
@@ -191,7 +101,7 @@
} }
} }
knownFields = parsedKnown; reset({ data: parsedKnown });
additionalSettings = parsedAdditional; additionalSettings = parsedAdditional;
} }
@@ -200,7 +110,7 @@
const t = token.trim(); const t = token.trim();
if (t) localStorage.setItem(TOKEN_STORAGE_KEY, t); if (t) localStorage.setItem(TOKEN_STORAGE_KEY, t);
else localStorage.removeItem(TOKEN_STORAGE_KEY); else localStorage.removeItem(TOKEN_STORAGE_KEY);
toast.success('Токен сохранён'); notify.success('Токен сохранён');
} }
async function loadApiSettings() { async function loadApiSettings() {
@@ -210,7 +120,7 @@
apiSettings = s; apiSettings = s;
resetFormFromApi(s); resetFormFromApi(s);
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : String(e)); notifyApiError(e);
} finally { } finally {
loadingSettings = false; loadingSettings = false;
} }
@@ -220,8 +130,8 @@
if (loadingSettings || savingSettings || hasValidationErrors) return false; if (loadingSettings || savingSettings || hasValidationErrors) return false;
const hasKnownValues = knownFieldKeys.some((key) => { const hasKnownValues = knownFieldKeys.some((key) => {
const value = String(knownFields[key] ?? '').trim(); const value = String($form[key] ?? '').trim();
return value !== '' && !knownFieldErrors[key]; return value !== '' && !$errors[key]?.length;
}); });
const hasAdditionalValues = additionalSettings.some((entry) => entry.key.trim() !== ''); const hasAdditionalValues = additionalSettings.some((entry) => entry.key.trim() !== '');
@@ -229,15 +139,20 @@
}); });
async function saveApiSettings() { async function saveApiSettings() {
const validation = await validateForm({ update: true });
if (!validation.valid) {
notify.error('Исправьте ошибки в полях настроек');
return;
}
if (!canSaveSettings) { if (!canSaveSettings) {
toast.error('Нечего сохранять или есть ошибки в полях'); notify.error('Нечего сохранять или есть ошибки в полях');
return; return;
} }
const payload: Record<string, string | number> = {}; const payload: Record<string, string | number> = {};
for (const key of knownFieldKeys) { for (const key of knownFieldKeys) {
const value = String(knownFields[key] ?? '').trim(); const value = String($form[key] ?? '').trim();
if (!value || knownFieldErrors[key]) continue; if (!value || $errors[key]?.length) continue;
if (key === 'bird_local_asn' || key === 'revision_retention_minutes') if (key === 'bird_local_asn' || key === 'revision_retention_minutes')
payload[key] = Number(value); payload[key] = Number(value);
else payload[key] = value; else payload[key] = value;
@@ -251,10 +166,10 @@
savingSettings = true; savingSettings = true;
try { try {
await apiMutate('/v1/settings', 'PATCH', payload); await apiMutate('/v1/settings', 'PATCH', payload);
toast.success('Настройки сохранены'); notify.success('Настройки сохранены');
await loadApiSettings(); await loadApiSettings();
} catch (e) { } catch (e) {
toast.error(e instanceof Error ? e.message : String(e)); notifyApiError(e);
} finally { } finally {
savingSettings = false; savingSettings = false;
} }
@@ -335,106 +250,99 @@
<div class="space-y-3"> <div class="space-y-3">
<h2 class="text-base font-semibold">Параметры BIRD</h2> <h2 class="text-base font-semibold">Параметры BIRD</h2>
<div class="space-y-2"> <FormField
<Label for="bird-router-id">Router ID (bird_router_id)</Label> id="bird-router-id"
label="Router ID (bird_router_id)"
error={$errors.bird_router_id?.[0]}
>
<Input <Input
id="bird-router-id" id="bird-router-id"
bind:value={knownFields.bird_router_id} bind:value={$form.bird_router_id}
placeholder="203.0.113.1" placeholder="203.0.113.1"
/> />
{#if knownFieldErrors.bird_router_id} </FormField>
<p class="text-sm text-red-600">{knownFieldErrors.bird_router_id}</p>
{/if}
</div>
<div class="space-y-2"> <FormField
<Label for="bird-local-ipv4">Локальный IPv4 (bird_local_ipv4)</Label> id="bird-local-ipv4"
label="Локальный IPv4 (bird_local_ipv4)"
error={$errors.bird_local_ipv4?.[0]}
>
<Input <Input
id="bird-local-ipv4" id="bird-local-ipv4"
bind:value={knownFields.bird_local_ipv4} bind:value={$form.bird_local_ipv4}
placeholder="198.51.100.10" placeholder="198.51.100.10"
/> />
{#if knownFieldErrors.bird_local_ipv4} </FormField>
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_ipv4}</p>
{/if}
</div>
<div class="space-y-2"> <FormField
<Label for="bird-local-ipv6">Локальный IPv6 (bird_local_ipv6)</Label> id="bird-local-ipv6"
label="Локальный IPv6 (bird_local_ipv6)"
error={$errors.bird_local_ipv6?.[0]}
>
<Input <Input
id="bird-local-ipv6" id="bird-local-ipv6"
bind:value={knownFields.bird_local_ipv6} bind:value={$form.bird_local_ipv6}
placeholder="2001:db8::10" placeholder="2001:db8::10"
/> />
{#if knownFieldErrors.bird_local_ipv6} </FormField>
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_ipv6}</p>
{/if}
</div>
<div class="space-y-2"> <FormField
<Label for="bird-local-asn">Локальный ASN (bird_local_asn)</Label> id="bird-local-asn"
label="Локальный ASN (bird_local_asn)"
error={$errors.bird_local_asn?.[0]}
>
<Input <Input
id="bird-local-asn" id="bird-local-asn"
type="number" type="number"
min="1" min="1"
bind:value={knownFields.bird_local_asn} bind:value={$form.bird_local_asn}
placeholder="65001" placeholder="65001"
/> />
{#if knownFieldErrors.bird_local_asn} </FormField>
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_asn}</p>
{/if}
</div>
<div class="space-y-2"> <FormField
<Label for="bird-bgp-source-ipv4">BGP source IPv4 (bird_bgp_source_ipv4)</Label> id="bird-bgp-source-ipv4"
label="BGP source IPv4 (bird_bgp_source_ipv4)"
error={$errors.bird_bgp_source_ipv4?.[0]}
>
<Input <Input
id="bird-bgp-source-ipv4" id="bird-bgp-source-ipv4"
bind:value={knownFields.bird_bgp_source_ipv4} bind:value={$form.bird_bgp_source_ipv4}
placeholder="198.51.100.11" placeholder="198.51.100.11"
/> />
{#if knownFieldErrors.bird_bgp_source_ipv4} </FormField>
<p class="text-sm text-red-600">{knownFieldErrors.bird_bgp_source_ipv4}</p>
{/if}
</div>
<div class="space-y-2"> <FormField
<Label for="bird-bgp-source-ipv6">BGP source IPv6 (bird_bgp_source_ipv6)</Label> id="bird-bgp-source-ipv6"
label="BGP source IPv6 (bird_bgp_source_ipv6)"
error={$errors.bird_bgp_source_ipv6?.[0]}
>
<Input <Input
id="bird-bgp-source-ipv6" id="bird-bgp-source-ipv6"
bind:value={knownFields.bird_bgp_source_ipv6} bind:value={$form.bird_bgp_source_ipv6}
placeholder="2001:db8::11" placeholder="2001:db8::11"
/> />
{#if knownFieldErrors.bird_bgp_source_ipv6} </FormField>
<p class="text-sm text-red-600">{knownFieldErrors.bird_bgp_source_ipv6}</p>
{/if}
</div>
</div> </div>
{:else if activeSection === 'revisions'} {:else if activeSection === 'revisions'}
<div class="space-y-3"> <div class="space-y-3">
<h2 class="text-base font-semibold">Управление ревизиями</h2> <h2 class="text-base font-semibold">Управление ревизиями</h2>
<div class="space-y-2"> <FormField
<Label for="revision-retention-minutes"> id="revision-retention-minutes"
Время жизни ревизий, мин (revision_retention_minutes) label="Время жизни ревизий, мин (revision_retention_minutes)"
</Label> error={$errors.revision_retention_minutes?.[0]}
description="Старые ревизии удаляются автоматически. Последняя раскатанная ревизия не удаляется."
>
<Input <Input
id="revision-retention-minutes" id="revision-retention-minutes"
type="number" type="number"
min="15" min="15"
max="43200" max="43200"
bind:value={knownFields.revision_retention_minutes} bind:value={$form.revision_retention_minutes}
placeholder="43200" placeholder="43200"
/> />
{#if knownFieldErrors.revision_retention_minutes} </FormField>
<p class="text-sm text-red-600">
{knownFieldErrors.revision_retention_minutes}
</p>
{/if}
<p class="text-sm text-muted-foreground">
Старые ревизии удаляются автоматически. Последняя раскатанная ревизия не
удаляется.
</p>
</div>
</div> </div>
{:else if activeSection === 'additional'} {:else if activeSection === 'additional'}
<div class="space-y-3"> <div class="space-y-3">