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.
224 lines
6.8 KiB
Svelte
224 lines
6.8 KiB
Svelte
<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>
|