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",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0"
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@internationalized/date": "^3.12.0",
@@ -3137,9 +3138,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"dev": true,
"license": "MIT",
"optional": true,
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
+2 -1
View File
@@ -39,6 +39,7 @@
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0",
"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">
import { browser } from '$app/environment';
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 { 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, 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';
import {
emptySettingsKnownForm,
settingsKnownSchema,
type SettingsKnownForm
} 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 Plus from '@lucide/svelte/icons/plus';
import SettingsIcon from '@lucide/svelte/icons/settings';
@@ -19,26 +27,10 @@
let apiSettings = $state<AppSettings | null>(null);
let loadingSettings = $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 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'
| 'revision_retention_minutes';
type KnownFieldKey = keyof SettingsKnownForm;
type SettingsSection = 'token' | 'bird' | 'revisions' | 'additional';
@@ -52,6 +44,15 @@
'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');
const sectionItems: Array<{ id: SettingsSection; label: string; description: string }> = [
@@ -61,98 +62,7 @@
{ id: 'additional', label: 'Дополнительно', description: 'Ключ-значение' }
];
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: '',
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])));
let hasValidationErrors = $derived(knownFieldKeys.some((key) => Boolean($errors[key]?.length)));
function addAdditionalSetting() {
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
@@ -191,7 +101,7 @@
}
}
knownFields = parsedKnown;
reset({ data: parsedKnown });
additionalSettings = parsedAdditional;
}
@@ -200,7 +110,7 @@
const t = token.trim();
if (t) localStorage.setItem(TOKEN_STORAGE_KEY, t);
else localStorage.removeItem(TOKEN_STORAGE_KEY);
toast.success('Токен сохранён');
notify.success('Токен сохранён');
}
async function loadApiSettings() {
@@ -210,7 +120,7 @@
apiSettings = s;
resetFormFromApi(s);
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
loadingSettings = false;
}
@@ -220,8 +130,8 @@
if (loadingSettings || savingSettings || hasValidationErrors) return false;
const hasKnownValues = knownFieldKeys.some((key) => {
const value = String(knownFields[key] ?? '').trim();
return value !== '' && !knownFieldErrors[key];
const value = String($form[key] ?? '').trim();
return value !== '' && !$errors[key]?.length;
});
const hasAdditionalValues = additionalSettings.some((entry) => entry.key.trim() !== '');
@@ -229,15 +139,20 @@
});
async function saveApiSettings() {
const validation = await validateForm({ update: true });
if (!validation.valid) {
notify.error('Исправьте ошибки в полях настроек');
return;
}
if (!canSaveSettings) {
toast.error('Нечего сохранять или есть ошибки в полях');
notify.error('Нечего сохранять или есть ошибки в полях');
return;
}
const payload: Record<string, string | number> = {};
for (const key of knownFieldKeys) {
const value = String(knownFields[key] ?? '').trim();
if (!value || knownFieldErrors[key]) continue;
const value = String($form[key] ?? '').trim();
if (!value || $errors[key]?.length) continue;
if (key === 'bird_local_asn' || key === 'revision_retention_minutes')
payload[key] = Number(value);
else payload[key] = value;
@@ -251,10 +166,10 @@
savingSettings = true;
try {
await apiMutate('/v1/settings', 'PATCH', payload);
toast.success('Настройки сохранены');
notify.success('Настройки сохранены');
await loadApiSettings();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
notifyApiError(e);
} finally {
savingSettings = false;
}
@@ -335,106 +250,99 @@
<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>
<FormField
id="bird-router-id"
label="Router ID (bird_router_id)"
error={$errors.bird_router_id?.[0]}
>
<Input
id="bird-router-id"
bind:value={knownFields.bird_router_id}
bind:value={$form.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>
</FormField>
<div class="space-y-2">
<Label for="bird-local-ipv4">Локальный IPv4 (bird_local_ipv4)</Label>
<FormField
id="bird-local-ipv4"
label="Локальный IPv4 (bird_local_ipv4)"
error={$errors.bird_local_ipv4?.[0]}
>
<Input
id="bird-local-ipv4"
bind:value={knownFields.bird_local_ipv4}
bind:value={$form.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>
</FormField>
<div class="space-y-2">
<Label for="bird-local-ipv6">Локальный IPv6 (bird_local_ipv6)</Label>
<FormField
id="bird-local-ipv6"
label="Локальный IPv6 (bird_local_ipv6)"
error={$errors.bird_local_ipv6?.[0]}
>
<Input
id="bird-local-ipv6"
bind:value={knownFields.bird_local_ipv6}
bind:value={$form.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>
</FormField>
<div class="space-y-2">
<Label for="bird-local-asn">Локальный ASN (bird_local_asn)</Label>
<FormField
id="bird-local-asn"
label="Локальный ASN (bird_local_asn)"
error={$errors.bird_local_asn?.[0]}
>
<Input
id="bird-local-asn"
type="number"
min="1"
bind:value={knownFields.bird_local_asn}
bind:value={$form.bird_local_asn}
placeholder="65001"
/>
{#if knownFieldErrors.bird_local_asn}
<p class="text-sm text-red-600">{knownFieldErrors.bird_local_asn}</p>
{/if}
</div>
</FormField>
<div class="space-y-2">
<Label for="bird-bgp-source-ipv4">BGP source IPv4 (bird_bgp_source_ipv4)</Label>
<FormField
id="bird-bgp-source-ipv4"
label="BGP source IPv4 (bird_bgp_source_ipv4)"
error={$errors.bird_bgp_source_ipv4?.[0]}
>
<Input
id="bird-bgp-source-ipv4"
bind:value={knownFields.bird_bgp_source_ipv4}
bind:value={$form.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>
</FormField>
<div class="space-y-2">
<Label for="bird-bgp-source-ipv6">BGP source IPv6 (bird_bgp_source_ipv6)</Label>
<FormField
id="bird-bgp-source-ipv6"
label="BGP source IPv6 (bird_bgp_source_ipv6)"
error={$errors.bird_bgp_source_ipv6?.[0]}
>
<Input
id="bird-bgp-source-ipv6"
bind:value={knownFields.bird_bgp_source_ipv6}
bind:value={$form.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>
</FormField>
</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>
<FormField
id="revision-retention-minutes"
label="Время жизни ревизий, мин (revision_retention_minutes)"
error={$errors.revision_retention_minutes?.[0]}
description="Старые ревизии удаляются автоматически. Последняя раскатанная ревизия не удаляется."
>
<Input
id="revision-retention-minutes"
type="number"
min="15"
max="43200"
bind:value={knownFields.revision_retention_minutes}
bind:value={$form.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-sm text-muted-foreground">
Старые ревизии удаляются автоматически. Последняя раскатанная ревизия не
удаляется.
</p>
</div>
</FormField>
</div>
{:else if activeSection === 'additional'}
<div class="space-y-3">