refactor(settings): deprecate settingsKnownSchema and integrate bird and revision settings
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 36s
CI / go (push) Successful in 49s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m59s
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 25s
CI / web (push) Successful in 36s
CI / go (push) Successful in 49s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m59s
- Merged birdSettingsSchema and revisionSettingsSchema into settingsKnownSchema, marking the previous schema as deprecated. - Updated emptySettingsKnownForm to utilize emptyBirdSettingsForm and emptyRevisionSettingsForm. - Refactored layout and page components to streamline theme management and improve tab synchronization in network and operations pages. - Introduced new system settings tab in operations and updated settings page to manage theme preferences.
This commit is contained in:
@@ -32,6 +32,6 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
|
||||
- Новая пользовательская возможность → `feat` (minor)
|
||||
- Починка ожидаемого поведения / баг → `fix` (patch)
|
||||
- Follow-up баги после недавнего `feat` в том же scope → **`fix`**, не `feat`
|
||||
- Только перестройка без нового поведения → `refactor` (none)
|
||||
- Только перестройка без нового поведения → `refactor` (patch, без новых функций)
|
||||
|
||||
Заголовок — EN, императив, ≤72 символов. Тело — RU.
|
||||
|
||||
@@ -50,7 +50,7 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
|
||||
| `feat` | **новая** пользовательская возможность (раньше нельзя было) | minor |
|
||||
| `fix` | восстановление **ожидаемого** поведения; баг, регрессия, падение UI | patch |
|
||||
| `perf` | ускорение без смены API | patch |
|
||||
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | — |
|
||||
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | patch |
|
||||
| `docs` | только документация | — |
|
||||
| `test` | тесты | — |
|
||||
| `ci` | CI/CD (`.gitea/`, workflows); правки, из‑за которых нужны новые образы | patch |
|
||||
@@ -62,7 +62,7 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
|
||||
|
||||
1. Появилось **новое** действие / экран / API / настройка, которых не было → `feat`
|
||||
2. То, что **должно было работать**, не работало (кнопки, диалоги, сохранение, 500) → `fix`
|
||||
3. Только перестройка кода или UI на другой паттерн, поведение для пользователя то же → `refactor`
|
||||
3. Только перестройка кода или UI на другой паттерн, поведение для пользователя то же → `refactor` (patch, без новых функций)
|
||||
4. Ускорение без изменения контракта → `perf`
|
||||
|
||||
**Не путать с формулировкой diff:**
|
||||
@@ -105,7 +105,7 @@ feat(web): migrate modules list to AppDataTable
|
||||
# Хорошо — если не было нового user-facing
|
||||
refactor(web): migrate modules list to AppDataTable
|
||||
|
||||
Единый паттерн таблиц; поведение списка модулей без изменений.
|
||||
Единый паттерн таблиц; поведение списка модулей без изменений. Semver: patch.
|
||||
```
|
||||
|
||||
```
|
||||
|
||||
@@ -101,7 +101,7 @@ git commit -m "$( @'
|
||||
| Пользователь получает **новую** возможность? | `feat` (minor) |
|
||||
| Восстанавливается **ожидаемое** поведение / устранён баг? | `fix` (patch) |
|
||||
| Только скорость, контракт тот же? | `perf` (patch) |
|
||||
| Только структура кода/UI, поведение то же? | `refactor` (none) |
|
||||
| Только структура кода/UI, поведение то же? | `refactor` (patch) |
|
||||
|
||||
**Follow-up:** правки сразу после `feat` в том же scope без новой возможности → **`fix`**, не `feat` (слова *enhance/improve/refactor* в задаче не делают commit `feat`).
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
{ "type": "fix", "release": "patch" },
|
||||
{ "type": "perf", "release": "patch" },
|
||||
{ "type": "ci", "release": "patch" },
|
||||
{ "type": "refactor", "release": "patch" },
|
||||
{ "breaking": true, "release": "major" }
|
||||
]
|
||||
}
|
||||
|
||||
+4
-2
@@ -7,9 +7,11 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
|
||||
| Тип коммита | Bump |
|
||||
|-------------|------|
|
||||
| `feat` | minor (1.0.0 → 1.1.0) |
|
||||
| `fix`, `perf`, `ci` | patch (1.5.1 → 1.5.2) |
|
||||
| `fix`, `perf`, `ci`, `refactor` | patch (1.5.1 → 1.5.2) |
|
||||
| `feat!`, `fix!` или `BREAKING CHANGE:` в теле | major (1.0.0 → 2.0.0) |
|
||||
| `docs`, `chore`, `test`, `refactor` | без релиза |
|
||||
| `docs`, `chore`, `test` | без релиза |
|
||||
|
||||
`refactor` — patch без новых функций: перестройка кода/UI при том же поведении для пользователя. По semver на одном уровне с `fix`, но семантически «мельче» `feat` (не minor).
|
||||
|
||||
Отдельного суффикса `1.x.y.fix` в semver нет: «fix» в Conventional Commits означает **patch** (третья цифра). Для починки пайплайна без смены продукта — `fix(ci):` или `ci:` (оба дают patch после настройки `.releaserc.json`).
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { defaults, superForm } from 'sveltekit-superforms';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import {
|
||||
birdSettingsSchema,
|
||||
emptyBirdSettingsForm,
|
||||
type BirdSettingsForm
|
||||
} from '$lib/settings/bird-settings.schema.js';
|
||||
import {
|
||||
buildPayloadFromFormFields,
|
||||
loadSettings,
|
||||
partitionSettings,
|
||||
patchSettings
|
||||
} from '$lib/settings/settings-api.js';
|
||||
import { BIRD_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Input } from '$lib/ui/core/input/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/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 Info from '@lucide/svelte/icons/info';
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let loaded = $state(false);
|
||||
|
||||
const { form, errors, reset, validateForm } = superForm(
|
||||
defaults(emptyBirdSettingsForm(), zod4(birdSettingsSchema)),
|
||||
{
|
||||
validators: zod4(birdSettingsSchema),
|
||||
SPA: true,
|
||||
dataType: 'json'
|
||||
}
|
||||
);
|
||||
|
||||
let hasValidationErrors = $derived(
|
||||
BIRD_SETTING_KEYS.some((key) => Boolean($errors[key as keyof BirdSettingsForm]?.length))
|
||||
);
|
||||
|
||||
let canSave = $derived.by(() => {
|
||||
if (loading || saving || hasValidationErrors || !loaded) return false;
|
||||
return BIRD_SETTING_KEYS.some((key) => {
|
||||
const value = String($form[key as keyof BirdSettingsForm] ?? '').trim();
|
||||
return value !== '' && !$errors[key as keyof BirdSettingsForm]?.length;
|
||||
});
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const { partitioned } = partitionSettings(settings);
|
||||
reset({ data: partitioned.bird });
|
||||
loaded = true;
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const validation = await validateForm({ update: true });
|
||||
if (!validation.valid) {
|
||||
notify.error('Исправьте ошибки в полях настроек');
|
||||
return;
|
||||
}
|
||||
if (!canSave) {
|
||||
notify.error('Нечего сохранять или есть ошибки в полях');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildPayloadFromFormFields(
|
||||
BIRD_SETTING_KEYS,
|
||||
$form as Record<string, string>,
|
||||
$errors as Partial<Record<string, string[]>>
|
||||
);
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await patchSettings(payload);
|
||||
notify.success('Параметры BIRD сохранены');
|
||||
await load();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Control plane</CardTitle>
|
||||
<CardDescription>
|
||||
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через
|
||||
<code class="text-xs">PATCH /v1/settings</code> (роль operator).
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-5">
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>Подстановка в конфиг</AlertTitle>
|
||||
<AlertDescription>
|
||||
Значения используются при генерации BIRD-конфигурации в pipeline (router id, local AS,
|
||||
адреса). Пиры и спикеры настраиваются на соседних вкладках.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{#if loading && !loaded}
|
||||
<p class="text-sm text-muted-foreground">Загрузка…</p>
|
||||
{:else if !loaded}
|
||||
<Button variant="outline" onclick={load}>Загрузить параметры</Button>
|
||||
{:else}
|
||||
<div class="space-y-3">
|
||||
<FormField
|
||||
id="bird-router-id"
|
||||
label="Router ID (bird_router_id)"
|
||||
error={$errors.bird_router_id?.[0]}
|
||||
>
|
||||
<Input id="bird-router-id" bind:value={$form.bird_router_id} placeholder="203.0.113.1" />
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="bird-local-ipv4"
|
||||
label="Локальный IPv4 (bird_local_ipv4)"
|
||||
error={$errors.bird_local_ipv4?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-local-ipv4"
|
||||
bind:value={$form.bird_local_ipv4}
|
||||
placeholder="198.51.100.10"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="bird-local-ipv6"
|
||||
label="Локальный IPv6 (bird_local_ipv6)"
|
||||
error={$errors.bird_local_ipv6?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-local-ipv6"
|
||||
bind:value={$form.bird_local_ipv6}
|
||||
placeholder="2001:db8::10"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<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={$form.bird_local_asn}
|
||||
placeholder="65001"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<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={$form.bird_bgp_source_ipv4}
|
||||
placeholder="198.51.100.11"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<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={$form.bird_bgp_source_ipv6}
|
||||
placeholder="2001:db8::11"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{#if hasValidationErrors}
|
||||
<p class="text-sm text-destructive">
|
||||
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button onclick={save} disabled={!canSave}>
|
||||
<Save />
|
||||
{saving ? 'Сохранение…' : 'Применить параметры'}
|
||||
</Button>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,223 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { defaults, superForm } from 'sveltekit-superforms';
|
||||
import { zod4 } from 'sveltekit-superforms/adapters';
|
||||
import {
|
||||
emptyRevisionSettingsForm,
|
||||
revisionSettingsSchema
|
||||
} from '$lib/settings/revision-settings.schema.js';
|
||||
import {
|
||||
buildPayloadFromFormFields,
|
||||
loadSettings,
|
||||
partitionSettings,
|
||||
patchSettings,
|
||||
type AdditionalSettingEntry
|
||||
} from '$lib/settings/settings-api.js';
|
||||
import { REVISION_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Input } from '$lib/ui/core/input/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
||||
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.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 Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let loaded = $state(false);
|
||||
let additionalSettings = $state<AdditionalSettingEntry[]>([]);
|
||||
let additionalIdCounter = $state(1);
|
||||
|
||||
const { form, errors, reset, validateForm } = superForm(
|
||||
defaults(emptyRevisionSettingsForm(), zod4(revisionSettingsSchema)),
|
||||
{
|
||||
validators: zod4(revisionSettingsSchema),
|
||||
SPA: true,
|
||||
dataType: 'json'
|
||||
}
|
||||
);
|
||||
|
||||
let hasValidationErrors = $derived(Boolean($errors.revision_retention_minutes?.length));
|
||||
|
||||
function addAdditionalSetting() {
|
||||
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
|
||||
}
|
||||
|
||||
function removeAdditionalSetting(id: number) {
|
||||
additionalSettings = additionalSettings.filter((entry) => entry.id !== id);
|
||||
}
|
||||
|
||||
let canSave = $derived.by(() => {
|
||||
if (loading || saving || hasValidationErrors || !loaded) return false;
|
||||
|
||||
const hasRetention = String($form.revision_retention_minutes ?? '').trim() !== '';
|
||||
const hasAdditional = additionalSettings.some((entry) => entry.key.trim() !== '');
|
||||
|
||||
return hasRetention || hasAdditional;
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const { partitioned, nextId } = partitionSettings(settings, additionalIdCounter);
|
||||
reset({ data: partitioned.revision });
|
||||
additionalSettings = partitioned.additional;
|
||||
additionalIdCounter = nextId;
|
||||
loaded = true;
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const validation = await validateForm({ update: true });
|
||||
if (!validation.valid) {
|
||||
notify.error('Исправьте ошибки в полях настроек');
|
||||
return;
|
||||
}
|
||||
if (!canSave) {
|
||||
notify.error('Нечего сохранять или есть ошибки в полях');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = buildPayloadFromFormFields(
|
||||
REVISION_SETTING_KEYS,
|
||||
$form as Record<string, string>,
|
||||
$errors as Partial<Record<string, string[]>>
|
||||
);
|
||||
|
||||
for (const entry of additionalSettings) {
|
||||
const key = entry.key.trim();
|
||||
if (!key) continue;
|
||||
payload[key] = entry.value;
|
||||
}
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await patchSettings(payload);
|
||||
notify.success('Системные настройки сохранены');
|
||||
await load();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>Operator-only</AlertTitle>
|
||||
<AlertDescription>
|
||||
Изменение параметров через <code class="text-xs">PATCH /v1/settings</code> требует роли operator.
|
||||
При отсутствии прав API вернёт 403.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Хранение ревизий</CardTitle>
|
||||
<CardDescription>
|
||||
Автоматическая очистка старых ревизий. Последняя раскатанная ревизия не удаляется.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if loading && !loaded}
|
||||
<p class="text-sm text-muted-foreground">Загрузка…</p>
|
||||
{:else if !loaded}
|
||||
<Button variant="outline" onclick={load}>Загрузить настройки</Button>
|
||||
{:else}
|
||||
<FormField
|
||||
id="revision-retention-minutes"
|
||||
label="Время жизни ревизий, мин (revision_retention_minutes)"
|
||||
error={$errors.revision_retention_minutes?.[0]}
|
||||
description="Допустимый диапазон: 15–43200 минут."
|
||||
>
|
||||
<Input
|
||||
id="revision-retention-minutes"
|
||||
type="number"
|
||||
min="15"
|
||||
max="43200"
|
||||
bind:value={$form.revision_retention_minutes}
|
||||
placeholder="43200"
|
||||
/>
|
||||
</FormField>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<CardTitle>Дополнительные параметры</CardTitle>
|
||||
<CardDescription>Произвольные KV-пары в global_settings.</CardDescription>
|
||||
</div>
|
||||
{#if loaded}
|
||||
<Button variant="outline" size="sm" onclick={addAdditionalSetting}>
|
||||
<Plus class="size-4" />
|
||||
Добавить строку
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{#if !loaded}
|
||||
<p class="text-sm text-muted-foreground">Загрузите настройки выше.</p>
|
||||
{:else if additionalSettings.length === 0}
|
||||
<EmptyState
|
||||
title="Нет дополнительных параметров"
|
||||
description="Добавьте KV-пару при необходимости."
|
||||
/>
|
||||
{:else}
|
||||
<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>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{#if loaded}
|
||||
{#if hasValidationErrors}
|
||||
<p class="text-sm text-destructive">
|
||||
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button onclick={save} disabled={!canSave}>
|
||||
<Save />
|
||||
{saving ? 'Сохранение…' : 'Применить настройки'}
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
import { optionalIPv4, optionalIPv6 } from './ip-validation.js';
|
||||
|
||||
export const birdSettingsSchema = 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')
|
||||
});
|
||||
|
||||
export type BirdSettingsForm = z.infer<typeof birdSettingsSchema>;
|
||||
|
||||
export const emptyBirdSettingsForm = (): BirdSettingsForm => ({
|
||||
bird_router_id: '',
|
||||
bird_local_ipv4: '',
|
||||
bird_local_ipv6: '',
|
||||
bird_local_asn: '',
|
||||
bird_bgp_source_ipv4: '',
|
||||
bird_bgp_source_ipv6: ''
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
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;
|
||||
}
|
||||
|
||||
export const optionalIPv4 = (label: string) =>
|
||||
z.string().refine((v) => v.trim() === '' || isValidIPv4(v.trim()), {
|
||||
message: `Введите корректный IPv4 адрес (${label})`
|
||||
});
|
||||
|
||||
export const optionalIPv6 = (label: string) =>
|
||||
z.string().refine((v) => v.trim() === '' || isValidIPv6(v.trim()), {
|
||||
message: `Введите корректный IPv6 адрес (${label})`
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const revisionSettingsSchema = z.object({
|
||||
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 RevisionSettingsForm = z.infer<typeof revisionSettingsSchema>;
|
||||
|
||||
export const emptyRevisionSettingsForm = (): RevisionSettingsForm => ({
|
||||
revision_retention_minutes: ''
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||
import type { AppSettings } from '$lib/api/types.js';
|
||||
import { emptyBirdSettingsForm, type BirdSettingsForm } from './bird-settings.schema.js';
|
||||
import {
|
||||
emptyRevisionSettingsForm,
|
||||
type RevisionSettingsForm
|
||||
} from './revision-settings.schema.js';
|
||||
import {
|
||||
BIRD_SETTING_KEYS,
|
||||
KNOWN_SETTING_KEYS,
|
||||
NUMERIC_SETTING_KEYS,
|
||||
type BirdSettingKey,
|
||||
type KnownSettingKey,
|
||||
type RevisionSettingKey
|
||||
} from './settings-known-keys.js';
|
||||
|
||||
export type AdditionalSettingEntry = { id: number; key: string; value: string };
|
||||
|
||||
export type PartitionedSettings = {
|
||||
bird: BirdSettingsForm;
|
||||
revision: RevisionSettingsForm;
|
||||
additional: AdditionalSettingEntry[];
|
||||
};
|
||||
|
||||
function parseKnownValue(key: KnownSettingKey, value: unknown): string {
|
||||
if (NUMERIC_SETTING_KEYS.has(key)) {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === 'string') return value;
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string') return value;
|
||||
return '';
|
||||
}
|
||||
|
||||
export function partitionSettings(
|
||||
settings: AppSettings,
|
||||
nextId = 1
|
||||
): { partitioned: PartitionedSettings; nextId: number } {
|
||||
const bird = emptyBirdSettingsForm();
|
||||
const revision = emptyRevisionSettingsForm();
|
||||
const additional: AdditionalSettingEntry[] = [];
|
||||
let idCounter = nextId;
|
||||
|
||||
for (const [key, value] of Object.entries(settings as Record<string, unknown>)) {
|
||||
if ((BIRD_SETTING_KEYS as readonly string[]).includes(key)) {
|
||||
bird[key as BirdSettingKey] = parseKnownValue(key as KnownSettingKey, value);
|
||||
} else if (key === 'revision_retention_minutes') {
|
||||
revision.revision_retention_minutes = parseKnownValue(key as RevisionSettingKey, value);
|
||||
} else {
|
||||
additional.push({
|
||||
id: idCounter++,
|
||||
key,
|
||||
value: typeof value === 'string' ? value : String(value)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
partitioned: { bird, revision, additional },
|
||||
nextId: idCounter
|
||||
};
|
||||
}
|
||||
|
||||
export async function loadSettings(): Promise<AppSettings> {
|
||||
return apiJSON<AppSettings>('/v1/settings');
|
||||
}
|
||||
|
||||
export async function patchSettings(payload: Record<string, string | number>): Promise<void> {
|
||||
await apiMutate('/v1/settings', 'PATCH', payload);
|
||||
}
|
||||
|
||||
export function buildPayloadFromFormFields(
|
||||
keys: readonly KnownSettingKey[],
|
||||
form: Record<string, string>,
|
||||
errors: Partial<Record<string, string[]>>
|
||||
): Record<string, string | number> {
|
||||
const payload: Record<string, string | number> = {};
|
||||
for (const key of keys) {
|
||||
const value = String(form[key] ?? '').trim();
|
||||
if (!value || errors[key]?.length) continue;
|
||||
if (NUMERIC_SETTING_KEYS.has(key)) payload[key] = Number(value);
|
||||
else payload[key] = value;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
export function isKnownSettingKey(key: string): key is KnownSettingKey {
|
||||
return (KNOWN_SETTING_KEYS as readonly string[]).includes(key);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
export const BIRD_SETTING_KEYS = [
|
||||
'bird_router_id',
|
||||
'bird_local_ipv4',
|
||||
'bird_local_ipv6',
|
||||
'bird_local_asn',
|
||||
'bird_bgp_source_ipv4',
|
||||
'bird_bgp_source_ipv6'
|
||||
] as const;
|
||||
|
||||
export const REVISION_SETTING_KEYS = ['revision_retention_minutes'] as const;
|
||||
|
||||
export const KNOWN_SETTING_KEYS = [...BIRD_SETTING_KEYS, ...REVISION_SETTING_KEYS] as const;
|
||||
|
||||
export type BirdSettingKey = (typeof BIRD_SETTING_KEYS)[number];
|
||||
export type RevisionSettingKey = (typeof REVISION_SETTING_KEYS)[number];
|
||||
export type KnownSettingKey = (typeof KNOWN_SETTING_KEYS)[number];
|
||||
|
||||
export const NUMERIC_SETTING_KEYS = new Set<KnownSettingKey>([
|
||||
'bird_local_asn',
|
||||
'revision_retention_minutes'
|
||||
]);
|
||||
@@ -1,79 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
import { birdSettingsSchema, emptyBirdSettingsForm } from './bird-settings.schema.js';
|
||||
import { emptyRevisionSettingsForm, revisionSettingsSchema } from './revision-settings.schema.js';
|
||||
|
||||
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 минут' }
|
||||
)
|
||||
});
|
||||
/** @deprecated Используйте birdSettingsSchema и revisionSettingsSchema отдельно. */
|
||||
export const settingsKnownSchema = birdSettingsSchema.merge(revisionSettingsSchema);
|
||||
|
||||
/** @deprecated Используйте BirdSettingsForm и RevisionSettingsForm. */
|
||||
export type SettingsKnownForm = z.infer<typeof settingsKnownSchema>;
|
||||
|
||||
/** @deprecated Используйте emptyBirdSettingsForm и emptyRevisionSettingsForm. */
|
||||
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: ''
|
||||
...emptyBirdSettingsForm(),
|
||||
...emptyRevisionSettingsForm()
|
||||
});
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { applyTheme, readTheme, THEME_STORAGE_KEY, type ThemePreference } from './theme.js';
|
||||
|
||||
class ThemePreferencesState {
|
||||
pref = $state<ThemePreference>('system');
|
||||
|
||||
init(): void {
|
||||
if (!browser) return;
|
||||
this.pref = readTheme();
|
||||
applyTheme(this.pref);
|
||||
}
|
||||
|
||||
set(next: ThemePreference): void {
|
||||
this.pref = next;
|
||||
}
|
||||
|
||||
persist(): void {
|
||||
if (!browser) return;
|
||||
applyTheme(this.pref);
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, this.pref);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const themeState = new ThemePreferencesState();
|
||||
@@ -5,19 +5,17 @@
|
||||
import favicon from '$lib/assets/favicon.svg';
|
||||
import AppLayout from '$lib/ui/app/layout/app-layout.svelte';
|
||||
import ConfirmDialog from '$lib/ui/patterns/confirm/confirm-dialog.svelte';
|
||||
import { applyTheme, readTheme, THEME_STORAGE_KEY, type ThemePreference } from '$lib/theme.js';
|
||||
import { applyTheme } from '$lib/theme.js';
|
||||
import { themeState } from '$lib/theme-preferences.svelte.js';
|
||||
import { Toaster } from 'svelte-sonner';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let themePref = $state<ThemePreference>('system');
|
||||
|
||||
onMount(() => {
|
||||
themePref = readTheme();
|
||||
applyTheme(themePref);
|
||||
themeState.init();
|
||||
const mq = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onOs = () => {
|
||||
if (themePref === 'system') applyTheme('system');
|
||||
if (themeState.pref === 'system') applyTheme('system');
|
||||
};
|
||||
mq.addEventListener('change', onOs);
|
||||
return () => mq.removeEventListener('change', onOs);
|
||||
@@ -25,16 +23,11 @@
|
||||
|
||||
$effect(() => {
|
||||
if (!browser) return;
|
||||
applyTheme(themePref);
|
||||
try {
|
||||
localStorage.setItem(THEME_STORAGE_KEY, themePref);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
themeState.persist();
|
||||
});
|
||||
|
||||
const sonnerTheme = $derived(
|
||||
themePref === 'system' ? 'system' : themePref === 'dark' ? 'dark' : 'light'
|
||||
themeState.pref === 'system' ? 'system' : themeState.pref === 'dark' ? 'dark' : 'light'
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -44,4 +37,4 @@
|
||||
</svelte:head>
|
||||
<Toaster richColors theme={sonnerTheme} position="top-right" />
|
||||
<ConfirmDialog />
|
||||
<AppLayout bind:theme={themePref}>{@render children()}</AppLayout>
|
||||
<AppLayout bind:theme={themeState.pref}>{@render children()}</AppLayout>
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { resolve } from '$app/paths';
|
||||
import { apiJSON } from '$lib/api/client.js';
|
||||
import type { PeerRow, PeersResponse, SpeakerRow, SpeakersResponse } from '$lib/api/types.js';
|
||||
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardDescription
|
||||
} from '$lib/ui/core/card/index.js';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||
import KpiMetricsGrid from '$lib/ui/patterns/kpi/kpi-metrics-grid.svelte';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
|
||||
import NetworkSpeakersCard from '$lib/components/network/NetworkSpeakersCard.svelte';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import BirdSettingsForm from '$lib/components/network/BirdSettingsForm.svelte';
|
||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import Share2 from '@lucide/svelte/icons/share-2';
|
||||
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
|
||||
import Server from '@lucide/svelte/icons/server';
|
||||
|
||||
type NetworkTab = 'peers' | 'speakers' | 'control-plane';
|
||||
|
||||
function parseNetworkTab(value: string | null): NetworkTab {
|
||||
if (value === 'speakers' || value === 'control-plane') return value;
|
||||
return 'peers';
|
||||
}
|
||||
|
||||
let peers = $state<PeerRow[]>([]);
|
||||
let speakers = $state<SpeakerRow[]>([]);
|
||||
let peersLoading = $state(false);
|
||||
@@ -34,6 +35,8 @@
|
||||
let initialLoading = $state(true);
|
||||
let loadError = $state<string | null>(null);
|
||||
let lastUpdated = $state<Date | null>(null);
|
||||
let activeTab = $state<NetworkTab>('peers');
|
||||
let tabSyncReady = $state(false);
|
||||
|
||||
const establishedCount = $derived(peers.filter((p) => p.session_state === 'Established').length);
|
||||
|
||||
@@ -150,7 +153,27 @@
|
||||
}
|
||||
}
|
||||
|
||||
onMount(load);
|
||||
onMount(() => {
|
||||
activeTab = parseNetworkTab(page.url.searchParams.get('tab'));
|
||||
tabSyncReady = true;
|
||||
void load();
|
||||
});
|
||||
|
||||
function syncTabToUrl(tab: NetworkTab) {
|
||||
if (!tabSyncReady) return;
|
||||
const url = new URL(page.url);
|
||||
if (tab === 'peers') url.searchParams.delete('tab');
|
||||
else url.searchParams.set('tab', tab);
|
||||
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||
if (next !== `${page.url.pathname}${page.url.search}${page.url.hash}`) {
|
||||
void goto(next, { replaceState: true, keepFocus: true, noScroll: true });
|
||||
}
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
if (!tabSyncReady) return;
|
||||
syncTabToUrl(activeTab);
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col gap-6">
|
||||
@@ -187,10 +210,11 @@
|
||||
class="sm:grid-cols-3"
|
||||
/>
|
||||
|
||||
<Tabs value="peers">
|
||||
<Tabs bind:value={activeTab}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="peers">Пиры</TabsTrigger>
|
||||
<TabsTrigger value="speakers">Спикеры</TabsTrigger>
|
||||
<TabsTrigger value="control-plane">Control plane</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="peers" class="mt-4">
|
||||
@@ -213,5 +237,9 @@
|
||||
onRefresh={refreshSpeakers}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="control-plane" class="mt-4">
|
||||
<BirdSettingsForm />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -53,6 +53,7 @@
|
||||
import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte';
|
||||
import OperationsJobsTab from '$lib/components/operations/OperationsJobsTab.svelte';
|
||||
import OperationsJobsFilters from '$lib/components/operations/OperationsJobsFilters.svelte';
|
||||
import OperationsSystemSettingsTab from '$lib/components/operations/OperationsSystemSettingsTab.svelte';
|
||||
import type {
|
||||
JobDetailedReport,
|
||||
JobLogEntry,
|
||||
@@ -80,10 +81,10 @@
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
type OpsTab = 'revisions' | 'diff' | 'jobs';
|
||||
type OpsTab = 'revisions' | 'diff' | 'jobs' | 'system';
|
||||
|
||||
function parseOpsTab(value: string | null): OpsTab {
|
||||
if (value === 'diff' || value === 'jobs') return value;
|
||||
if (value === 'diff' || value === 'jobs' || value === 'system') return value;
|
||||
return 'revisions';
|
||||
}
|
||||
|
||||
@@ -882,12 +883,12 @@
|
||||
|
||||
<Alert class="border-info/30 bg-info/5">
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>Три раздела на одной странице</AlertTitle>
|
||||
<AlertTitle>Четыре раздела на одной странице</AlertTitle>
|
||||
<AlertDescription>
|
||||
<strong>Ревизии</strong> — история конфигов и откат; <strong>Сравнение</strong> — diff
|
||||
префиксов;
|
||||
<strong>Задачи</strong> — ingest, apply, rollback. Apply и Reload требуют operator. Сводный
|
||||
мониторинг BGP — на
|
||||
<strong>Задачи</strong> — ingest, apply, rollback; <strong>Система</strong> — TTL ревизий и
|
||||
дополнительные KV. Apply и Reload требуют operator. Сводный мониторинг BGP — на
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -918,6 +919,7 @@
|
||||
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
|
||||
<TabsTrigger value="diff">Сравнение</TabsTrigger>
|
||||
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
|
||||
<TabsTrigger value="system">Система</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -982,6 +984,10 @@
|
||||
jobStatusVariant={jobStatusBadgeVariant}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="system" class="mt-4">
|
||||
<OperationsSystemSettingsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,110 +1,33 @@
|
||||
<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 {
|
||||
emptySettingsKnownForm,
|
||||
settingsKnownSchema,
|
||||
type SettingsKnownForm
|
||||
} from '$lib/settings/settings-known.schema.js';
|
||||
import { themeState } from '$lib/theme-preferences.svelte.js';
|
||||
import type { ThemePreference } from '$lib/theme.js';
|
||||
import { Button } from '$lib/ui/core/button/index.js';
|
||||
import { Card, CardContent, CardHeader, CardDescription } from '$lib/ui/core/card/index.js';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle
|
||||
} 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 { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||
import { notify } 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';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||
|
||||
let token = $state('');
|
||||
let apiSettings = $state<AppSettings | null>(null);
|
||||
let loadingSettings = $state(false);
|
||||
let savingSettings = $state(false);
|
||||
let additionalSettings = $state<Array<{ id: number; key: string; value: string }>>([]);
|
||||
let additionalIdCounter = $state(1);
|
||||
|
||||
type KnownFieldKey = keyof SettingsKnownForm;
|
||||
|
||||
type SettingsSection = 'token' | 'bird' | 'revisions' | 'additional';
|
||||
|
||||
const knownFieldKeys: KnownFieldKey[] = [
|
||||
'bird_router_id',
|
||||
'bird_local_ipv4',
|
||||
'bird_local_ipv6',
|
||||
'bird_local_asn',
|
||||
'bird_bgp_source_ipv4',
|
||||
'bird_bgp_source_ipv6',
|
||||
'revision_retention_minutes'
|
||||
const themeOptions: Array<{ value: ThemePreference; label: string }> = [
|
||||
{ value: 'light', label: 'Светлая' },
|
||||
{ value: 'dark', label: 'Тёмная' },
|
||||
{ value: 'system', label: 'Как в системе' }
|
||||
];
|
||||
|
||||
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 }> = [
|
||||
{ id: 'token', label: 'API-ключ', description: 'Авторизация в UI' },
|
||||
{ id: 'bird', label: 'BIRD', description: 'Сетевые параметры' },
|
||||
{ id: 'revisions', label: 'Ревизии', description: 'Хранение истории' },
|
||||
{ id: 'additional', label: 'Дополнительно', description: 'Ключ-значение' }
|
||||
];
|
||||
|
||||
let hasValidationErrors = $derived(knownFieldKeys.some((key) => Boolean($errors[key]?.length)));
|
||||
|
||||
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: '',
|
||||
revision_retention_minutes: ''
|
||||
};
|
||||
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' || key === 'revision_retention_minutes') {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) parsedKnown[key] = String(value);
|
||||
else if (typeof value === 'string') parsedKnown[key] = value;
|
||||
} else if (typeof value === 'string') {
|
||||
parsedKnown[key as KnownFieldKey] = value;
|
||||
}
|
||||
} else {
|
||||
parsedAdditional.push({
|
||||
id: additionalIdCounter++,
|
||||
key,
|
||||
value: typeof value === 'string' ? value : String(value)
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
reset({ data: parsedKnown });
|
||||
additionalSettings = parsedAdditional;
|
||||
}
|
||||
|
||||
function saveToken() {
|
||||
if (!browser) return;
|
||||
const t = token.trim();
|
||||
@@ -113,295 +36,75 @@
|
||||
notify.success('Токен сохранён');
|
||||
}
|
||||
|
||||
async function loadApiSettings() {
|
||||
loadingSettings = true;
|
||||
try {
|
||||
const s = await apiJSON<AppSettings>('/v1/settings');
|
||||
apiSettings = s;
|
||||
resetFormFromApi(s);
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
loadingSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
let canSaveSettings = $derived.by(() => {
|
||||
if (loadingSettings || savingSettings || hasValidationErrors) return false;
|
||||
|
||||
const hasKnownValues = knownFieldKeys.some((key) => {
|
||||
const value = String($form[key] ?? '').trim();
|
||||
return value !== '' && !$errors[key]?.length;
|
||||
});
|
||||
const hasAdditionalValues = additionalSettings.some((entry) => entry.key.trim() !== '');
|
||||
|
||||
return hasKnownValues || hasAdditionalValues;
|
||||
});
|
||||
|
||||
async function saveApiSettings() {
|
||||
const validation = await validateForm({ update: true });
|
||||
if (!validation.valid) {
|
||||
notify.error('Исправьте ошибки в полях настроек');
|
||||
return;
|
||||
}
|
||||
if (!canSaveSettings) {
|
||||
notify.error('Нечего сохранять или есть ошибки в полях');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, string | number> = {};
|
||||
for (const key of knownFieldKeys) {
|
||||
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;
|
||||
}
|
||||
for (const entry of additionalSettings) {
|
||||
const key = entry.key.trim();
|
||||
if (!key) continue;
|
||||
payload[key] = entry.value;
|
||||
}
|
||||
|
||||
savingSettings = true;
|
||||
try {
|
||||
await apiMutate('/v1/settings', 'PATCH', payload);
|
||||
notify.success('Настройки сохранены');
|
||||
await loadApiSettings();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
savingSettings = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
themeState.init();
|
||||
if (browser) {
|
||||
token = localStorage.getItem(TOKEN_STORAGE_KEY) ?? '';
|
||||
}
|
||||
void loadApiSettings();
|
||||
});
|
||||
|
||||
function onThemeChange(value: string) {
|
||||
if (value === 'light' || value === 'dark' || value === 'system') {
|
||||
themeState.set(value);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex max-w-5xl flex-col gap-6">
|
||||
<div class="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
<PageHeader
|
||||
title="Настройки"
|
||||
description="Управление токеном доступа и глобальными параметрами control plane."
|
||||
description="Параметры браузера и подключения к API."
|
||||
icon={SettingsIcon}
|
||||
iconClass="bg-muted text-muted-foreground"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="rounded-xl border border-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-sm text-muted-foreground">
|
||||
Bearer-токен хранится только в localStorage браузера. Для локального демо с
|
||||
<code class="rounded bg-muted px-1 py-0.5 text-xs">EVOBGP_DEV_INSECURE=1</code>
|
||||
используйте токен <code class="rounded bg-muted 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-sm text-muted-foreground">Загрузка…</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>
|
||||
|
||||
<FormField
|
||||
id="bird-router-id"
|
||||
label="Router ID (bird_router_id)"
|
||||
error={$errors.bird_router_id?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-router-id"
|
||||
bind:value={$form.bird_router_id}
|
||||
placeholder="203.0.113.1"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="bird-local-ipv4"
|
||||
label="Локальный IPv4 (bird_local_ipv4)"
|
||||
error={$errors.bird_local_ipv4?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-local-ipv4"
|
||||
bind:value={$form.bird_local_ipv4}
|
||||
placeholder="198.51.100.10"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField
|
||||
id="bird-local-ipv6"
|
||||
label="Локальный IPv6 (bird_local_ipv6)"
|
||||
error={$errors.bird_local_ipv6?.[0]}
|
||||
>
|
||||
<Input
|
||||
id="bird-local-ipv6"
|
||||
bind:value={$form.bird_local_ipv6}
|
||||
placeholder="2001:db8::10"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<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={$form.bird_local_asn}
|
||||
placeholder="65001"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<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={$form.bird_bgp_source_ipv4}
|
||||
placeholder="198.51.100.11"
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<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={$form.bird_bgp_source_ipv6}
|
||||
placeholder="2001:db8::11"
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
{:else if activeSection === 'revisions'}
|
||||
<div class="space-y-3">
|
||||
<h2 class="text-base font-semibold">Управление ревизиями</h2>
|
||||
|
||||
<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={$form.revision_retention_minutes}
|
||||
placeholder="43200"
|
||||
/>
|
||||
</FormField>
|
||||
</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-sm text-muted-foreground">Нет дополнительных параметров.</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">
|
||||
<CardHeader>
|
||||
<CardTitle>API-ключ</CardTitle>
|
||||
<CardDescription>
|
||||
<code class="text-xs">GET/PATCH /v1/settings</code> — глобальные параметры control plane (хранятся
|
||||
в БД). Требуется роль operator.
|
||||
Bearer-токен хранится только в localStorage браузера. Для локального демо с
|
||||
<code class="text-xs">EVOBGP_DEV_INSECURE=1</code> используйте токен
|
||||
<code class="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>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Оформление</CardTitle>
|
||||
<CardDescription>
|
||||
Тема интерфейса. Быстрый переключатель также доступен в боковой панели.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-2">
|
||||
<Label for="theme-select">Тема</Label>
|
||||
<Select type="single" value={themeState.pref} onValueChange={onThemeChange}>
|
||||
<SelectTrigger id="theme-select" class="w-full max-w-xs">
|
||||
{themeOptions.find((o) => o.value === themeState.pref)?.label ?? 'Как в системе'}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{#each themeOptions as option (option.value)}
|
||||
<SelectItem value={option.value} label={option.label}>{option.label}</SelectItem>
|
||||
{/each}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user