feat(runtime-logs): enhance runtime log management and configuration
Добавлены новые возможности для управления файловыми логами в Docker-сервисах: - Обновлены конфигурации для поддержки логов, включая переменные окружения и монтирование директорий. - Документация обновлена для описания новых эндпоинтов и параметров, связанных с логами. - Упрощен доступ к логам через API и интерфейс пользователя. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { resolve } from '$app/paths';
|
||||
import { loadSettings, partitionSettings } 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 { notifyApiError } from '$lib/ui/app/toast.js';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
bird_router_id: 'Router ID',
|
||||
bird_local_ipv4: 'Local IPv4',
|
||||
bird_local_ipv6: 'Local IPv6',
|
||||
bird_local_asn: 'Local ASN',
|
||||
bird_bgp_source_ipv4: 'BGP source IPv4',
|
||||
bird_bgp_source_ipv6: 'BGP source IPv6'
|
||||
};
|
||||
|
||||
let loading = $state(true);
|
||||
let values = $state<Record<string, string>>({});
|
||||
|
||||
onMount(() => {
|
||||
void (async () => {
|
||||
loading = true;
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const { partitioned } = partitionSettings(settings);
|
||||
const out: Record<string, string> = {};
|
||||
for (const key of BIRD_SETTING_KEYS) {
|
||||
const v = String(partitioned.bird[key] ?? '').trim();
|
||||
if (v) out[key] = v;
|
||||
}
|
||||
values = out;
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
})();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>BIRD (кратко)</CardTitle>
|
||||
<CardDescription>
|
||||
Глобальные параметры BIRD из tenant settings. Полная форма — в разделе «Параметры».
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
{#if loading}
|
||||
<p class="text-sm text-muted-foreground">Загрузка…</p>
|
||||
{:else if Object.keys(values).length === 0}
|
||||
<p class="text-sm text-muted-foreground">Параметры BIRD ещё не заданы.</p>
|
||||
{:else}
|
||||
<dl class="grid gap-2 text-sm sm:grid-cols-2">
|
||||
{#each Object.entries(values) as [key, value] (key)}
|
||||
<div class="rounded-md border bg-muted/30 px-3 py-2">
|
||||
<dt class="text-muted-foreground">{labels[key] ?? key}</dt>
|
||||
<dd class="font-mono text-xs break-all">{value}</dd>
|
||||
</div>
|
||||
{/each}
|
||||
</dl>
|
||||
{/if}
|
||||
|
||||
<Button variant="outline" href={resolve('/tenant-settings?tab=bird')}>
|
||||
<SlidersHorizontal class="size-4" />
|
||||
Изменить параметры
|
||||
<ArrowRight class="size-4" />
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -1,223 +0,0 @@
|
||||
<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,149 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
loadSettings,
|
||||
partitionSettings,
|
||||
patchSettings,
|
||||
type AdditionalSettingEntry
|
||||
} from '$lib/settings/settings-api.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 EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
|
||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||
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';
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let loaded = $state(false);
|
||||
let additionalSettings = $state<AdditionalSettingEntry[]>([]);
|
||||
let additionalIdCounter = $state(1);
|
||||
|
||||
let canSave = $derived.by(() => {
|
||||
if (loading || saving || !loaded) return false;
|
||||
return additionalSettings.some((entry) => entry.key.trim() !== '');
|
||||
});
|
||||
|
||||
function addAdditionalSetting() {
|
||||
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
|
||||
}
|
||||
|
||||
function requestRemoveAdditionalSetting(entry: AdditionalSettingEntry) {
|
||||
const key = entry.key.trim();
|
||||
void confirm({
|
||||
title: key ? `Удалить параметр «${key}»?` : 'Удалить строку?',
|
||||
description: key
|
||||
? 'Строка исчезнет из формы. Чтобы удалить ключ из tenant, сохраните без него или очистите значение и примените PATCH.'
|
||||
: 'Несохранённая пустая строка будет удалена из формы.',
|
||||
confirmLabel: 'Удалить',
|
||||
destructive: Boolean(key),
|
||||
onConfirm: async () => {
|
||||
additionalSettings = additionalSettings.filter((row) => row.id !== entry.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const { partitioned, nextId } = partitionSettings(settings, additionalIdCounter);
|
||||
additionalSettings = partitioned.additional;
|
||||
additionalIdCounter = nextId;
|
||||
loaded = true;
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave) {
|
||||
notify.error('Нечего сохранять');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: 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>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="space-y-1">
|
||||
<CardTitle>Дополнительные параметры</CardTitle>
|
||||
<CardDescription>Произвольные KV-пары в global_settings (operator).</CardDescription>
|
||||
</div>
|
||||
{#if loaded}
|
||||
<Button variant="outline" size="sm" onclick={addAdditionalSetting}>
|
||||
<Plus class="size-4" />
|
||||
Добавить строку
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
{#if loading && !loaded}
|
||||
<p class="text-sm text-muted-foreground">Загрузка…</p>
|
||||
{:else if !loaded}
|
||||
<Button variant="outline" onclick={load}>Загрузить настройки</Button>
|
||||
{: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={() => requestRemoveAdditionalSetting(entry)}
|
||||
>
|
||||
<Trash2 class="size-4" />
|
||||
</Button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<Button onclick={save} disabled={!canSave}>
|
||||
<Save />
|
||||
{saving ? 'Сохранение…' : 'Применить дополнительные параметры'}
|
||||
</Button>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
+4
-4
@@ -104,7 +104,7 @@
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Control plane</CardTitle>
|
||||
<CardTitle>BIRD control plane</CardTitle>
|
||||
<CardDescription>
|
||||
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через
|
||||
<code class="text-xs">PATCH /v1/settings</code> (роль operator).
|
||||
@@ -115,8 +115,8 @@
|
||||
<Info class="text-info" />
|
||||
<AlertTitle>Подстановка в конфиг</AlertTitle>
|
||||
<AlertDescription>
|
||||
Значения используются при генерации BIRD-конфигурации в pipeline (router id, local AS,
|
||||
адреса). Пиры и спикеры настраиваются на соседних вкладках.
|
||||
Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса). Пиры и
|
||||
спикеры настраиваются в разделе «Сеть».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
@@ -205,7 +205,7 @@
|
||||
|
||||
<Button onclick={save} disabled={!canSave}>
|
||||
<Save />
|
||||
{saving ? 'Сохранение…' : 'Применить параметры'}
|
||||
{saving ? 'Сохранение…' : 'Применить параметры BIRD'}
|
||||
</Button>
|
||||
{/if}
|
||||
</CardContent>
|
||||
@@ -0,0 +1,138 @@
|
||||
<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
|
||||
} 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 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';
|
||||
|
||||
let loading = $state(false);
|
||||
let saving = $state(false);
|
||||
let loaded = $state(false);
|
||||
|
||||
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));
|
||||
|
||||
let canSave = $derived.by(() => {
|
||||
if (loading || saving || hasValidationErrors || !loaded) return false;
|
||||
return String($form.revision_retention_minutes ?? '').trim() !== '';
|
||||
});
|
||||
|
||||
async function load() {
|
||||
loading = true;
|
||||
try {
|
||||
const settings = await loadSettings();
|
||||
const { partitioned } = partitionSettings(settings);
|
||||
reset({ data: partitioned.revision });
|
||||
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[]>>
|
||||
);
|
||||
|
||||
saving = true;
|
||||
try {
|
||||
await patchSettings(payload);
|
||||
notify.success('Параметры хранения ревизий сохранены');
|
||||
await load();
|
||||
} catch (e) {
|
||||
notifyApiError(e);
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Хранение ревизий</CardTitle>
|
||||
<CardDescription>
|
||||
Автоматическая очистка старых ревизий. Последняя раскатанная ревизия не удаляется.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent class="space-y-4">
|
||||
{#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 hasValidationErrors}
|
||||
<p class="text-sm text-destructive">
|
||||
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
<Button onclick={save} disabled={!canSave}>
|
||||
<Save />
|
||||
{saving ? 'Сохранение…' : 'Применить параметры ревизий'}
|
||||
</Button>
|
||||
{/if}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||
import TenantBirdSettingsCard from '$lib/components/tenant-settings/TenantBirdSettingsCard.svelte';
|
||||
import TenantRevisionSettingsCard from '$lib/components/tenant-settings/TenantRevisionSettingsCard.svelte';
|
||||
import TenantAdditionalSettingsCard from '$lib/components/tenant-settings/TenantAdditionalSettingsCard.svelte';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
|
||||
type TenantSettingsTab = 'bird' | 'revision' | 'additional';
|
||||
|
||||
function parseTenantSettingsTab(value: string | null): TenantSettingsTab {
|
||||
if (value === 'revision' || value === 'additional') return value;
|
||||
return 'bird';
|
||||
}
|
||||
|
||||
let activeTab = $state<TenantSettingsTab>('bird');
|
||||
let tabSyncReady = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
activeTab = parseTenantSettingsTab(page.url.searchParams.get('tab'));
|
||||
tabSyncReady = true;
|
||||
});
|
||||
|
||||
function syncTabToUrl(tab: TenantSettingsTab) {
|
||||
if (!tabSyncReady) return;
|
||||
const url = new URL(page.url);
|
||||
if (tab === 'bird') 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">
|
||||
<PageHeader
|
||||
title="Параметры tenant"
|
||||
description="Параметры control plane для текущего tenant (API /v1/settings). Токен и тема интерфейса — в разделе «Настройки»."
|
||||
icon={SlidersHorizontal}
|
||||
iconClass="bg-chart-5/15 text-chart-5"
|
||||
/>
|
||||
|
||||
<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>
|
||||
|
||||
<Tabs bind:value={activeTab}>
|
||||
<div class="overflow-x-auto pb-1 [scrollbar-gutter:stable]">
|
||||
<TabsList class="inline-flex min-w-max">
|
||||
<TabsTrigger value="bird">BIRD</TabsTrigger>
|
||||
<TabsTrigger value="revision">Ревизии</TabsTrigger>
|
||||
<TabsTrigger value="additional">Дополнительно</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
<TabsContent value="bird" class="mt-4">
|
||||
<TenantBirdSettingsCard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="revision" class="mt-4">
|
||||
<TenantRevisionSettingsCard />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="additional" class="mt-4">
|
||||
<TenantAdditionalSettingsCard />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
@@ -5,7 +5,7 @@ import { emptyRevisionSettingsForm, revisionSettingsSchema } from './revision-se
|
||||
/** @deprecated Используйте birdSettingsSchema и revisionSettingsSchema отдельно. */
|
||||
export const settingsKnownSchema = birdSettingsSchema.merge(revisionSettingsSchema);
|
||||
|
||||
/** @deprecated Используйте BirdSettingsForm и RevisionSettingsForm. */
|
||||
/** @deprecated Используйте TenantBirdSettingsCard и TenantRevisionSettingsCard. */
|
||||
export type SettingsKnownForm = z.infer<typeof settingsKnownSchema>;
|
||||
|
||||
/** @deprecated Используйте emptyBirdSettingsForm и emptyRevisionSettingsForm. */
|
||||
|
||||
@@ -8,6 +8,7 @@ import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||
import Network from '@lucide/svelte/icons/network';
|
||||
import Settings from '@lucide/svelte/icons/settings';
|
||||
import Shield from '@lucide/svelte/icons/shield';
|
||||
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
|
||||
export type NavItem = {
|
||||
href: string;
|
||||
label: string;
|
||||
@@ -21,7 +22,8 @@ export const mainNav: NavItem[] = [
|
||||
{ href: '/network', label: 'Сеть', icon: Network },
|
||||
{ href: '/operations', label: 'Ревизии', icon: Activity },
|
||||
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
|
||||
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge }
|
||||
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge },
|
||||
{ href: '/tenant-settings', label: 'Параметры', icon: SlidersHorizontal }
|
||||
];
|
||||
|
||||
export const bottomNav: NavItem[] = [
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
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 BirdSettingsForm from '$lib/components/network/BirdSettingsForm.svelte';
|
||||
import NetworkBirdSettingsSummaryCard from '$lib/components/network/NetworkBirdSettingsSummaryCard.svelte';
|
||||
import NetworkOverviewTab from '$lib/components/network/NetworkOverviewTab.svelte';
|
||||
import NetworkSpeakerDetailSheet from '$lib/components/network/NetworkSpeakerDetailSheet.svelte';
|
||||
import NetworkAutoRefreshToggle from '$lib/components/network/NetworkAutoRefreshToggle.svelte';
|
||||
@@ -238,7 +238,7 @@
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="control-plane" class="mt-4">
|
||||
<BirdSettingsForm />
|
||||
<NetworkBirdSettingsSummaryCard />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
@@ -53,7 +53,6 @@
|
||||
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,
|
||||
@@ -82,10 +81,10 @@
|
||||
import Info from '@lucide/svelte/icons/info';
|
||||
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||
|
||||
type OpsTab = 'revisions' | 'diff' | 'jobs' | 'system';
|
||||
type OpsTab = 'revisions' | 'diff' | 'jobs';
|
||||
|
||||
function parseOpsTab(value: string | null): OpsTab {
|
||||
if (value === 'diff' || value === 'jobs' || value === 'system') return value;
|
||||
if (value === 'diff' || value === 'jobs') return value;
|
||||
return 'revisions';
|
||||
}
|
||||
|
||||
@@ -351,7 +350,12 @@
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
activeTab = parseOpsTab(page.url.searchParams.get('tab'));
|
||||
const tabParam = page.url.searchParams.get('tab');
|
||||
if (tabParam === 'system') {
|
||||
void goto(resolve('/tenant-settings?tab=revision'), { replaceState: true });
|
||||
return;
|
||||
}
|
||||
activeTab = parseOpsTab(tabParam);
|
||||
lastLoadedTab = activeTab;
|
||||
tabSyncReady = true;
|
||||
void refreshActiveTab(true);
|
||||
@@ -459,9 +463,6 @@
|
||||
break;
|
||||
case 'diff':
|
||||
break;
|
||||
case 'system':
|
||||
await loadBirdStatus();
|
||||
break;
|
||||
default:
|
||||
await loadRevisions();
|
||||
}
|
||||
@@ -928,12 +929,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; <strong>Система</strong> — TTL ревизий и
|
||||
дополнительные KV. Apply и Reload требуют operator. Сводный мониторинг BGP — на
|
||||
префиксов; <strong>Задачи</strong> — ingest, apply, rollback. TTL ревизий и tenant KV — в
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/tenant-settings')}>Параметры</Button
|
||||
>. Apply и Reload требуют operator. Сводный мониторинг BGP — на
|
||||
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</Button>.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -964,7 +965,6 @@
|
||||
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
|
||||
<TabsTrigger value="diff">Сравнение</TabsTrigger>
|
||||
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
|
||||
<TabsTrigger value="system">Система</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
@@ -1029,10 +1029,6 @@
|
||||
jobStatusVariant={jobStatusBadgeVariant}
|
||||
/>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="system" class="mt-4">
|
||||
<OperationsSystemSettingsTab />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import TenantSettingsPage from '$lib/components/tenant-settings/TenantSettingsPage.svelte';
|
||||
</script>
|
||||
|
||||
<TenantSettingsPage />
|
||||
Reference in New Issue
Block a user