feat(monorepo): restructure web components and update configurations
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 38s
CI / go (push) Successful in 2m36s
CI / bird2 (push) Successful in 15s
CI / release (push) Failing after 3m7s

Refactored the project structure to support a monorepo setup, moving the web application to `apps/web/` and updating related configurations. Adjusted pre-commit hooks to use `pnpm` for linting and formatting. Updated CI workflows to reflect the new directory structure and dependencies. Removed legacy files and configurations from the previous `web/` directory, streamlining the project for better maintainability and clarity.
This commit is contained in:
Denozordec
2026-06-30 23:54:28 +07:00
parent db75126bea
commit a37c931ee7
377 changed files with 8537 additions and 1093 deletions
@@ -0,0 +1,284 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '$lib/api/types.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import AppInput from '$lib/components/patterns/form/app-input.svelte';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Trash2 from '@lucide/svelte/icons/trash-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Copy from '@lucide/svelte/icons/copy';
type Props = {
items: ApiKey[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
const roleOptions: Array<{ value: ApiKeyRole; label: string }> = [
{ value: 'viewer', label: 'viewer — только чтение' },
{ value: 'editor', label: 'editor — CRUD без apply' },
{ value: 'operator', label: 'operator — полный доступ' },
{ value: 'node', label: 'node — только API ноды' }
];
let dialogOpen = $state(false);
let tokenDialogOpen = $state(false);
let revealedToken = $state('');
let form = $state<ApiKeyCreate>({ name: '', role: 'editor' });
let expiresLocal = $state('');
let saving = $state(false);
const columns = [
{ id: 'name', label: 'Имя', sortable: true, sortValue: (k: ApiKey) => k.name },
{ id: 'role', label: 'Роль', sortable: true, sortValue: (k: ApiKey) => k.role },
{ id: 'prefix', label: 'Префикс', sortable: true, sortValue: (k: ApiKey) => k.prefix },
{
id: 'revoked',
label: 'Статус',
sortable: true,
sortValue: (k: ApiKey) => (k.revoked_at ? 1 : 0)
},
{ id: 'actions', label: '', class: 'w-24' }
] as const;
function openCreate() {
form = { name: '', role: 'editor' };
expiresLocal = '';
dialogOpen = true;
}
function showToken(created: ApiKeyCreated) {
revealedToken = created.token;
tokenDialogOpen = true;
}
async function copyToken() {
try {
await navigator.clipboard.writeText(revealedToken);
notify.success('Скопировано');
} catch {
notify.error('Не удалось скопировать');
}
}
function requestRevoke(k: ApiKey) {
if (k.revoked_at) return;
void confirm({
title: 'Отозвать API-ключ?',
description: `${k.name} (${k.prefix}…)`,
confirmLabel: 'Отозвать',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/api-keys/${k.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Ключ отозван');
await onRefresh();
}
});
}
function requestRotate(k: ApiKey) {
if (k.revoked_at) return;
void confirm({
title: 'Ротировать ключ?',
description: 'Старый токен перестанет работать сразу.',
confirmLabel: 'Ротировать',
onConfirm: async () => {
try {
const out = await apiMutate<ApiKeyCreated>(
`/v1/api-keys/${k.id}/rotate`,
'POST',
undefined,
{ idempotent: false }
);
notify.success('Ключ обновлён');
showToken(out);
await onRefresh();
} catch (e) {
notifyApiError(e);
}
}
});
}
async function save() {
if (!form.name.trim()) {
notify.error('Укажите имя');
return;
}
saving = true;
try {
const body: ApiKeyCreate = {
name: form.name.trim(),
role: form.role
};
if (expiresLocal.trim()) {
const d = new Date(expiresLocal);
if (Number.isNaN(d.getTime())) {
notify.error('Некорректная дата истечения');
return;
}
body.expires_at = d.toISOString();
}
const created = await apiMutate<ApiKeyCreated>('/v1/api-keys', 'POST', body);
notify.success('Ключ создан');
dialogOpen = false;
showToken(created);
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">API-ключи</CardTitle>
<CardDescription>
Управление ключами tenant. Полный токен показывается только при создании и ротации.
</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" variant="outline" onclick={() => onRefresh()} disabled={loading}>
<RefreshCw class={loading ? 'animate-spin' : ''} />
Обновить
</Button>
<Button size="sm" onclick={openCreate}><Plus />Создать</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(k) => k.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет ключей"
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
>
{#snippet cell({ row: k, column })}
{#if column.id === 'name'}
<span class="font-medium">{k.name}</span>
{:else if column.id === 'role'}
<span class="font-mono text-sm">{k.role}</span>
{:else if column.id === 'prefix'}
<span class="font-mono text-xs text-muted-foreground">{k.prefix}</span>
{:else if column.id === 'revoked'}
{#if k.revoked_at}
<span class="text-sm text-destructive">отозван</span>
{:else}
<span class="text-sm text-muted-foreground">активен</span>
{/if}
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button
variant="ghost"
size="icon-sm"
title="Ротировать"
disabled={!!k.revoked_at}
onclick={() => requestRotate(k)}
>
<RefreshCw class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
disabled={!!k.revoked_at}
onclick={() => requestRevoke(k)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Новый API-ключ</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="Имя" id="key-name" required>
<AppInput id="key-name" bind:value={form.name} placeholder="CI / оператор UI" />
</FormField>
<FormField label="Роль" id="key-role" required>
<Select
type="single"
value={form.role}
onValueChange={(v) => (form.role = v as ApiKeyRole)}
>
<SelectTrigger id="key-role" class="w-full">
{roleOptions.find((o) => o.value === form.role)?.label ?? form.role}
</SelectTrigger>
<SelectContent>
{#each roleOptions as opt (opt.value)}
<SelectItem value={opt.value} label={opt.label}>{opt.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</FormField>
<FormField label="Истекает (опционально)" id="key-expires">
<AppInput id="key-expires" type="datetime-local" bind:value={expiresLocal} />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Создание…' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={tokenDialogOpen}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Сохраните токен</DialogTitle>
<DialogDescription
>Он больше не будет показан. Скопируйте в безопасное хранилище.</DialogDescription
>
</DialogHeader>
<div class="rounded-md border bg-muted/40 p-3 font-mono text-xs break-all">{revealedToken}</div>
<DialogFooter>
<Button variant="outline" onclick={copyToken}><Copy />Копировать</Button>
<Button onclick={() => (tokenDialogOpen = false)}>Готово</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,29 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
/** preserve — вывод CLI (колонки birdc); wrap — конфиги и JSON с длинными строками */
type Variant = 'preserve' | 'wrap';
let {
variant = 'preserve',
text,
class: className = ''
}: {
variant?: Variant;
text: string;
class?: string;
} = $props();
const shell =
'border-border bg-muted/40 relative isolate min-w-0 overflow-auto rounded-lg border [scrollbar-gutter:stable] overscroll-contain';
const prePreserve =
'text-foreground m-0 block w-max min-w-full p-4 font-mono text-[0.8125rem] leading-normal whitespace-pre select-text';
const preWrap =
'text-foreground m-0 block min-w-0 w-full max-w-none p-4 font-mono text-[0.8125rem] leading-relaxed whitespace-pre-wrap break-words select-text';
</script>
<div class={cn(shell, className)} data-slot="scroll-pre-block">
<pre class={variant === 'preserve' ? prePreserve : preWrap}>{text}</pre>
</div>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import PageShell from '$lib/components/page-shell.svelte';
import QueryState from '$lib/components/query-state.svelte';
type Props = {
title: string;
description?: string;
actions?: Snippet;
data: unknown;
isLoading: boolean;
isError: boolean;
error?: unknown;
onRetry?: () => void;
skeleton?: Snippet;
content: Snippet;
};
let {
title,
description,
actions,
data,
isLoading,
isError,
error,
onRetry,
skeleton,
content
}: Props = $props();
</script>
<PageShell>
<PageHeader {title} {description}>
{#snippet actions()}
{#if actions}
{@render actions()}
{/if}
{/snippet}
</PageHeader>
{#if isLoading}
{#if skeleton}
{@render skeleton()}
{/if}
{:else if isError}
<QueryState {data} isLoading={false} isError={true} {error} {onRetry} children={emptyChild} />
{:else}
{@render content()}
{/if}
</PageShell>
{#snippet emptyChild()}{/snippet}
@@ -0,0 +1,42 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import { cn } from '$lib/utils.js';
type Props = {
title?: string;
description?: string;
toolbar?: Snippet;
class?: string;
children: Snippet;
};
let { title, description, toolbar, class: className, children }: Props = $props();
</script>
<Card class={cn('gap-0 py-0', className)}>
{#if title || description || toolbar}
<CardHeader
class="flex flex-row flex-wrap items-start justify-between gap-2 border-b px-4 py-3"
>
<div class="flex min-w-0 flex-col gap-0.5">
{#if title}
<CardTitle class="text-base">{title}</CardTitle>
{/if}
{#if description}
<CardDescription>{description}</CardDescription>
{/if}
</div>
{#if toolbar}
<div class="flex shrink-0 items-center gap-2">{@render toolbar()}</div>
{/if}
</CardHeader>
{/if}
<CardContent class="p-0">{@render children()}</CardContent>
</Card>
@@ -0,0 +1,188 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, BgpCommunityCreate } from '$lib/api/types.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@evobgp/ui/components/dialog/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import AppInput from '$lib/components/patterns/form/app-input.svelte';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
items: BgpCommunity[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<BgpCommunity | null>(null);
let form = $state<BgpCommunityCreate>({ community: '', title: '' });
let saving = $state(false);
const columns = [
{
id: 'community',
label: 'Код сообщества',
sortable: true,
sortValue: (c: BgpCommunity) => c.community
},
{
id: 'title',
label: 'Название',
sortable: true,
sortValue: (c: BgpCommunity) => c.title ?? ''
},
{ id: 'id', label: 'ID' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function displayName(c: BgpCommunity | null) {
if (!c) return '';
const t = c.title?.trim();
return t || c.community;
}
function openCreate() {
editTarget = null;
form = { community: '', title: '' };
dialogOpen = true;
}
function openEdit(c: BgpCommunity) {
editTarget = c;
form = { community: c.community, title: c.title ?? '' };
dialogOpen = true;
}
function requestDelete(c: BgpCommunity) {
void confirm({
title: `Удалить сообщество «${displayName(c)}»?`,
description: 'Это приведёт к удалению привязки во всех модулях.',
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/communities/${c.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Удалено');
await onRefresh();
}
});
}
async function save() {
if (!form.community.trim()) {
notify.error('Укажите community');
return;
}
saving = true;
try {
const body = { ...form, title: form.title?.trim() || undefined };
if (editTarget) {
await apiMutate(`/v1/communities/${editTarget.id}`, 'PATCH', body);
notify.success('Запись сообщества обновлена');
} else {
await apiMutate('/v1/communities', 'POST', body);
notify.success('Сообщество создано');
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Сообщества BGP</CardTitle>
<CardDescription>Используются для тегирования префиксов в AS- и CDN-модулях</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(c) => c.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет сообществ BGP"
emptyDescription="Создайте первое сообщество для тегирования префиксов."
>
{#snippet cell({ row: c, column })}
{#if column.id === 'community'}
<span class="font-mono text-sm font-medium">{c.community}</span>
{:else if column.id === 'title'}
<span>{c.title?.trim() || '—'}</span>
{:else if column.id === 'id'}
<span class="font-mono text-xs text-muted-foreground">{c.id}</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(c)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(c)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle
>{editTarget ? 'Редактировать сообщество BGP' : 'Новое сообщество BGP'}</DialogTitle
>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="Код сообщества" id="c-community" required>
<AppInput id="c-community" bind:value={form.community} placeholder="65001:120" />
</FormField>
<FormField label="Название" id="c-title" description="Человекочитаемое имя для списков">
<AppInput id="c-title" bind:value={form.title} placeholder="Название" />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,178 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { DohProfile, DohProfileCreate } from '$lib/api/types.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@evobgp/ui/components/dialog/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import AppInput from '$lib/components/patterns/form/app-input.svelte';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
items: DohProfile[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<DohProfile | null>(null);
let form = $state<DohProfileCreate & { timeout_ms?: number | null }>({
url: '',
timeout_ms: null,
vault_secret_ref: null
});
let saving = $state(false);
const columns = [
{ id: 'url', label: 'URL', sortable: true, sortValue: (d: DohProfile) => d.url },
{
id: 'timeout_ms',
label: 'Таймаут (мс)',
sortable: true,
sortValue: (d: DohProfile) => d.timeout_ms ?? 0
},
{ id: 'id', label: 'ID' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function openCreate() {
editTarget = null;
form = { url: '', timeout_ms: null, vault_secret_ref: null };
dialogOpen = true;
}
function openEdit(d: DohProfile) {
editTarget = d;
form = { url: d.url, timeout_ms: d.timeout_ms, vault_secret_ref: d.vault_secret_ref };
dialogOpen = true;
}
function requestDelete(d: DohProfile) {
void confirm({
title: 'Удалить DoH профиль?',
description: d.url,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/doh-profiles/${d.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Удалено');
await onRefresh();
}
});
}
async function save() {
if (!form.url.trim()) {
notify.error('Укажите URL');
return;
}
saving = true;
try {
if (editTarget) {
await apiMutate(`/v1/doh-profiles/${editTarget.id}`, 'PATCH', form);
notify.success('DoH профиль обновлён');
} else {
await apiMutate('/v1/doh-profiles', 'POST', form);
notify.success('DoH профиль создан');
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">DoH профили</CardTitle>
<CardDescription>DNS-over-HTTPS серверы для резолвинга доменных модулей</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(d) => d.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет DoH профилей"
emptyDescription="Добавьте DNS-over-HTTPS сервер для доменных модулей."
>
{#snippet cell({ row: d, column })}
{#if column.id === 'url'}
<span class="font-mono text-sm">{d.url}</span>
{:else if column.id === 'timeout_ms'}
<span class="text-muted-foreground">{d.timeout_ms ?? '—'}</span>
{:else if column.id === 'id'}
<span class="font-mono text-xs text-muted-foreground">{d.id}</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(d)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(d)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать' : 'Новый'} DoH профиль</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="URL" id="doh-url" required>
<AppInput id="doh-url" bind:value={form.url} placeholder="https://dns.google/dns-query" />
</FormField>
<FormField label="Таймаут (мс)" id="doh-timeout">
<AppInput id="doh-timeout" type="number" bind:value={form.timeout_ms} placeholder="5000" />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Component, Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
type Props = {
title?: string;
description?: string;
icon?: Component;
action?: Snippet;
class?: string;
};
let { title = 'Нет данных', description, icon: Icon, action, class: className }: Props = $props();
</script>
<div
class={cn('flex flex-col items-center justify-center gap-2 px-4 py-12 text-center', className)}
>
{#if Icon}
<div class="mb-1 text-muted-foreground/60" aria-hidden="true">
<Icon class="size-10" />
</div>
{/if}
<p class="text-sm font-medium">{title}</p>
{#if description}
<p class="max-w-sm text-sm text-muted-foreground">{description}</p>
{/if}
{#if action}
<div class="mt-2">{@render action()}</div>
{/if}
</div>
@@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as Sheet from '@evobgp/ui/components/sheet/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
type Props = {
open: boolean;
title: string;
description?: string;
onOpenChange: (open: boolean) => void;
onSubmit?: () => void;
submitLabel?: string;
submitting?: boolean;
children: Snippet;
};
let {
open,
title,
description,
onOpenChange,
onSubmit,
submitLabel = 'Сохранить',
submitting = false,
children
}: Props = $props();
</script>
<Sheet.Root {open} {onOpenChange}>
<Sheet.Content class="flex w-full flex-col gap-0 sm:max-w-lg">
<Sheet.Header>
<Sheet.Title>{title}</Sheet.Title>
{#if description}
<Sheet.Description>{description}</Sheet.Description>
{/if}
</Sheet.Header>
<div class="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
{@render children()}
</div>
{#if onSubmit}
<Sheet.Footer>
<Button onclick={onSubmit} disabled={submitting}>{submitLabel}</Button>
</Sheet.Footer>
{/if}
</Sheet.Content>
</Sheet.Root>
@@ -0,0 +1,116 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import * as Sidebar from '@evobgp/ui/components/sidebar/index.js';
import * as Breadcrumb from '@evobgp/ui/components/breadcrumb/index.js';
import { Separator } from '@evobgp/ui/components/separator/index.js';
import type { ThemePreference } from '$lib/theme.js';
import { mainNav, bottomNav } from '$lib/ui/app/layout/nav.js';
import ThemeMenu from '$lib/ui/app/layout/theme-menu.svelte';
import AppVersion from '$lib/ui/app/layout/app-version.svelte';
import AppMobileNav from '$lib/ui/app/layout/app-mobile-nav.svelte';
type Props = {
children: Snippet;
theme?: ThemePreference;
};
let { children, theme = $bindable<ThemePreference>('system') }: Props = $props();
let mobileNavOpen = $state(false);
const routeLabels: Record<string, string> = {
'/': 'Обзор',
...Object.fromEntries(mainNav.map((i) => [i.href, i.label])),
...Object.fromEntries(bottomNav.map((i) => [i.href, i.label]))
};
const breadcrumbLabel = $derived(routeLabels[page.url.pathname] ?? 'EvoBGP');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const navHref = (href: string) => resolve(href as any);
function isActive(href: string) {
const pathname = page.url.pathname;
if (href === '/') return pathname === '/';
return pathname === href || pathname.startsWith(href + '/');
}
</script>
<Sidebar.Provider>
<Sidebar.Root>
<Sidebar.Header class="border-b border-sidebar-border">
<div class="flex items-center gap-2 px-2 py-1">
<div class="flex min-w-0 flex-1 flex-col group-data-[collapsible=icon]:hidden">
<a href={navHref('/')} class="truncate font-semibold tracking-tight">EvoBGP</a>
<p class="truncate text-xs text-muted-foreground">Панель управления</p>
</div>
<ThemeMenu bind:theme />
</div>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupLabel>Операции</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each mainNav as item (item.href)}
{@const Icon = item.icon}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive(item.href)}>
{#snippet child({ props })}
<a href={navHref(item.href)} {...props}>
<Icon class="size-4" />
<span>{item.label}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer class="border-t border-sidebar-border">
<Sidebar.Menu>
{#each bottomNav as item (item.href)}
{@const Icon = item.icon}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive(item.href)}>
{#snippet child({ props })}
<a href={navHref(item.href)} {...props}>
<Icon class="size-4" />
<span>{item.label}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
<AppVersion />
</Sidebar.Footer>
<Sidebar.Rail />
</Sidebar.Root>
<Sidebar.Inset>
<header
class="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<div class="flex items-center gap-2 md:hidden">
<AppMobileNav bind:open={mobileNavOpen} bind:theme />
<span class="font-semibold tracking-tight">EvoBGP</span>
</div>
<Sidebar.Trigger class="-ms-1 hidden md:flex" />
<Separator orientation="vertical" class="mx-2 hidden h-4 md:block" />
<Breadcrumb.Root class="hidden min-w-0 md:flex">
<Breadcrumb.List>
<Breadcrumb.Item>
<Breadcrumb.Page>{breadcrumbLabel}</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</header>
<main class="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">
{@render children()}
</main>
</Sidebar.Inset>
</Sidebar.Provider>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import X from '@lucide/svelte/icons/x';
export type FilterChip = {
id: string;
label: string;
};
type Props = {
chips?: FilterChip[];
onRemoveChip?: (id: string) => void;
onClear?: () => void;
children?: Snippet;
};
let { chips = [], onRemoveChip, onClear, children }: Props = $props();
</script>
<div class="flex flex-wrap items-center gap-2">
{#if children}
{@render children()}
{/if}
{#each chips as chip (chip.id)}
<Badge variant="secondary" class="gap-1 pr-1">
{chip.label}
{#if onRemoveChip}
<Button
variant="ghost"
size="icon-sm"
class="size-5"
onclick={() => onRemoveChip(chip.id)}
aria-label="Убрать фильтр {chip.label}"
>
<X class="size-3" />
</Button>
{/if}
</Badge>
{/each}
{#if chips.length > 0 && onClear}
<Button variant="ghost" size="sm" onclick={onClear}>Сбросить</Button>
{/if}
</div>
@@ -0,0 +1,342 @@
<script lang="ts">
import { apiFetch, apiMutate } from '$lib/api/client.js';
import type { AsEntry, BgpCommunity, ModuleRow } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import {
communityLabel,
sanitizeFilenamePart,
supportsCsvIO
} from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleAsEntryDialog from '$lib/components/modules/ModuleAsEntryDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download';
type Props = {
moduleId: string;
mod: ModuleRow;
entries: AsEntry[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<AsEntry | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
let csvImporting = $state(false);
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const activeSelected = $derived.by(() => {
const allowed = new Set(entries.map((e) => e.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'asn', label: 'ASN', sortable: true, sortValue: (e: AsEntry) => e.asn },
{
id: 'name',
label: 'Название AS',
sortable: true,
sortValue: (e: AsEntry) => e.asn_name ?? ''
},
{
id: 'prefixes',
label: 'Префиксов',
sortable: true,
sortValue: (e: AsEntry) => e.prefix_count ?? 0,
class: 'text-right'
},
{
id: 'updated',
label: 'Обновлено',
sortable: true,
sortValue: (e: AsEntry) => e.asn_resolved_at ?? ''
},
{ id: 'community', label: 'Community' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(entry: AsEntry) {
editTarget = entry;
dialogOpen = true;
}
function requestDelete(entry: AsEntry) {
void confirm({
title: 'Удалить запись?',
description: `ASN: ${entry.asn}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${entry.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные AS-записи?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено AS-записей: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
async function readErrorText(res: Response): Promise<string> {
const body = (await res.text()).trim();
return body || `HTTP ${res.status}`;
}
async function exportCsv() {
if (!supportsCsvIO(mod.type) || csvExporting) return;
csvExporting = true;
try {
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'GET',
headers: { Accept: 'text/csv' }
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
notifyApiError(e);
} finally {
csvExporting = false;
}
}
function openImportPicker() {
if (!supportsCsvIO(mod.type) || csvImporting) return;
csvFileInput?.click();
}
async function handleImportChange(event: Event) {
const input = event.currentTarget as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file || csvImporting) return;
csvImporting = true;
try {
const fileText = await file.text();
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: fileText
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const payload = (await res.json()) as { imported?: number };
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
await onChanged();
} catch (e) {
notifyApiError(e);
} finally {
csvImporting = false;
if (input) input.value = '';
}
}
</script>
<input
class="hidden"
type="file"
accept=".csv,text/csv"
bind:this={csvFileInput}
onchange={handleImportChange}
/>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">AS-записи</CardTitle>
<CardDescription>
Номер AS и community; имя, число префиксов и дата обновляются при успешном refresh
(RIPEstat).
</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button
variant="outline"
size="sm"
onclick={openImportPicker}
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
>
<Upload />
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
</Button>
<Button
variant="outline"
size="sm"
onclick={exportCsv}
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
>
<Download />
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
</Button>
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={entries}
rowKey={(e) => e.id}
{loading}
emptyTitle="Нет AS-записей"
emptyDescription="Добавьте ASN или импортируйте CSV."
>
{#snippet toolbar()}
{#if entries.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все AS-записи"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: entry, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(entry.id)}
aria-label={`Выбрать AS ${entry.asn}`}
onCheckedChange={() => toggleSelection(entry.id)}
/>
{:else if column.id === 'asn'}
<span class="font-mono">{entry.asn}</span>
{:else if column.id === 'name'}
<span
class="max-w-[14rem] truncate text-sm text-muted-foreground"
title={entry.asn_name ?? ''}
>
{entry.asn_name?.trim() ? entry.asn_name : '—'}
</span>
{:else if column.id === 'prefixes'}
<span class="font-mono text-sm">
{entry.prefix_count != null ? entry.prefix_count : '—'}
</span>
{:else if column.id === 'updated'}
<span class="text-sm whitespace-nowrap text-muted-foreground">
{formatDateTime(entry.asn_resolved_at)}
</span>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(entry)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleAsEntryDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,142 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: AsEntry | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let form = $state<AsEntryCreate>({ asn: 0, community_id: null });
let initKey = $state('');
function resetForm() {
form = edit
? { asn: edit.asn, community_id: edit.community_id }
: { asn: 0, community_id: null };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function save() {
const asn = Number(form.asn);
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
notify.error('Укажите корректный ASN (14294967295)');
return;
}
saving = true;
try {
const body: AsEntryCreate | AsEntryPatch = { asn, community_id: form.community_id };
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${edit.id}`, 'PATCH', body);
notify.success('Запись обновлена');
} else {
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate);
notify.success('Запись добавлена');
}
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
<DialogDescription>
Номер автономной системы и community для политики анонса.
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="as-asn">ASN</Label>
<Input
id="as-asn"
type="number"
placeholder="12345"
bind:value={form.asn}
min={1}
max={4294967295}
/>
</div>
<div class="space-y-1.5">
<Label for="as-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="as-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,275 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type {
BgpCommunity,
CdnPreviewResponse,
CdnSource,
CdnSourceCreate
} from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
normalizeCdnSourceKind,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: CdnSource | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let previewLoading = $state(false);
let previewItems = $state<string[]>([]);
let previewTotal = $state(0);
let previewTruncated = $state(false);
let previewError = $state<string | null>(null);
let previewOk = $state(false);
let form = $state<CdnSourceCreate & { refresh_interval_sec?: number | null }>({
url: '',
source_kind: 'plaintext',
prefix_path: '',
community_id: null
});
let initKey = $state('');
function clearPreview() {
previewLoading = false;
previewItems = [];
previewTotal = 0;
previewTruncated = false;
previewError = null;
previewOk = false;
}
function resetForm() {
clearPreview();
form = edit
? {
url: edit.url,
source_kind: normalizeCdnSourceKind(edit.source_kind),
prefix_path: edit.prefix_path ?? '',
community_id: edit.community_id,
refresh_interval_sec: edit.refresh_interval_sec
}
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function previewCdn() {
const urlTrim = form.url.trim();
if (!urlTrim) {
notify.error('Укажите URL');
return;
}
previewLoading = true;
previewError = null;
previewOk = false;
try {
const res = await apiMutate<CdnPreviewResponse>(
`/v1/modules/${moduleId}/cdn-sources/preview`,
'POST',
{
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? ''
}
);
previewItems = res.items;
previewTotal = res.total;
previewTruncated = res.truncated;
previewOk = true;
} catch (e) {
previewError = e instanceof Error ? e.message : String(e);
previewItems = [];
previewTotal = 0;
previewTruncated = false;
previewOk = false;
} finally {
previewLoading = false;
}
}
async function save() {
const urlTrim = form.url.trim();
if (!urlTrim) {
notify.error('Укажите URL');
return;
}
saving = true;
try {
const body = {
...form,
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? ''
};
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body);
notify.success('Источник обновлён');
} else {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body);
notify.success('Источник добавлен');
}
clearPreview();
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) {
clearPreview();
onClose();
}
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="cdn-url">URL</Label>
<Input id="cdn-url" placeholder="https://example.com/list.txt" bind:value={form.url} />
</div>
<div class="space-y-1.5">
<Label for="cdn-kind">Тип источника</Label>
<Select
type="single"
value={form.source_kind}
onValueChange={(v) => {
form.source_kind = v || 'plaintext';
}}
>
<SelectTrigger id="cdn-kind" class="w-full">
{form.source_kind === 'json' ? 'json' : 'plaintext'}
</SelectTrigger>
<SelectContent>
<SelectItem value="plaintext">plaintext</SelectItem>
<SelectItem value="json">json</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label for="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
id="cdn-prefix-path"
placeholder="напр. prefixes[] или data.items[].cidr"
bind:value={form.prefix_path}
/>
{#if form.source_kind === 'json' && !form.prefix_path?.trim()}
<p class="text-xs text-muted-foreground">
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
</p>
{/if}
</div>
<div class="space-y-1.5">
<Label for="cdn-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="cdn-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label for="cdn-interval">Интервал обновления (сек)</Label>
<Input
id="cdn-interval"
type="number"
placeholder="3600"
bind:value={form.refresh_interval_sec}
/>
</div>
<div class="flex flex-col gap-2 rounded-lg border border-border p-3">
<div class="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onclick={previewCdn}
disabled={previewLoading}
>
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
</Button>
{#if previewError}
<span class="text-sm text-destructive">{previewError}</span>
{:else if previewOk}
<span class="text-sm text-muted-foreground">
Всего: {previewTotal}{#if previewTruncated}
<span class="text-amber-600 dark:text-amber-500"> (обрезано)</span>{/if}
</span>
{/if}
</div>
{#if previewItems.length}
<ul class="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
{#each previewItems as item, i (`${i}-${item}`)}
<li class="py-0.5">{item}</li>
{/each}
</ul>
{/if}
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,237 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, CdnSource } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import {
communityLabel,
normalizeCdnSourceKind
} from '$lib/components/modules/module-helpers.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleCdnSourceDialog from '$lib/components/modules/ModuleCdnSourceDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
moduleId: string;
sources: CdnSource[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, sources, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<CdnSource | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
const activeSelected = $derived.by(() => {
const allowed = new Set(sources.map((s) => s.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(sources.length > 0 && sources.every((s) => selectedIds.has(s.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'url', label: 'URL', sortable: true, sortValue: (s: CdnSource) => s.url },
{ id: 'kind', label: 'Тип', sortable: true, sortValue: (s: CdnSource) => s.source_kind },
{ id: 'community', label: 'Community' },
{
id: 'interval',
label: 'Интервал',
sortable: true,
sortValue: (s: CdnSource) => s.refresh_interval_sec ?? 0
},
{
id: 'refreshed',
label: 'Последнее обновление',
sortable: true,
sortValue: (s: CdnSource) => s.last_refreshed_at ?? ''
},
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(sources.map((s) => s.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(src: CdnSource) {
editTarget = src;
dialogOpen = true;
}
function requestDelete(src: CdnSource) {
void confirm({
title: 'Удалить CDN-источник?',
description: src.url,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${src.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные CDN-источники?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено CDN-источников: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
</script>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">CDN-источники</CardTitle>
<CardDescription>URL источников для скачивания списков CIDR.</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={sources}
rowKey={(s) => s.id}
{loading}
emptyTitle="Нет CDN-источников"
emptyDescription="Добавьте URL для загрузки списков CIDR."
>
{#snippet toolbar()}
{#if sources.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все CDN-источники"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: src, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(src.id)}
aria-label="Выбрать CDN-источник"
onCheckedChange={() => toggleSelection(src.id)}
/>
{:else if column.id === 'url'}
<span class="max-w-xs truncate font-mono text-xs" title={src.url}>{src.url}</span>
{:else if column.id === 'kind'}
<div class="flex flex-col gap-0.5">
<Badge variant="outline">{normalizeCdnSourceKind(src.source_kind)}</Badge>
{#if src.prefix_path?.trim()}
<span
class="font-mono text-xs break-all text-muted-foreground"
title={src.prefix_path}>{src.prefix_path}</span
>
{/if}
</div>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(src.community_id, communities)}
</span>
{:else if column.id === 'interval'}
<span class="text-sm text-muted-foreground">
{src.refresh_interval_sec != null ? `${src.refresh_interval_sec}с` : '—'}
</span>
{:else if column.id === 'refreshed'}
<span class="text-sm whitespace-nowrap text-muted-foreground">
{formatDateTime(src.last_refreshed_at)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(src)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(src)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleCdnSourceDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,149 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { ModuleCreate } from '$lib/api/types.js';
import { moduleTypeRu } from '$lib/ui-labels.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { Switch } from '@evobgp/ui/components/switch/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
onClose: () => void;
onCreated: () => void | Promise<void>;
};
let { open = $bindable(), onClose, onCreated }: Props = $props();
let saving = $state(false);
let form = $state<ModuleCreate>({
type: 'AS_PREFIXES',
name: '',
enabled: true,
priority: 0
});
const moduleTypes = [
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') }
] as const;
function resetForm() {
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
}
async function create() {
if (!form.name.trim()) {
notify.error('Укажите название модуля');
return;
}
saving = true;
try {
await apiMutate('/v1/modules', 'POST', form);
notify.success('Модуль создан');
open = false;
resetForm();
await onCreated();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) {
onClose();
resetForm();
}
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Новый модуль</DialogTitle>
<DialogDescription>Создание нового модуля префиксов.</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="m-name">Название</Label>
<Input id="m-name" bind:value={form.name} placeholder="my-asn-module" />
</div>
<div class="space-y-1.5">
<Label for="m-type">Тип</Label>
<Select
type="single"
value={form.type}
onValueChange={(v) => {
if (v) form.type = v as typeof form.type;
}}
>
<SelectTrigger id="m-type" class="w-full">
{moduleTypes.find((t) => t.value === form.type)?.label ?? 'Выберите тип'}
</SelectTrigger>
<SelectContent>
{#each moduleTypes as t (t.value)}
<SelectItem value={t.value}>{t.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="m-priority">Приоритет</Label>
<Input id="m-priority" type="number" bind:value={form.priority} />
</div>
<div class="space-y-1.5">
<Label for="m-interval">Интервал (сек)</Label>
<Input
id="m-interval"
type="number"
placeholder="3600"
bind:value={form.refresh_interval_sec}
/>
</div>
</div>
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="m-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Модуль участвует в сборке ревизий, если включён.
</p>
</div>
<Switch
id="m-enabled"
class="shrink-0"
checked={form.enabled !== false}
onCheckedChange={(v) => {
form = { ...form, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={create} disabled={saving}>{saving ? 'Создание…' : 'Создать'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,57 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { ModuleRow } from '$lib/api/types.js';
import { moduleEnabledRu, moduleEnabledBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Blocks from '@lucide/svelte/icons/blocks';
type Props = {
mod: ModuleRow;
refreshing: boolean;
onRefresh: () => void;
onEdit: () => void;
onDelete: () => void;
};
let { mod, refreshing, onRefresh, onEdit, onDelete }: Props = $props();
</script>
<div class="flex min-w-0 items-start gap-2">
<Button variant="ghost" size="icon-sm" class="mt-1 shrink-0" href={resolve('/modules')}>
<ArrowLeft class="size-4" />
</Button>
<PageHeader
class="min-w-0 flex-1"
title={mod.name}
description={mod.id}
icon={Blocks}
iconClass="bg-chart-2/15 text-chart-2"
>
{#snippet actions()}
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">{moduleTypeRu(mod.type)}</Badge>
<Badge variant={moduleEnabledBadgeVariant(!!mod.enabled)} class="text-xs">
{moduleEnabledRu(!!mod.enabled)}
</Badge>
<Button variant="outline" size="sm" onclick={() => onRefresh()} disabled={refreshing}>
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<Button variant="outline" size="sm" onclick={() => onEdit()}>
<Pencil />
Редактировать
</Button>
<Button variant="destructive" size="sm" onclick={() => onDelete()}>
<Trash2 />
Удалить
</Button>
</div>
{/snippet}
</PageHeader>
</div>
@@ -0,0 +1,302 @@
<script lang="ts">
import { apiFetch, apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, DomainEntry, ModuleRow } from '$lib/api/types.js';
import {
communityLabel,
sanitizeFilenamePart,
supportsCsvIO
} from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleDomainEntryDialog from '$lib/components/modules/ModuleDomainEntryDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download';
type Props = {
moduleId: string;
mod: ModuleRow;
entries: DomainEntry[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<DomainEntry | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
let csvImporting = $state(false);
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const activeSelected = $derived.by(() => {
const allowed = new Set(entries.map((e) => e.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'fqdn', label: 'FQDN', sortable: true, sortValue: (e: DomainEntry) => e.fqdn },
{ id: 'community', label: 'Community' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(entry: DomainEntry) {
editTarget = entry;
dialogOpen = true;
}
function requestDelete(entry: DomainEntry) {
void confirm({
title: 'Удалить домен?',
description: entry.fqdn,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${entry.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные домены?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено доменов: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
async function readErrorText(res: Response): Promise<string> {
const body = (await res.text()).trim();
return body || `HTTP ${res.status}`;
}
async function exportCsv() {
if (!supportsCsvIO(mod.type) || csvExporting) return;
csvExporting = true;
try {
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'GET',
headers: { Accept: 'text/csv' }
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
notifyApiError(e);
} finally {
csvExporting = false;
}
}
function openImportPicker() {
if (!supportsCsvIO(mod.type) || csvImporting) return;
csvFileInput?.click();
}
async function handleImportChange(event: Event) {
const input = event.currentTarget as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file || csvImporting) return;
csvImporting = true;
try {
const fileText = await file.text();
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: fileText
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const payload = (await res.json()) as { imported?: number };
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
await onChanged();
} catch (e) {
notifyApiError(e);
} finally {
csvImporting = false;
if (input) input.value = '';
}
}
</script>
<input
class="hidden"
type="file"
accept=".csv,text/csv"
bind:this={csvFileInput}
onchange={handleImportChange}
/>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Домены</CardTitle>
<CardDescription>FQDN для резолвинга через DoH.</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button
variant="outline"
size="sm"
onclick={openImportPicker}
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
>
<Upload />
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
</Button>
<Button
variant="outline"
size="sm"
onclick={exportCsv}
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
>
<Download />
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
</Button>
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={entries}
rowKey={(e) => e.id}
{loading}
emptyTitle="Нет доменов"
emptyDescription="Добавьте FQDN или импортируйте CSV."
>
{#snippet toolbar()}
{#if entries.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все домены"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: entry, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(entry.id)}
aria-label={`Выбрать домен ${entry.fqdn}`}
onCheckedChange={() => toggleSelection(entry.id)}
/>
{:else if column.id === 'fqdn'}
<span class="font-mono">{entry.fqdn}</span>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(entry)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleDomainEntryDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,125 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: DomainEntry | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let form = $state<DomainEntryCreate>({ fqdn: '', community_id: null });
let initKey = $state('');
function resetForm() {
form = edit
? { fqdn: edit.fqdn, community_id: edit.community_id }
: { fqdn: '', community_id: null };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function save() {
saving = true;
try {
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', form);
notify.success('Домен обновлён');
} else {
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', form);
notify.success('Домен добавлен');
}
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="dom-fqdn">FQDN</Label>
<Input id="dom-fqdn" placeholder="example.com" bind:value={form.fqdn} />
</div>
<div class="space-y-1.5">
<Label for="dom-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="dom-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,299 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type {
BgpCommunity,
DohProfile,
DohResolverPolicy,
ModulePatch,
ModuleRow
} from '$lib/api/types.js';
import { dohPolicyRu } from '$lib/ui-labels.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
moduleDohProfileIds,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { Switch } from '@evobgp/ui/components/switch/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
const dohPolicyOptions: { value: DohResolverPolicy; label: string; hint: string }[] = [
{
value: 'primary_only',
label: 'Только первый',
hint: 'Используется первый выбранный DoH-профиль.'
},
{
value: 'failover',
label: 'Резервирование',
hint: 'Профили по порядку до первого успешного ответа.'
},
{
value: 'union',
label: 'Объединение',
hint: 'Все A/AAAA со всех профилей (geo-split DNS).'
}
];
type Props = {
open: boolean;
mod: ModuleRow;
moduleId: string;
communities: BgpCommunity[];
dohProfiles: DohProfile[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let {
open = $bindable(),
mod,
moduleId,
communities,
dohProfiles,
onSaved,
onClose
}: Props = $props();
let editForm = $state<ModulePatch>({});
let editSaving = $state(false);
let initKey = $state('');
function resetEditForm() {
editForm = {
name: mod.name,
enabled: mod.enabled,
priority: mod.priority,
refresh_interval_sec: mod.refresh_interval_sec,
cron_expr: mod.cron_expr,
default_community_id: mod.default_community_id,
doh_profile_ids: moduleDohProfileIds(mod),
doh_resolver_policy: mod.doh_resolver_policy ?? 'primary_only'
};
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = mod.id;
if (nextKey !== initKey) {
initKey = nextKey;
resetEditForm();
}
});
function toggleEditDohProfile(id: string, checked: boolean) {
let ids = [...(editForm.doh_profile_ids ?? [])];
if (checked) {
if (!ids.includes(id)) ids.push(id);
} else {
ids = ids.filter((x) => x !== id);
}
editForm = { ...editForm, doh_profile_ids: ids };
}
function isEditDohProfileSelected(id: string): boolean {
return (editForm.doh_profile_ids ?? []).includes(id);
}
async function saveMod() {
editSaving = true;
try {
const cron =
typeof editForm.cron_expr === 'string' ? editForm.cron_expr.trim() : editForm.cron_expr;
const intervalRaw = editForm.refresh_interval_sec;
const interval =
intervalRaw === null || intervalRaw === undefined ? null : Number(intervalRaw);
const payload: ModulePatch = {
...editForm,
cron_expr: cron ? cron : null,
refresh_interval_sec: Number.isFinite(interval) ? interval : null,
default_community_id: fromNullableSelect(
nullableSelectValue(editForm.default_community_id)
),
doh_profile_ids: editForm.doh_profile_ids ?? [],
doh_resolver_policy: editForm.doh_resolver_policy ?? 'primary_only'
};
await apiMutate<ModuleRow>(`/v1/modules/${moduleId}`, 'PATCH', payload);
notify.success('Модуль обновлён');
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
editSaving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Редактировать модуль</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="e-name">Название</Label>
<Input id="e-name" bind:value={editForm.name} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="e-priority">Приоритет</Label>
<Input id="e-priority" type="number" bind:value={editForm.priority} />
</div>
<div class="space-y-1.5">
<Label for="e-interval">Интервал (сек)</Label>
<Input id="e-interval" type="number" bind:value={editForm.refresh_interval_sec} />
</div>
</div>
<div class="space-y-1.5">
<Label for="e-cron">Cron-выражение</Label>
<Input id="e-cron" placeholder="0 */6 * * *" bind:value={editForm.cron_expr} />
</div>
<div class="space-y-1.5">
<Label for="e-comm">Community по умолчанию</Label>
<Select
type="single"
value={nullableSelectValue(editForm.default_community_id)}
onValueChange={(v) => {
editForm.default_community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="e-comm" class="w-full">
{editForm.default_community_id
? communityLabel(editForm.default_community_id, communities)
: 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => {
editForm.default_community_id = null;
}}
>
Сбросить
</Button>
</div>
{#if mod.type === 'DOMAINS'}
<div class="space-y-1.5">
<Label for="e-doh-policy">Политика DoH</Label>
<Select
type="single"
value={editForm.doh_resolver_policy ?? 'primary_only'}
onValueChange={(v) => {
if (v) editForm.doh_resolver_policy = v as DohResolverPolicy;
}}
>
<SelectTrigger id="e-doh-policy" class="w-full">
{dohPolicyRu(editForm.doh_resolver_policy)}
</SelectTrigger>
<SelectContent>
{#each dohPolicyOptions as opt (opt.value)}
<SelectItem value={opt.value}>{opt.label}</SelectItem>
{/each}
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{dohPolicyOptions.find(
(o) => o.value === (editForm.doh_resolver_policy ?? 'primary_only')
)?.hint}
</p>
</div>
<div class="space-y-2">
<Label>DoH-профили</Label>
<p class="text-xs text-muted-foreground">
Порядок выбора = порядок в списке (сверху вниз).
</p>
<div class="max-h-40 space-y-2 overflow-y-auto rounded-md border p-3">
{#each dohProfiles as d (d.id)}
<label class="flex items-start gap-2 text-sm">
<Checkbox
checked={isEditDohProfileSelected(d.id)}
onCheckedChange={(v) => toggleEditDohProfile(d.id, v === true)}
/>
<span class="min-w-0 break-all">
<span class="font-medium">{d.name?.trim() ? d.name : d.url}</span>
{#if d.name?.trim()}
<span class="block font-mono text-xs text-muted-foreground">{d.url}</span>
{/if}
</span>
</label>
{:else}
<p class="text-xs text-muted-foreground">Нет профилей — создайте в справочниках.</p>
{/each}
</div>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => {
editForm = { ...editForm, doh_profile_ids: [] };
}}
>
Сбросить профили
</Button>
</div>
{/if}
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="e-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Отключённые модули не участвуют в обновлении конфигурации.
</p>
</div>
<Switch
id="e-enabled"
class="shrink-0"
checked={editForm.enabled !== false}
onCheckedChange={(v) => {
editForm = { ...editForm, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={saveMod} disabled={editSaving}>
{editSaving ? 'Сохранение…' : 'Сохранить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,120 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '$lib/api/types.js';
import { communityLabel, communityOptionLabel } from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: IpRangeEntry | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let form = $state<IpRangeEntryCreate>({ prefix: '', community_id: '' });
let initKey = $state('');
function resetForm() {
form = edit
? { prefix: edit.prefix, community_id: edit.community_id }
: { prefix: '', community_id: '' };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function save() {
saving = true;
try {
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${edit.id}`, 'PATCH', form);
notify.success('Диапазон обновлён');
} else {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', form);
notify.success('Диапазон добавлен');
}
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="ip-prefix">Префикс (CIDR)</Label>
<Input id="ip-prefix" placeholder="203.0.113.0/24" bind:value={form.prefix} />
</div>
<div class="space-y-1.5">
<Label for="ip-comm">Community (обязательно)</Label>
<Select
type="single"
value={form.community_id}
onValueChange={(v) => {
form.community_id = v;
}}
>
<SelectTrigger id="ip-comm" class="w-full">
{form.community_id
? communityLabel(form.community_id, communities)
: 'Выберите community'}
</SelectTrigger>
<SelectContent>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,310 @@
<script lang="ts">
import { apiFetch, apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, IpRangeEntry, ModuleRow } from '$lib/api/types.js';
import {
communityLabel,
sanitizeFilenamePart,
supportsCsvIO
} from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleIpRangeEntryDialog from '$lib/components/modules/ModuleIpRangeEntryDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download';
type Props = {
moduleId: string;
mod: ModuleRow;
entries: IpRangeEntry[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<IpRangeEntry | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
let csvImporting = $state(false);
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const activeSelected = $derived.by(() => {
const allowed = new Set(entries.map((e) => e.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{
id: 'prefix',
label: 'Префикс (CIDR)',
sortable: true,
sortValue: (e: IpRangeEntry) => e.prefix
},
{ id: 'community', label: 'Community' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(entry: IpRangeEntry) {
editTarget = entry;
dialogOpen = true;
}
function requestDelete(entry: IpRangeEntry) {
void confirm({
title: 'Удалить диапазон?',
description: entry.prefix,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(
`/v1/modules/${moduleId}/ip-range-entries/${entry.id}`,
'DELETE',
undefined,
{ idempotent: false }
);
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные диапазоны?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено диапазонов: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
async function readErrorText(res: Response): Promise<string> {
const body = (await res.text()).trim();
return body || `HTTP ${res.status}`;
}
async function exportCsv() {
if (!supportsCsvIO(mod.type) || csvExporting) return;
csvExporting = true;
try {
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'GET',
headers: { Accept: 'text/csv' }
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
notifyApiError(e);
} finally {
csvExporting = false;
}
}
function openImportPicker() {
if (!supportsCsvIO(mod.type) || csvImporting) return;
csvFileInput?.click();
}
async function handleImportChange(event: Event) {
const input = event.currentTarget as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file || csvImporting) return;
csvImporting = true;
try {
const fileText = await file.text();
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: fileText
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const payload = (await res.json()) as { imported?: number };
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
await onChanged();
} catch (e) {
notifyApiError(e);
} finally {
csvImporting = false;
if (input) input.value = '';
}
}
</script>
<input
class="hidden"
type="file"
accept=".csv,text/csv"
bind:this={csvFileInput}
onchange={handleImportChange}
/>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">IP-диапазоны</CardTitle>
<CardDescription>Статические CIDR для анонса.</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button
variant="outline"
size="sm"
onclick={openImportPicker}
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
>
<Upload />
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
</Button>
<Button
variant="outline"
size="sm"
onclick={exportCsv}
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
>
<Download />
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
</Button>
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={entries}
rowKey={(e) => e.id}
{loading}
emptyTitle="Нет диапазонов"
emptyDescription="Добавьте CIDR или импортируйте CSV."
>
{#snippet toolbar()}
{#if entries.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все диапазоны"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: entry, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(entry.id)}
aria-label={`Выбрать диапазон ${entry.prefix}`}
onCheckedChange={() => toggleSelection(entry.id)}
/>
{:else if column.id === 'prefix'}
<span class="font-mono">{entry.prefix}</span>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(entry)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleIpRangeEntryDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,155 @@
<script lang="ts">
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '$lib/api/types.js';
import { formatDateTime, moduleIntervalLabel } from '$lib/modules/display.js';
import { dohPolicyRu } from '$lib/ui-labels.js';
import {
communityLabel,
dohProfileLabel,
moduleDohProfileIds
} from '$lib/components/modules/module-helpers.js';
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card/index.js';
import CardSkeleton from '$lib/components/patterns/feedback/card-skeleton.svelte';
import { cn } from '$lib/utils.js';
import ArrowDownUp from '@lucide/svelte/icons/arrow-down-up';
import Timer from '@lucide/svelte/icons/timer';
import ShieldCheck from '@lucide/svelte/icons/shield-check';
import Network from '@lucide/svelte/icons/network';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
type Props = {
mod: ModuleRow | null;
communities: BgpCommunity[];
dohProfiles: DohProfile[];
asEntries: AsEntry[];
loading?: boolean;
};
let { mod, communities, dohProfiles, asEntries, loading = false }: Props = $props();
const asPrefixTotal = $derived(
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
);
const statAccents = [
{
border: 'border-l-chart-3',
bg: 'bg-chart-3/5',
iconBg: 'bg-chart-3/15',
iconText: 'text-chart-3'
},
{
border: 'border-l-chart-5',
bg: 'bg-chart-5/5',
iconBg: 'bg-chart-5/15',
iconText: 'text-chart-5'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-muted-foreground',
bg: 'bg-muted/30',
iconBg: 'bg-muted',
iconText: 'text-muted-foreground'
}
] as const;
const kpiCards = $derived.by(() => {
if (!mod) return [];
const dohIds = moduleDohProfileIds(mod);
return [
{
id: 'priority',
label: 'Приоритет',
value: String(mod.priority ?? 0),
description: 'порядок в сборке ревизии',
icon: ArrowDownUp,
accent: statAccents[0]
},
{
id: 'interval',
label: 'Интервал',
value: moduleIntervalLabel(mod),
description: 'refresh_interval_sec / cron',
icon: Timer,
accent: statAccents[1],
mono: true
},
{
id: 'doh',
label: 'DoH',
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
description:
mod.type === 'DOMAINS'
? dohIds.length
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
: 'Системный DNS'
: 'не применимо',
icon: Network,
accent: statAccents[2]
},
{
id: 'community',
label: 'Community по умолч.',
value: communityLabel(mod.default_community_id, communities),
description: 'для записей без своего community',
icon: ShieldCheck,
accent: statAccents[3]
},
{
id: 'refreshed',
label: 'Последнее обновление',
value: formatDateTime(mod.last_refreshed_at),
description:
mod.type === 'AS_PREFIXES'
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
: 'время последнего refresh',
icon: RefreshCw,
accent: statAccents[4]
}
];
});
</script>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
{#if loading}
{#each Array(5) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card class={cn('overflow-hidden border-l-4 shadow-sm', a.border, a.bg)}>
<CardHeader class="pb-2">
<p class="flex items-center gap-1.5 text-xs text-muted-foreground">
<span
class={cn('flex size-7 shrink-0 items-center justify-center rounded-md', a.iconBg)}
aria-hidden="true"
>
<Icon class={cn('size-3.5', a.iconText)} />
</span>
{card.label}
</p>
<CardTitle
class={cn('text-base font-semibold break-all', card.mono ? 'font-mono text-sm' : '')}
>
{card.value}
</CardTitle>
</CardHeader>
<CardContent>
<p class="line-clamp-2 text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
@@ -0,0 +1,115 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { AsEntry, CdnSource, DomainEntry, IpRangeEntry, ModuleRow } from '$lib/api/types.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
type Props = {
mod: ModuleRow;
asEntries: AsEntry[];
cdnSources: CdnSource[];
domainEntries: DomainEntry[];
ipEntries: IpRangeEntry[];
};
let { mod, asEntries, cdnSources, domainEntries, ipEntries }: Props = $props();
const asPrefixTotal = $derived(
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
);
</script>
<Card>
<CardHeader>
<CardTitle class="text-base">Операционный отчёт модуля</CardTitle>
<CardDescription>
Читаемая сводка по данным модуля: источники, объёмы и ожидаемый результат для
refresh/агрегации.
</CardDescription>
</CardHeader>
<CardContent class="grid gap-3 md:grid-cols-2">
{#if mod.type === 'DOMAINS'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">Домены и ожидаемые IP</p>
<p class="text-xs text-muted-foreground">
После refresh домены резолвятся в IP и конвертируются в префиксы.
</p>
<div class="mt-2 space-y-1">
{#each domainEntries.slice(0, 8) as entry (entry.id)}
<p class="font-mono text-xs break-all">{entry.fqdn}</p>
{:else}
<p class="text-xs text-muted-foreground">Нет доменов</p>
{/each}
{#if domainEntries.length > 8}
<p class="text-xs text-muted-foreground">…и ещё {domainEntries.length - 8}</p>
{/if}
</div>
</div>
{:else if mod.type === 'AS_PREFIXES'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">ASN и число полученных префиксов</p>
<p class="text-xs text-muted-foreground">
Счётчик префиксов обновляется после успешного refresh (RIPEstat).
</p>
<div class="mt-2 space-y-1">
<p class="text-xs">
Всего ASN: <span class="font-semibold">{asEntries.length}</span>
</p>
<p class="text-xs">
Сумма префиксов: <span class="font-semibold">{asPrefixTotal}</span>
</p>
</div>
</div>
{:else if mod.type === 'CDN_CIDRS'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">CDN ссылки и импортируемые префиксы</p>
<p class="text-xs text-muted-foreground">
Каждый URL поставляет список CIDR для агрегации.
</p>
<div class="mt-2 space-y-1">
{#each cdnSources.slice(0, 6) as src (src.id)}
<p class="font-mono text-xs break-all">{src.url}</p>
{:else}
<p class="text-xs text-muted-foreground">Нет CDN источников</p>
{/each}
{#if cdnSources.length > 6}
<p class="text-xs text-muted-foreground">…и ещё {cdnSources.length - 6}</p>
{/if}
</div>
</div>
{:else if mod.type === 'IP_RANGES'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">IP ranges для агрегации</p>
<p class="text-xs text-muted-foreground">
Статические CIDR, которые попадают в итоговую ревизию.
</p>
<div class="mt-2 space-y-1">
{#each ipEntries.slice(0, 8) as entry (entry.id)}
<p class="font-mono text-xs">{entry.prefix}</p>
{:else}
<p class="text-xs text-muted-foreground">Нет диапазонов</p>
{/each}
{#if ipEntries.length > 8}
<p class="text-xs text-muted-foreground">…и ещё {ipEntries.length - 8}</p>
{/if}
</div>
</div>
{/if}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">Результат операции</p>
<p class="text-xs text-muted-foreground">
Подробный результат по конкретному запуску refresh смотрите в Операции → Задачи →
module_refresh: там отображаются источники, количество префиксов и итог агрегации.
</p>
<Button variant="link" class="mt-2 h-auto p-0" href={resolve('/operations?tab=jobs')}>
Открыть задачи
</Button>
</div>
</CardContent>
</Card>
@@ -0,0 +1,60 @@
import type { BgpCommunity, DohProfile, ModuleRow } from '$lib/api/types.js';
export const NONE_OPTION = '__none__';
export function supportsCsvIO(type: ModuleRow['type'] | null | undefined): boolean {
return type === 'AS_PREFIXES' || type === 'DOMAINS' || type === 'IP_RANGES';
}
export function sanitizeFilenamePart(v: string): string {
const cleaned = v
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^[-_.]+|[-_.]+$/g, '');
return cleaned || 'module';
}
export function communityLabel(id: string | null, communities: BgpCommunity[]): string {
if (!id) return '—';
const c = communities.find((x) => x.id === id);
if (!c) return id.slice(0, 8) + '…';
const t = c.title?.trim();
return t || c.community;
}
export function communityOptionLabel(c: BgpCommunity): string {
const t = c.title?.trim();
return t || c.community;
}
export function nullableSelectValue(value: string | null | undefined): string {
if (value === null || value === undefined || value === '') return NONE_OPTION;
return value;
}
export function fromNullableSelect(value: string): string | null {
if (value === NONE_OPTION || value === '') return null;
return value;
}
export function moduleDohProfileIds(modRow: ModuleRow | null): string[] {
if (!modRow) return [];
if (modRow.doh_profile_ids?.length) return modRow.doh_profile_ids;
return modRow.doh_profile_id ? [modRow.doh_profile_id] : [];
}
export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
const p = dohProfiles.find((d) => d.id === id);
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : id.slice(0, 8) + '…';
}
export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext';
}
export function syncSelection(selected: Set<string>, existingIds: string[]): Set<string> {
const validIds = new Set(existingIds);
return new Set([...selected].filter((id) => validIds.has(id)));
}
@@ -0,0 +1,687 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { AuthSession } from '$lib/api/types.js';
import type { PostgresTableRow } from '$lib/monitoring/postgres.js';
import {
createMaintenancePolicy,
deleteMaintenancePolicy,
fetchPolicyHints,
listMaintenancePolicies,
runMaintenancePolicy,
updateMaintenancePolicy,
type MaintenancePolicy,
type MaintenancePolicyHints
} from '$lib/maintenance/policy-api.js';
import {
emptyMaintenancePolicyForm,
formToPayload,
vacuumStrategies,
type MaintenancePolicyForm
} from '$lib/maintenance/policy.schema.js';
import {
filterAvailablePresets,
isPresetAlreadyApplied,
maintenancePolicyPresets,
presetForm,
type MaintenancePolicyPreset
} from '$lib/maintenance/policy-presets.js';
import {
applyScheduleEditor,
cronToEditor,
defaultScheduleEditor,
describeCron,
scheduleModeOptions,
weekdayOptions,
type ScheduleEditor,
type ScheduleMode
} from '$lib/maintenance/policy-schedule.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@evobgp/ui/components/dialog/index.js';
import { Switch } from '@evobgp/ui/components/switch/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import AppInput from '$lib/components/patterns/form/app-input.svelte';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import type { DataTableColumn } from '$lib/components/patterns/data-table/types.js';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Play from '@lucide/svelte/icons/play';
import FlaskConical from '@lucide/svelte/icons/flask-conical';
import Info from '@lucide/svelte/icons/info';
import Layers from '@lucide/svelte/icons/layers';
type Props = {
session: AuthSession | null;
tables: PostgresTableRow[];
onJobQueued?: () => void | Promise<void>;
};
let { session, tables = [], onJobQueued }: Props = $props();
let policies = $state<MaintenancePolicy[]>([]);
let loading = $state(true);
let dialogOpen = $state(false);
let editTarget = $state<MaintenancePolicy | null>(null);
let form = $state<MaintenancePolicyForm>(emptyMaintenancePolicyForm());
let saving = $state(false);
let hints = $state<MaintenancePolicyHints | null>(null);
let hintsLoading = $state(false);
let selectedPresetIds = $state<string[]>([]);
let applyingPresets = $state(false);
let activePresetId = $state<string | null>(null);
let scheduleEditor = $state<ScheduleEditor>(defaultScheduleEditor());
const schedulePreview = $derived(describeCron(form.schedule));
function loadScheduleEditor(cron: string) {
scheduleEditor = cronToEditor(cron);
}
function patchSchedule(patch: Partial<ScheduleEditor>) {
scheduleEditor = { ...scheduleEditor, ...patch };
syncScheduleToForm();
}
function setScheduleMode(mode: ScheduleMode) {
patchSchedule({ mode });
}
function syncScheduleToForm() {
const { editor, cron } = applyScheduleEditor(scheduleEditor);
scheduleEditor = editor;
form.schedule = cron;
}
function onCustomCronInput(value: string) {
scheduleEditor = { ...scheduleEditor, customCron: value };
form.schedule = value.trim() || '0 3 * * *';
}
const creatablePresets = $derived(filterAvailablePresets(policies, selectedPresetIds));
function togglePresetSelection(id: string, checked: boolean) {
if (checked) {
if (!selectedPresetIds.includes(id)) {
selectedPresetIds = [...selectedPresetIds, id];
}
} else {
selectedPresetIds = selectedPresetIds.filter((x) => x !== id);
}
}
function applyPresetToForm(preset: MaintenancePolicyPreset) {
form = presetForm(preset);
activePresetId = preset.id;
loadScheduleEditor(form.schedule);
}
async function createSelectedPresets() {
const toCreate = creatablePresets;
if (toCreate.length === 0) {
notify.error('Выберите пресеты, которые ещё не созданы');
return;
}
applyingPresets = true;
let created = 0;
try {
for (const preset of toCreate) {
await createMaintenancePolicy(formToPayload(preset.form));
created++;
}
selectedPresetIds = selectedPresetIds.filter((id) => !toCreate.some((p) => p.id === id));
notify.success(`Создано политик: ${created}`);
await loadPolicies();
} catch (e) {
notifyApiError(e, created > 0 ? `Создано ${created} из ${toCreate.length}` : undefined);
if (created > 0) await loadPolicies();
} finally {
applyingPresets = false;
}
}
const isOperator = $derived(session?.role === 'operator');
const tableOptions = $derived.by(() => {
const names = new Set(tables.map((t) => t.relname));
if (form.table_name.trim()) names.add(form.table_name.trim());
return [...names].sort();
});
const columns: DataTableColumn<MaintenancePolicy>[] = [
{ id: 'name', label: 'Название', sortable: true, sortValue: (p) => p.name },
{ id: 'table_name', label: 'Таблица', sortable: true, sortValue: (p) => p.table_name },
{ id: 'schedule', label: 'Cron (UTC)' },
{ id: 'status', label: 'Статус' },
{ id: 'actions', label: '', class: 'w-40' }
];
async function loadPolicies() {
loading = true;
try {
policies = await listMaintenancePolicies();
} catch (e) {
notifyApiError(e, 'Не удалось загрузить политики');
} finally {
loading = false;
}
}
function openCreate() {
editTarget = null;
form = emptyMaintenancePolicyForm();
hints = null;
activePresetId = null;
loadScheduleEditor(form.schedule);
dialogOpen = true;
}
function openEdit(p: MaintenancePolicy) {
editTarget = p;
form = {
name: p.name,
table_name: p.table_name,
condition: p.condition || 'true',
retention_period_sec: p.retention_period_sec ? String(p.retention_period_sec) : '',
max_rows: p.max_rows ? String(p.max_rows) : '',
vacuum_strategy: (vacuumStrategies.includes(
p.vacuum_strategy as (typeof vacuumStrategies)[number]
)
? p.vacuum_strategy
: 'none') as MaintenancePolicyForm['vacuum_strategy'],
schedule: p.schedule,
enabled: p.enabled,
dry_run_enabled: p.dry_run_enabled
};
loadScheduleEditor(form.schedule);
hints = null;
dialogOpen = true;
void loadHints(p.id);
}
async function loadHints(id: string) {
hintsLoading = true;
try {
hints = await fetchPolicyHints(id);
} catch {
hints = null;
} finally {
hintsLoading = false;
}
}
function requestDelete(p: MaintenancePolicy) {
void confirm({
title: `Удалить политику «${p.name}»?`,
description: 'Расписание и очистка по этой политике прекратятся.',
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await deleteMaintenancePolicy(p.id);
notify.success('Политика удалена');
await loadPolicies();
}
});
}
async function save() {
if (!form.name.trim() || !form.table_name.trim() || !form.schedule.trim()) {
notify.error('Заполните обязательные поля');
return;
}
saving = true;
try {
const payload = formToPayload(form);
if (editTarget) {
await updateMaintenancePolicy(editTarget.id, payload);
notify.success('Политика обновлена');
} else {
await createMaintenancePolicy(payload);
notify.success('Политика создана');
}
dialogOpen = false;
await loadPolicies();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function queueRun(p: MaintenancePolicy, dryRun: boolean) {
void confirm({
title: dryRun ? `Dry-run: ${p.name}` : `Запуск: ${p.name}`,
description: dryRun
? 'Изменения в БД не применяются — только оценка.'
: 'Задача будет поставлена в очередь jobs.',
confirmLabel: dryRun ? 'Dry-run' : 'Запустить',
destructive: !dryRun,
onConfirm: async () => {
const res = await runMaintenancePolicy(p.id, dryRun);
notify.success(`Задача ${res.job_id}`);
await onJobQueued?.();
}
});
}
function statusBadge(p: MaintenancePolicy) {
if (!p.enabled) return 'выкл';
if (p.dry_run_enabled) return 'dry-run sched';
return p.last_status || '—';
}
onMount(() => {
void loadPolicies();
});
</script>
{#if !isOperator}
<Alert>
<AlertTitle>Только operator</AlertTitle>
<AlertDescription>Политики обслуживания БД настраиваются с ролью operator.</AlertDescription>
</Alert>
{/if}
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<CardTitle>Политики обслуживания</CardTitle>
<CardDescription>
Единственный источник конфигурации retention, vacuum и расписания (UTC cron).
</CardDescription>
</div>
{#if isOperator}
<Button size="sm" onclick={openCreate}><Plus class="size-4" /> Новая политика</Button>
{/if}
</CardHeader>
<CardContent class="flex flex-col gap-4 pt-4">
{#if isOperator}
<div class="rounded-lg border border-border/80 bg-muted/20 p-4">
<div class="mb-3 flex flex-wrap items-start justify-between gap-2">
<div>
<p class="flex items-center gap-2 text-sm font-medium">
<Layers class="size-4 text-muted-foreground" />
Пресеты стратегий
</p>
<p class="mt-1 text-xs text-muted-foreground">
Выберите шаблоны и создайте политики одним действием или примените шаблон в форме.
</p>
</div>
<Button
size="sm"
disabled={creatablePresets.length === 0 || applyingPresets}
onclick={createSelectedPresets}
>
{applyingPresets ? 'Создание…' : `Создать выбранные (${creatablePresets.length})`}
</Button>
</div>
<div class="grid gap-2 sm:grid-cols-2">
{#each maintenancePolicyPresets as preset (preset.id)}
{@const applied = isPresetAlreadyApplied(preset, policies)}
{@const checked = selectedPresetIds.includes(preset.id)}
<label
class="flex cursor-pointer gap-3 rounded-md border border-border/60 bg-background p-3 transition-colors hover:bg-muted/30 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60"
>
<Checkbox
id="preset-{preset.id}"
{checked}
disabled={applied}
onCheckedChange={(v) => togglePresetSelection(preset.id, v === true)}
/>
<span class="min-w-0 flex-1 space-y-1">
<span class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium">{preset.label}</span>
{#if applied}
<Badge variant="outline" class="text-xs">уже есть</Badge>
{/if}
</span>
<span class="block text-xs text-muted-foreground">{preset.description}</span>
<span class="block font-mono text-[11px] text-muted-foreground">
{preset.form.table_name} · {describeCron(preset.form.schedule)}
</span>
</span>
<Button
type="button"
variant="ghost"
size="sm"
class="shrink-0 self-start"
disabled={!isOperator}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
editTarget = null;
applyPresetToForm(preset);
dialogOpen = true;
}}
>
В форму
</Button>
</label>
{/each}
</div>
</div>
{/if}
<AppDataTable
{columns}
rows={policies}
rowKey={(p) => p.id}
{loading}
emptyTitle="Политики не созданы"
emptyDescription="Добавьте первую политику через UI — это единственный способ настройки."
>
{#snippet cell({ row, column })}
{#if column.id === 'status'}
<Badge variant={row.enabled ? 'secondary' : 'outline'}>{statusBadge(row)}</Badge>
{#if row.last_run_at}
<p class="mt-1 text-xs text-muted-foreground">{row.last_run_at}</p>
{/if}
{:else if column.id === 'actions' && isOperator}
<div class="flex flex-wrap gap-1">
<Button
variant="ghost"
size="icon-sm"
onclick={() => openEdit(row)}
aria-label="Изменить"
>
<Pencil class="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => queueRun(row, true)}
aria-label="Dry-run"
>
<FlaskConical class="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => queueRun(row, false)}
aria-label="Run"
>
<Play class="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => requestDelete(row)}
aria-label="Удалить"
>
<Trash2 class="size-4" />
</Button>
</div>
{:else if column.id === 'name'}
{row.name}
{:else if column.id === 'table_name'}
{row.table_name}
{:else if column.id === 'schedule'}
<div class="space-y-0.5">
<span class="text-sm">{describeCron(row.schedule)}</span>
<span class="block font-mono text-[11px] text-muted-foreground">{row.schedule}</span>
</div>
{:else if column.id !== 'actions'}
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editTarget ? 'Изменить политику' : 'Новая политика'}</DialogTitle>
</DialogHeader>
{#if hints?.recommend_vacuum}
<Alert class="border-warning/30 bg-warning/5">
<Info class="text-warning" />
<AlertTitle>Рекомендация</AlertTitle>
<AlertDescription>{hints.detail ?? 'Рекомендуется VACUUM.'}</AlertDescription>
</Alert>
{:else if hintsLoading}
<p class="text-sm text-muted-foreground">Загрузка подсказок pg_stat…</p>
{/if}
{#if !editTarget}
<div class="space-y-2">
<p class="text-sm font-medium">Шаблон (опционально)</p>
<div class="flex flex-wrap gap-2">
{#each maintenancePolicyPresets as preset (preset.id)}
<Button
type="button"
variant={activePresetId === preset.id ? 'secondary' : 'outline'}
size="sm"
class="h-auto max-w-full py-1.5 text-left whitespace-normal"
disabled={!isOperator}
onclick={() => applyPresetToForm(preset)}
>
{preset.label}
</Button>
{/each}
</div>
<p class="text-xs text-muted-foreground">
Поля формы заполняются из шаблона; перед сохранением можно изменить любое значение.
</p>
</div>
{/if}
<div class="grid gap-4 py-2">
<FormField label="Название" id="mp-name" required>
<AppInput bind:value={form.name} disabled={!isOperator} />
</FormField>
<FormField label="Таблица" id="mp-table" required>
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
bind:value={form.table_name}
disabled={!isOperator}
>
<option value="">— выберите —</option>
{#each tableOptions as name (name)}
<option value={name}>{name}</option>
{/each}
</select>
</FormField>
<FormField label="Condition (SQL WHERE)" id="mp-condition" required>
<textarea
class="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
bind:value={form.condition}
disabled={!isOperator}
></textarea>
</FormField>
<div class="grid gap-4 sm:grid-cols-2">
<FormField label="Retention (сек)" id="mp-retention">
<AppInput bind:value={form.retention_period_sec} type="number" disabled={!isOperator} />
</FormField>
<FormField label="Max rows (batch)" id="mp-max-rows">
<AppInput bind:value={form.max_rows} type="number" disabled={!isOperator} />
</FormField>
</div>
<FormField label="Vacuum strategy" id="mp-vacuum">
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
bind:value={form.vacuum_strategy}
disabled={!isOperator}
>
{#each vacuumStrategies as s (s)}
<option value={s}>{s}</option>
{/each}
</select>
</FormField>
<FormField label="Расписание (UTC)" id="mp-schedule" required>
<div class="grid gap-3">
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={scheduleEditor.mode}
disabled={!isOperator}
onchange={(e) => setScheduleMode(e.currentTarget.value as ScheduleMode)}
>
{#each scheduleModeOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if scheduleEditor.mode === 'minutes'}
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">Каждые</span>
<AppInput
class="w-24"
type="number"
min="1"
max="59"
value={scheduleEditor.intervalMinutes}
disabled={!isOperator}
oninput={(e) => patchSchedule({ intervalMinutes: e.currentTarget.value })}
/>
<span class="text-muted-foreground">минут</span>
</div>
{:else if scheduleEditor.mode === 'hours'}
<div class="grid gap-3 sm:grid-cols-2">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">Каждые</span>
<AppInput
class="w-24"
type="number"
min="1"
max="23"
value={scheduleEditor.intervalHours}
disabled={!isOperator}
oninput={(e) => patchSchedule({ intervalHours: e.currentTarget.value })}
/>
<span class="text-muted-foreground">часов</span>
</div>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">В минуту</span>
<AppInput
class="w-24"
type="number"
min="0"
max="59"
value={scheduleEditor.minute}
disabled={!isOperator}
oninput={(e) => patchSchedule({ minute: e.currentTarget.value })}
/>
<span class="text-muted-foreground">часа (059)</span>
</div>
</div>
{:else if scheduleEditor.mode === 'daily'}
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">В</span>
<AppInput
class="w-20"
type="number"
min="0"
max="23"
value={scheduleEditor.hour}
disabled={!isOperator}
oninput={(e) => patchSchedule({ hour: e.currentTarget.value })}
/>
<span class="text-muted-foreground">:</span>
<AppInput
class="w-20"
type="number"
min="0"
max="59"
value={scheduleEditor.minute}
disabled={!isOperator}
oninput={(e) => patchSchedule({ minute: e.currentTarget.value })}
/>
<span class="text-muted-foreground">UTC</span>
</div>
{:else if scheduleEditor.mode === 'weekly'}
<div class="grid gap-3">
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={scheduleEditor.weekday}
disabled={!isOperator}
onchange={(e) => patchSchedule({ weekday: e.currentTarget.value })}
>
{#each weekdayOptions as wd (wd.value)}
<option value={wd.value}>{wd.label}</option>
{/each}
</select>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">В</span>
<AppInput
class="w-20"
type="number"
min="0"
max="23"
value={scheduleEditor.hour}
disabled={!isOperator}
oninput={(e) => patchSchedule({ hour: e.currentTarget.value })}
/>
<span class="text-muted-foreground">:</span>
<AppInput
class="w-20"
type="number"
min="0"
max="59"
value={scheduleEditor.minute}
disabled={!isOperator}
oninput={(e) => patchSchedule({ minute: e.currentTarget.value })}
/>
<span class="text-muted-foreground">UTC</span>
</div>
</div>
{:else}
<AppInput
class="font-mono"
value={scheduleEditor.customCron}
disabled={!isOperator}
placeholder="0 3 * * *"
oninput={(e) => onCustomCronInput(e.currentTarget.value)}
/>
<p class="text-xs text-muted-foreground">
5 полей: минута час день месяц день_недели (UTC). Пример: <code>0 6 * * 0</code> — воскресенье
06:00.
</p>
{/if}
<p class="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<span class="font-medium text-foreground">{schedulePreview}</span>
<span class="mt-1 block font-mono">{form.schedule}</span>
</p>
</div>
</FormField>
<div class="flex flex-wrap gap-6">
<div class="flex items-center gap-2">
<Switch id="mp-enabled" bind:checked={form.enabled} disabled={!isOperator} />
<Label for="mp-enabled">Включена</Label>
</div>
<div class="flex items-center gap-2">
<Switch id="mp-dry" bind:checked={form.dry_run_enabled} disabled={!isOperator} />
<Label for="mp-dry">Scheduler только dry-run</Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
{#if isOperator}
<Button onclick={save} disabled={saving}>{saving ? 'Сохранение…' : 'Сохранить'}</Button>
{/if}
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,436 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON } from '$lib/api/client.js';
import type { AuthSession } from '$lib/api/types.js';
import { notifyApiError } from '$lib/ui/app/toast.js';
import MaintenancePoliciesTab from '$lib/components/monitoring/MaintenancePoliciesTab.svelte';
import {
POSTGRES_POLL_MS,
POSTGRES_SLOW_POLL_MS,
formatBytes,
connUsagePct,
type PostgresOverview,
type PostgresQueriesResponse,
type PostgresLockRow,
type PostgresTableRow,
type PostgresRecommendationsResponse,
type PostgresMaintLog,
type CorrelationResponse
} from '$lib/monitoring/postgres.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@evobgp/ui/components/table/index.js';
import { Switch } from '@evobgp/ui/components/switch/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import Database from '@lucide/svelte/icons/database';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
let pgTab = $state('overview');
let autoRefresh = $state(true);
let session = $state<AuthSession | null>(null);
let unavailable = $state(false);
let overview = $state<PostgresOverview | null>(null);
let queries = $state<PostgresQueriesResponse | null>(null);
let locks = $state<PostgresLockRow[]>([]);
let tables = $state<PostgresTableRow[]>([]);
let recommendations = $state<PostgresRecommendationsResponse | null>(null);
let maintLogs = $state<PostgresMaintLog[]>([]);
let correlation = $state<CorrelationResponse | null>(null);
let loading = $state(true);
async function loadCore() {
try {
overview = await apiJSON<PostgresOverview>('/v1/monitoring/postgres/overview');
locks = (await apiJSON<{ items: PostgresLockRow[] }>('/v1/monitoring/postgres/locks')).items;
unavailable = false;
} catch (e) {
unavailable = true;
overview = null;
throw e;
}
}
async function loadSlow() {
queries = await apiJSON<PostgresQueriesResponse>('/v1/monitoring/postgres/queries?limit=20');
tables = (
await apiJSON<{ items: PostgresTableRow[] }>('/v1/monitoring/postgres/tables?limit=30')
).items;
recommendations = await apiJSON<PostgresRecommendationsResponse>(
'/v1/monitoring/postgres/recommendations'
);
correlation = await apiJSON<CorrelationResponse>('/v1/monitoring/correlation?window=60');
maintLogs = (
await apiJSON<{ items: PostgresMaintLog[] }>('/v1/postgres/maintenance/logs?limit=20')
).items;
}
async function loadAll() {
loading = true;
try {
await loadCore();
await loadSlow();
} catch (e) {
notifyApiError(e, 'PostgreSQL monitoring');
} finally {
loading = false;
}
}
onMount(() => {
void (async () => {
try {
session = await apiJSON<AuthSession>('/v1/auth/session');
} catch {
session = null;
}
await loadAll();
})();
});
$effect(() => {
if (!autoRefresh || unavailable) return;
const fast = setInterval(() => {
void loadCore().catch(() => {});
}, POSTGRES_POLL_MS);
const slow = setInterval(() => {
void loadSlow().catch(() => {});
}, POSTGRES_SLOW_POLL_MS);
return () => {
clearInterval(fast);
clearInterval(slow);
};
});
</script>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Database class="size-4" />
<span>Instance-level PostgreSQL (control plane)</span>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<Switch id="pg-auto" bind:checked={autoRefresh} />
<Label for="pg-auto">Автообновление</Label>
</div>
<Button variant="outline" size="sm" onclick={() => loadAll()} disabled={loading}>
<RefreshCw class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
</div>
{#if unavailable}
<Alert variant="destructive" class="mt-4">
<AlertTitle>PostgreSQL недоступен</AlertTitle>
<AlertDescription>
Мониторинг требует <code class="text-xs">EVOBGP_DATABASE_URL</code> (не memory backend).
</AlertDescription>
</Alert>
{:else}
<Tabs bind:value={pgTab} class="mt-4">
<TabsList>
<TabsTrigger value="overview">Обзор</TabsTrigger>
<TabsTrigger value="queries">Запросы</TabsTrigger>
<TabsTrigger value="locks">Блокировки</TabsTrigger>
<TabsTrigger value="tables">Таблицы</TabsTrigger>
<TabsTrigger value="maintenance">Обслуживание</TabsTrigger>
<TabsTrigger value="correlation">Корреляция</TabsTrigger>
</TabsList>
<TabsContent value="overview" class="mt-4 space-y-4">
{#if overview}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">Подключения</CardTitle>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold tabular-nums">
{overview.connections.active} / {overview.connections.max_connections}
</p>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-chart-1 transition-all"
style="width: {connUsagePct(overview)}%"
></div>
</div>
<p class="mt-1 text-xs text-muted-foreground">
idle {overview.connections.idle}, total {overview.connections.total}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">Cache hit</CardTitle>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold tabular-nums">
{overview.database.cache_hit_pct ?? '—'}%
</p>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-chart-2 transition-all"
style="width: {overview.database.cache_hit_pct ?? 0}%"
></div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">TPS (commits)</CardTitle>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold tabular-nums">
{overview.database.xact_commit.toLocaleString()}
</p>
<p class="text-xs text-muted-foreground">
rollback {overview.database.xact_rollback.toLocaleString()}, deadlocks {overview
.database.deadlocks}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">Размер БД</CardTitle>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold">{formatBytes(overview.database_size_bytes)}</p>
<p class="text-xs text-muted-foreground">
shared_buffers {overview.memory_settings.shared_buffers}
</p>
</CardContent>
</Card>
</div>
{#if overview.replication?.length}
<Card>
<CardHeader>
<CardTitle>Репликация</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Адрес</TableHead>
<TableHead>Состояние</TableHead>
<TableHead>Lag ms</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each overview.replication as r (r.client_addr ?? r.state)}
<TableRow>
<TableCell>{r.client_addr ?? '—'}</TableCell>
<TableCell>{r.state}</TableCell>
<TableCell>{r.lag_ms ?? '—'}</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
{/if}
{/if}
</TabsContent>
<TabsContent value="queries" class="mt-4">
<Card>
<CardHeader>
<CardTitle>Медленные запросы</CardTitle>
<CardDescription>
Источник: {queries?.source ?? '—'}
{#if queries?.statements_available === false || (overview && !overview.pg_stat_statements_enabled)}
· pg_stat_statements недоступен
{/if}
</CardDescription>
</CardHeader>
<CardContent>
{#if queries?.statements_hint}
<Alert class="mb-4">
<AlertTitle>Нет статистики запросов</AlertTitle>
<AlertDescription>{queries.statements_hint}</AlertDescription>
</Alert>
{/if}
<Table>
<TableHeader>
<TableRow>
<TableHead>mean ms</TableHead>
<TableHead>calls</TableHead>
<TableHead>query</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each queries?.items ?? [] as q (q.queryid ?? q.query)}
<TableRow>
<TableCell class="tabular-nums">{q.mean_exec_ms.toFixed(1)}</TableCell>
<TableCell>{q.calls}</TableCell>
<TableCell class="max-w-md truncate font-mono text-xs">{q.query}</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={3} class="text-muted-foreground">Нет данных</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="locks" class="mt-4">
<Card>
<CardHeader>
<CardTitle>Блокировки</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>pid</TableHead>
<TableHead>mode</TableHead>
<TableHead>granted</TableHead>
<TableHead>query</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each locks as l (l.pid)}
<TableRow>
<TableCell>{l.pid}</TableCell>
<TableCell>
<Badge variant={l.blocked ? 'destructive' : 'secondary'}>{l.mode}</Badge>
</TableCell>
<TableCell>{l.granted ? 'да' : 'нет'}</TableCell>
<TableCell class="max-w-lg truncate font-mono text-xs">{l.query ?? '—'}</TableCell
>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground"
>Нет активных блокировок</TableCell
>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tables" class="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle>Таблицы и хранилище</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>table</TableHead>
<TableHead>size</TableHead>
<TableHead>seq_scan</TableHead>
<TableHead>idx_scan</TableHead>
<TableHead>bloat</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each tables as t (t.relname)}
<TableRow>
<TableCell class="font-mono text-xs">{t.relname}</TableCell>
<TableCell>{formatBytes(t.total_bytes)}</TableCell>
<TableCell>{t.seq_scan}</TableCell>
<TableCell>{t.idx_scan}</TableCell>
<TableCell>{(t.bloat_ratio ?? 0).toFixed(2)}</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
{#if recommendations?.items?.length}
<Card>
<CardHeader>
<CardTitle>Рекомендации</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
{#each recommendations.items as item (item.code + item.title)}
<Alert>
<AlertTitle>{item.title}</AlertTitle>
<AlertDescription>{item.detail}</AlertDescription>
</Alert>
{/each}
</CardContent>
</Card>
{/if}
</TabsContent>
<TabsContent value="maintenance" class="mt-4 space-y-4">
<MaintenancePoliciesTab {session} {tables} onJobQueued={loadSlow} />
<Card>
<CardHeader>
<CardTitle>Журнал обслуживания</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>время</TableHead>
<TableHead>kind</TableHead>
<TableHead>status</TableHead>
<TableHead>dry_run</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each maintLogs as log (log.id)}
<TableRow>
<TableCell class="text-xs">{log.created_at}</TableCell>
<TableCell>{log.kind}</TableCell>
<TableCell>{log.status}</TableCell>
<TableCell>{log.dry_run ? 'да' : 'нет'}</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground">Пусто</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="correlation" class="mt-4">
<Card>
<CardHeader>
<CardTitle>Корреляция (1ч)</CardTitle>
<CardDescription>Pipeline refresh p99 vs cache hit по минутам</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
{#each correlation?.points ?? [] as p (p.timestamp)}
<div class="grid gap-2 rounded-md border p-2 text-xs md:grid-cols-3">
<span>{p.timestamp}</span>
<span>p99 refresh: {p.pipeline_refresh_p99_ms?.toFixed(0) ?? '—'} ms</span>
<span>cache hit: {p.cache_hit_pct?.toFixed(1) ?? '—'}%</span>
</div>
{:else}
<p class="text-muted-foreground">Нет точек за окно</p>
{/each}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/if}
@@ -0,0 +1,451 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON } from '$lib/api/client.js';
import type { AuthSession } from '$lib/api/types.js';
import {
cleanupRuntimeLogFile,
getRuntimeLogTail,
isRuntimeLogsUnavailable,
listRuntimeLogCleanupAudit,
listRuntimeLogFiles,
type RuntimeLogCleanupAudit,
type RuntimeLogCleanupMode,
type RuntimeLogFile
} from '$lib/runtime-logs/runtime-logs-api.js';
import { formatBytes } from '$lib/monitoring/postgres.js';
import { formatDateTime } from '$lib/modules/display.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@evobgp/ui/components/dialog/index.js';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@evobgp/ui/components/dropdown-menu/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import type { DataTableColumn } from '$lib/components/patterns/data-table/types.js';
import EmptyState from '$lib/components/patterns/empty-state/empty-state.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import {
dialogBodyDocument,
dialogContentDocument,
dialogHeaderDocument
} from '$lib/dialog-layout.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Eye from '@lucide/svelte/icons/eye';
import Eraser from '@lucide/svelte/icons/eraser';
import Trash2 from '@lucide/svelte/icons/trash-2';
import HardDrive from '@lucide/svelte/icons/hard-drive';
import FileText from '@lucide/svelte/icons/file-text';
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
type SubTab = 'files' | 'audit';
let subTab = $state<SubTab>('files');
let session = $state<AuthSession | null>(null);
let filesUnavailable = $state(false);
let filesLoading = $state(true);
let files = $state<RuntimeLogFile[]>([]);
let auditLoading = $state(false);
let auditError = $state<string | null>(null);
let auditItems = $state<RuntimeLogCleanupAudit[]>([]);
let auditCursor = $state<string | undefined>(undefined);
let auditHasMore = $state(false);
let previewOpen = $state(false);
let previewFilename = $state('');
let previewLoading = $state(false);
let previewContent = $state('');
let previewTruncated = $state(false);
let previewLines = $state(0);
let cleaningFilename = $state<string | null>(null);
const isOperator = $derived(session?.role === 'operator');
const totalBytes = $derived(files.reduce((sum, f) => sum + (f.size_bytes ?? 0), 0));
const fileColumns: DataTableColumn<RuntimeLogFile>[] = [
{ id: 'name', label: 'Файл', sortable: true, sortValue: (f) => f.name },
{
id: 'size',
label: 'Размер',
sortable: true,
sortValue: (f) => f.size_bytes
},
{
id: 'modified',
label: 'Изменён',
sortable: true,
sortValue: (f) => f.modified_at
},
{ id: 'actions', label: '', class: 'w-36' }
];
const auditColumns: DataTableColumn<RuntimeLogCleanupAudit>[] = [
{
id: 'created',
label: 'Время',
sortable: true,
sortValue: (r) => r.created_at
},
{ id: 'source', label: 'Источник' },
{ id: 'actor', label: 'Actor' },
{ id: 'filename', label: 'Файл', sortable: true, sortValue: (r) => r.filename },
{ id: 'action', label: 'Действие' },
{ id: 'sizes', label: 'Размер' }
];
async function loadSession() {
try {
session = await apiJSON<AuthSession>('/v1/auth/session');
} catch {
session = null;
}
}
async function loadFiles() {
filesLoading = true;
try {
files = await listRuntimeLogFiles();
filesUnavailable = false;
} catch (e) {
if (isRuntimeLogsUnavailable(e)) {
filesUnavailable = true;
files = [];
return;
}
notifyApiError(e, 'Файловые логи');
} finally {
filesLoading = false;
}
}
async function loadAudit(reset = true) {
auditLoading = true;
if (reset) auditError = null;
try {
const page = await listRuntimeLogCleanupAudit({
limit: 20,
cursor: reset ? undefined : auditCursor
});
auditItems = reset ? (page.items ?? []) : [...auditItems, ...(page.items ?? [])];
auditCursor = page.next_cursor;
auditHasMore = Boolean(page.has_more && page.next_cursor);
auditError = null;
} catch (e) {
auditError = e instanceof Error ? e.message : 'Не удалось загрузить audit';
if (reset) auditItems = [];
notifyApiError(e, 'Audit очистки логов');
} finally {
auditLoading = false;
}
}
function auditSourceLabel(actor: string): string {
if (actor.startsWith('auto:')) return 'Авто';
if (actor.startsWith('op:')) return 'Оператор';
return '—';
}
async function refreshAll() {
await Promise.all([loadFiles(), loadAudit(true)]);
}
async function openPreview(file: RuntimeLogFile) {
previewFilename = file.name;
previewOpen = true;
previewLoading = true;
previewContent = '';
try {
const tail = await getRuntimeLogTail(file.name, { lines: 200 });
previewContent = tail.content;
previewTruncated = tail.truncated;
previewLines = tail.lines_returned;
} catch (e) {
notifyApiError(e);
previewOpen = false;
} finally {
previewLoading = false;
}
}
function requestCleanup(file: RuntimeLogFile, mode: RuntimeLogCleanupMode) {
const actionLabel = mode === 'delete' ? 'удалить файл' : 'обнулить (truncate)';
void confirm({
title: mode === 'delete' ? 'Удалить log-файл?' : 'Очистить log-файл?',
description: `${file.name} · ${formatBytes(file.size_bytes)}. Действие: ${actionLabel}. Операция синхронная.`,
confirmLabel: mode === 'delete' ? 'Удалить' : 'Очистить',
destructive: true,
onConfirm: async () => {
cleaningFilename = file.name;
try {
await cleanupRuntimeLogFile(file.name, mode);
notify.success(mode === 'delete' ? 'Файл удалён' : 'Файл обнулён');
await refreshAll();
} catch (e) {
notifyApiError(e);
} finally {
cleaningFilename = null;
}
}
});
}
function actionBadgeVariant(action: RuntimeLogCleanupMode): 'secondary' | 'destructive' {
return action === 'delete' ? 'destructive' : 'secondary';
}
onMount(() => {
void (async () => {
await loadSession();
await loadFiles();
await loadAudit(true);
})();
});
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<HardDrive class="size-4" />
<span>Файловые логи Docker-сервисов (sidecar stack-runtime-logs)</span>
</div>
<Button
variant="outline"
size="sm"
onclick={() => refreshAll()}
disabled={filesLoading || auditLoading}
>
<RefreshCw class={filesLoading || auditLoading ? 'animate-spin' : ''} />
Обновить
</Button>
</div>
{#if filesUnavailable}
<EmptyState
icon={HardDrive}
title="Файловые логи недоступны"
description="Список и очистка *.log доступны только на evobgp-all с примонтированным каталогом runtime-logs (EVOBGP_RUNTIME_LOGS_DIR и EVOBGP_SERVICE=evobgp-all). См. docs/access.md в репозитории. Вкладка audit очистки ниже работает без volume."
/>
{/if}
{#if !filesUnavailable}
<div class="grid gap-3 sm:grid-cols-2">
<Card>
<CardHeader class="pb-2">
<CardDescription>Файлов</CardDescription>
<CardTitle class="text-2xl tabular-nums">{files.length}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>Суммарный размер</CardDescription>
<CardTitle class="text-2xl tabular-nums">{formatBytes(totalBytes)}</CardTitle>
</CardHeader>
</Card>
</div>
{/if}
<Tabs bind:value={subTab}>
<TabsList>
<TabsTrigger value="files">Файлы</TabsTrigger>
<TabsTrigger value="audit">Audit очистки</TabsTrigger>
</TabsList>
<TabsContent value="files" class="mt-4">
{#if filesUnavailable}
<EmptyState
icon={FileText}
title="FS API отключён"
description="Примонтируйте runtime-logs к evobgp-all и задайте EVOBGP_RUNTIME_LOGS_DIR."
/>
{:else}
<Card>
<CardHeader>
<CardTitle class="text-base">*.log на хосте</CardTitle>
<CardDescription>
Просмотр хвоста и синхронная очистка (truncate по умолчанию). Очистка — роль operator.
</CardDescription>
</CardHeader>
<CardContent class="min-w-0 p-4 pt-0">
<AppDataTable
columns={fileColumns}
rows={files}
rowKey={(f) => f.name}
loading={filesLoading}
emptyTitle="Нет log-файлов"
emptyDescription="Sidecar ещё не создал файлы или каталог пуст."
>
{#snippet cell({ row, column })}
{#if column.id === 'name'}
<span class="font-mono text-xs">{row.name}</span>
{:else if column.id === 'size'}
{formatBytes(row.size_bytes)}
{:else if column.id === 'modified'}
<span class="text-sm">{formatDateTime(row.modified_at)}</span>
{:else if column.id === 'actions'}
<div class="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label="Просмотр"
onclick={() => openPreview(row)}
>
<Eye class="size-3.5" />
</Button>
{#if isOperator}
<Button
variant="ghost"
size="icon-sm"
aria-label="Очистить (truncate)"
disabled={cleaningFilename === row.name}
onclick={() => requestCleanup(row, 'truncate')}
>
{#if cleaningFilename === row.name}
<LoaderCircle class="size-3.5 animate-spin" />
{:else}
<Eraser class="size-3.5" />
{/if}
</Button>
<DropdownMenu>
<DropdownMenuTrigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-sm" aria-label="Ещё">
<MoreHorizontal class="size-3.5" />
</Button>
{/snippet}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
variant="destructive"
disabled={cleaningFilename === row.name}
onclick={() => requestCleanup(row, 'delete')}
>
<Trash2 class="size-4" />
Удалить файл
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/if}
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
{/if}
</TabsContent>
<TabsContent value="audit" class="mt-4">
<Card>
<CardHeader>
<CardTitle class="text-base">История очистки</CardTitle>
<CardDescription>
Записи из <code class="text-xs">runtime_log_cleanup_audit</code> (viewer+).
</CardDescription>
</CardHeader>
<CardContent class="min-w-0 space-y-3 p-4 pt-0">
{#if auditError && auditItems.length === 0}
<EmptyState icon={FileText} title="Не удалось загрузить audit" description={auditError}>
{#snippet action()}
<Button variant="outline" size="sm" onclick={() => loadAudit(true)}>
Повторить
</Button>
{/snippet}
</EmptyState>
{:else}
<AppDataTable
columns={auditColumns}
rows={auditItems}
rowKey={(r) => r.id}
loading={auditLoading && auditItems.length === 0}
emptyTitle="Записей пока нет"
emptyDescription="Очистка появится после ручного DELETE или автоочистки. Настройки — Параметры → Файловые логи."
>
{#snippet cell({ row, column })}
{#if column.id === 'created'}
<span class="text-sm">{formatDateTime(row.created_at)}</span>
{:else if column.id === 'source'}
<Badge variant={row.actor_prefix.startsWith('auto:') ? 'secondary' : 'outline'}>
{auditSourceLabel(row.actor_prefix)}
</Badge>
{:else if column.id === 'actor'}
<span class="font-mono text-xs">{row.actor_prefix}</span>
{:else if column.id === 'filename'}
<span class="font-mono text-xs">{row.filename}</span>
{:else if column.id === 'action'}
<Badge variant={actionBadgeVariant(row.action)}>{row.action}</Badge>
{:else if column.id === 'sizes'}
<span class="text-xs tabular-nums">
{formatBytes(row.size_before)}
{#if row.size_after != null}
{formatBytes(row.size_after)}
{/if}
</span>
{/if}
{/snippet}
</AppDataTable>
{/if}
{#if auditHasMore}
<Button
variant="outline"
size="sm"
disabled={auditLoading}
onclick={() => loadAudit(false)}
>
{#if auditLoading}
<LoaderCircle class="size-4 animate-spin" />
{/if}
Загрузить ещё
</Button>
{/if}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
<Dialog bind:open={previewOpen}>
<DialogContent class={dialogContentDocument}>
<DialogHeader class={dialogHeaderDocument}>
<DialogTitle class="flex items-center gap-2">
<FileText class="size-4" />
{previewFilename}
</DialogTitle>
<DialogDescription>
{#if previewTruncated}
Показан усечённый хвост ({previewLines} строк).
{:else}
Хвост файла ({previewLines} строк).
{/if}
</DialogDescription>
</DialogHeader>
<div class={dialogBodyDocument}>
{#if previewLoading}
<p class="text-sm text-muted-foreground">Загрузка…</p>
{:else}
<ScrollPreBlock variant="preserve" text={previewContent || '—'} class="max-h-[70vh]" />
{/if}
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,30 @@
<script lang="ts">
import { Label } from '@evobgp/ui/components/label/index.js';
import { Switch } from '@evobgp/ui/components/switch/index.js';
import { readNetworkAutoRefresh, writeNetworkAutoRefresh } from '$lib/network/network-metrics.js';
type Props = {
enabled?: boolean;
onchange?: (enabled: boolean) => void;
disabled?: boolean;
};
let {
enabled = $bindable(readNetworkAutoRefresh()),
onchange,
disabled = false
}: Props = $props();
function onToggle(checked: boolean) {
enabled = checked;
writeNetworkAutoRefresh(checked);
onchange?.(checked);
}
</script>
<div class="flex items-center gap-2">
<Switch id="network-auto-refresh" bind:checked={enabled} onCheckedChange={onToggle} {disabled} />
<Label for="network-auto-refresh" class="cursor-pointer text-sm text-muted-foreground">
Авто (~15 с)
</Label>
</div>
@@ -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 '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/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>
@@ -0,0 +1,240 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { BirdStatus, PeerRow, SpeakerRow } from '$lib/api/types.js';
import {
aggregateNetworkMetrics,
collectNetworkIssues,
deriveNetworkOverallStatus,
networkOverallStatusHint,
networkOverallStatusLabel
} from '$lib/network/network-metrics.js';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import KpiMetricsGrid from '$lib/components/patterns/kpi/kpi-metrics-grid.svelte';
import NetworkSpeakerStatusCard from '$lib/components/network/NetworkSpeakerStatusCard.svelte';
import CheckCircle from '@lucide/svelte/icons/check-circle';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
import XCircle from '@lucide/svelte/icons/x-circle';
import Share2 from '@lucide/svelte/icons/share-2';
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
import Server from '@lucide/svelte/icons/server';
import GitBranch from '@lucide/svelte/icons/git-branch';
import Activity from '@lucide/svelte/icons/activity';
import Bird from '@lucide/svelte/icons/bird';
import Gauge from '@lucide/svelte/icons/gauge';
type Props = {
peers: PeerRow[];
speakers: SpeakerRow[];
bird: BirdStatus | null;
loading?: boolean;
initialLoading?: boolean;
onSpeakerSelect?: (speaker: SpeakerRow) => void;
};
let {
peers,
speakers,
bird,
loading = false,
initialLoading = false,
onSpeakerSelect
}: Props = $props();
const statAccents = [
{
border: 'border-l-chart-3',
bg: 'bg-chart-3/5',
iconBg: 'bg-chart-3/15',
iconText: 'text-chart-3'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
},
{
border: 'border-l-warning',
bg: 'bg-warning/5',
iconBg: 'bg-warning/15',
iconText: 'text-warning'
},
{
border: 'border-l-destructive',
bg: 'bg-destructive/5',
iconBg: 'bg-destructive/15',
iconText: 'text-destructive'
},
{
border: 'border-l-info',
bg: 'bg-info/10',
iconBg: 'bg-info/15',
iconText: 'text-info'
}
] as const;
const metrics = $derived(aggregateNetworkMetrics(peers, speakers, bird));
const overallStatus = $derived(deriveNetworkOverallStatus(metrics));
const overallHint = $derived(networkOverallStatusHint(overallStatus, metrics));
const issues = $derived(collectNetworkIssues(peers, speakers, 5));
const birdText = $derived.by(() => {
if (!bird?.birdc_configured) return '—';
if (bird.error) return '—';
return `${bird.bgp_established}/${bird.bgp_sessions_total}`;
});
const kpiCards = $derived.by(() => [
{
id: 'peers',
label: 'BGP-пиры',
value: initialLoading ? '—' : String(metrics.peersTotal),
description: initialLoading
? ''
: `${metrics.peersEstablished} Established из ${metrics.peersEnabled} вкл.`,
icon: Share2,
accent: statAccents[0],
badge: metrics.peersMismatch > 0 ? `mismatch ${metrics.peersMismatch}` : 'peers',
badgeClass:
metrics.peersMismatch > 0 ? 'border-warning/30 bg-warning/15 text-warning' : undefined
},
{
id: 'established',
label: 'Активные сессии',
value: initialLoading ? '—' : String(metrics.peersEstablished),
description: 'Established среди включённых пиров',
icon: CheckCircle2,
accent: statAccents[1],
badge: metrics.peersEstablished > 0 ? 'Established' : 'нет сессий',
badgeClass:
metrics.peersEstablished > 0 ? 'border-success/30 bg-success/15 text-success' : undefined
},
{
id: 'speakers',
label: 'Спикеры online',
value: initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`,
description: 'agent + BGP poll',
icon: Server,
accent: statAccents[2],
badge: metrics.speakersOnline === metrics.speakersTotal ? 'все online' : 'есть offline',
badgeClass:
metrics.speakersOnline === metrics.speakersTotal
? 'border-success/30 bg-success/15 text-success'
: 'border-warning/30 bg-warning/15 text-warning'
},
{
id: 'drift',
label: 'Drift',
value: initialLoading ? '—' : String(metrics.speakersDrift),
description: 'applied ≠ published',
icon: GitBranch,
accent: statAccents[3],
badge: metrics.speakersDrift > 0 ? 'требует apply' : 'синхронно',
badgeVariant: metrics.speakersDrift > 0 ? ('secondary' as const) : ('outline' as const)
},
{
id: 'poll-errors',
label: 'Ошибки опроса',
value: initialLoading ? '—' : String(metrics.pollErrors),
description: 'agent или BGP poll',
icon: Activity,
accent: statAccents[4],
badge: metrics.pollErrors > 0 ? 'ошибки' : 'ok',
badgeClass:
metrics.pollErrors === 0 ? 'border-success/30 bg-success/15 text-success' : undefined
},
{
id: 'cp-bird',
label: 'BGP на CP',
value: initialLoading ? '—' : birdText,
description: bird?.birdc_configured
? 'Established / total на API-хосте'
: (bird?.message ?? 'birdc не настроен'),
icon: Bird,
accent: statAccents[5],
badge: !bird?.birdc_configured ? 'N/A' : bird?.healthy ? 'В норме' : 'Деградация',
href: '/monitoring' as const
}
]);
</script>
<div class="flex min-w-0 flex-col gap-6">
{#if !initialLoading && !loading}
{#if overallStatus === 'ok'}
<Alert class="border-success/30 bg-success/5">
<CheckCircle class="text-success" />
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription>{overallHint}</AlertDescription>
</Alert>
{:else if overallStatus === 'warn'}
<Alert class="border-warning/30 bg-warning/5">
<AlertTriangle class="text-warning" />
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription>
{overallHint}
{#if issues.length > 0}
<ul class="mt-2 list-inside list-disc text-sm">
{#each issues as issue (issue.id)}
<li>{issue.message}</li>
{/each}
</ul>
{/if}
</AlertDescription>
</Alert>
{:else}
<Alert variant="destructive">
<XCircle />
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription>
{overallHint}
{#if issues.length > 0}
<ul class="mt-2 list-inside list-disc text-sm">
{#each issues as issue (issue.id)}
<li>{issue.message}</li>
{/each}
</ul>
{/if}
</AlertDescription>
</Alert>
{/if}
{/if}
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading || loading}
skeletonCount={6}
class="sm:grid-cols-2 xl:grid-cols-3"
/>
<section class="flex min-w-0 flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-base font-semibold">Ноды</h2>
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
<Gauge class="size-3.5" />
Мониторинг API
</Button>
</div>
{#if speakers.length === 0 && !initialLoading && !loading}
<p class="text-sm text-muted-foreground">Спикеры не зарегистрированы.</p>
{:else}
<div class="grid auto-rows-fr gap-4 sm:grid-cols-2 xl:grid-cols-3">
{#each speakers as speaker (speaker.id)}
<NetworkSpeakerStatusCard
{speaker}
{peers}
class="h-full"
onclick={onSpeakerSelect ? () => onSpeakerSelect(speaker) : undefined}
/>
{/each}
</div>
{/if}
</section>
</div>
@@ -0,0 +1,347 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { PeerRow, BgpPeerCreate, SpeakerRow, PeerSessionOnSpeaker } from '$lib/api/types.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { Switch } from '@evobgp/ui/components/switch/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import AppInput from '$lib/components/patterns/form/app-input.svelte';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
items: PeerRow[];
speakers: SpeakerRow[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let {
items,
speakers,
loading = false,
initialLoading = false,
error = null,
onRefresh
}: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<PeerRow | null>(null);
let form = $state<BgpPeerCreate & { bgp_speaker_id?: string | null }>({
name: '',
neighbor: '',
remote_asn: 0,
bgp_speaker_id: null,
enabled: true
});
let saving = $state(false);
let toggleId = $state<string | null>(null);
const speakerById = $derived.by(() => new Map(speakers.map((s) => [s.id, s])));
const columns = [
{ id: 'name', label: 'Имя', sortable: true, sortValue: (p: PeerRow) => p.name ?? '' },
{ id: 'neighbor', label: 'Адрес', sortable: true, sortValue: (p: PeerRow) => p.neighbor },
{
id: 'remote_asn',
label: 'Remote ASN',
sortable: true,
sortValue: (p: PeerRow) => p.remote_asn ?? 0
},
{ id: 'enabled', label: 'Вкл.', class: 'w-[4.5rem] text-center' },
{ id: 'session_state', label: 'Состояние сессии' },
{ id: 'speaker', label: 'Спикер' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function peerNodeLine(s: PeerSessionOnSpeaker): string {
if (s.poll_error) return `${s.label}: опрос недоступен`;
if (s.state === 'Established') return `${s.label}: Established`;
if (s.state === 'absent') return `${s.label}: нет сессии`;
return `${s.label}: ${s.state || '—'}`;
}
function peerConnectedLabel(p: PeerRow): string {
const nodes = p.session_on_speakers ?? [];
if (nodes.length > 0) {
return nodes.map(peerNodeLine).join(' · ');
}
const established = p.established_on_speakers ?? [];
if (established.length > 0) {
return established.map((s) => `${s.label}: Established`).join(' · ');
}
return 'Не найден на опрошенных нодах';
}
function peerSessionHint(p: PeerRow): string | null {
if (!p.session_mismatch || !p.bgp_speaker_id) return null;
const expected = speakerLabelById(p.bgp_speaker_id);
const actual =
p.established_on_speakers?.map((s) => s.label).join(', ') ||
p.connected_speaker_label?.trim() ||
'другие ноды';
return `В конфиге: ${expected}; Established на: ${actual}`;
}
function speakerLabelById(id: string | null | undefined) {
if (!id) return 'Все спикеры';
const s = speakerById.get(id);
if (!s) return id.slice(0, 8) + '…';
if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint;
return host ? `CP · ${host}` : 'CP (master)';
}
return s.agent_domain ?? s.endpoint ?? id.slice(0, 8) + '…';
}
function sessionBadge(
state: string,
p: PeerRow
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (p.session_mismatch) return 'destructive';
if (state === 'Established') return 'default';
if (state === 'Active' || state === 'Connect') return 'secondary';
return 'outline';
}
function openCreate() {
editTarget = null;
form = { name: '', neighbor: '', remote_asn: 0, bgp_speaker_id: null, enabled: true };
dialogOpen = true;
}
function openEdit(p: PeerRow) {
editTarget = p;
form = {
name: p.name ?? '',
neighbor: p.neighbor,
remote_asn: p.remote_asn ?? 0,
bgp_speaker_id: p.bgp_speaker_id,
enabled: p.enabled !== false
};
dialogOpen = true;
}
function requestDelete(p: PeerRow) {
void confirm({
title: 'Удалить пира?',
description: p.neighbor,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/peers/${p.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Пир удалён');
await onRefresh();
}
});
}
async function setEnabled(p: PeerRow, enabled: boolean) {
toggleId = p.id;
try {
await apiMutate(`/v1/peers/${p.id}`, 'PATCH', { enabled });
notify.success(enabled ? 'Пир включён' : 'Пир отключён');
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
toggleId = null;
}
}
async function save() {
if (!form.neighbor.trim()) {
notify.error('Укажите адрес соседа');
return;
}
if (!form.remote_asn || form.remote_asn <= 0) {
notify.error('Remote ASN должен быть больше 0');
return;
}
saving = true;
try {
if (editTarget) {
await apiMutate(`/v1/peers/${editTarget.id}`, 'PATCH', form);
notify.success('Пир обновлён');
} else {
await apiMutate('/v1/peers', 'POST', {
...form,
enabled: form.enabled !== false
});
notify.success('Пир создан');
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">BGP-пиры</CardTitle>
<CardDescription>Настройка BGP-соседей и привязка к спикерам</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(p) => p.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет BGP-пиров"
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
>
{#snippet cell({ row: p, column })}
{#if column.id === 'name'}
<span>{p.name?.trim() || '—'}</span>
{:else if column.id === 'neighbor'}
<span class="font-mono text-sm">{p.neighbor}</span>
{:else if column.id === 'remote_asn'}
<span class="font-mono text-sm">{p.remote_asn ?? '—'}</span>
{:else if column.id === 'enabled'}
<div class="flex justify-center">
<Switch
size="sm"
checked={p.enabled !== false}
disabled={loading || toggleId === p.id}
onCheckedChange={(v) => setEnabled(p, v)}
/>
</div>
{:else if column.id === 'session_state'}
<div class="flex min-w-0 flex-col gap-0.5">
<Badge variant={sessionBadge(p.session_state, p)}>{p.session_state || '—'}</Badge>
<span
class="truncate text-xs text-muted-foreground"
title={peerSessionHint(p) ?? peerConnectedLabel(p)}
>
{peerConnectedLabel(p)}
</span>
{#if peerSessionHint(p)}
<span class="truncate text-xs text-destructive">{peerSessionHint(p)}</span>
{/if}
</div>
{:else if column.id === 'speaker'}
<span class="text-xs text-muted-foreground" title="Привязка в конфиге CP">
{p.bgp_speaker_id ? speakerLabelById(p.bgp_speaker_id) : 'Все спикеры'}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(p)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(p)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать пира' : 'Новый пир'}</DialogTitle>
<DialogDescription>BGP-сосед для установки сессии</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<FormField label="Имя пира (опционально)" id="p-name">
<AppInput id="p-name" placeholder="Core-RTR-1" bind:value={form.name} />
</FormField>
<FormField label="Адрес соседа" id="p-neighbor" required>
<AppInput id="p-neighbor" placeholder="192.0.2.1" bind:value={form.neighbor} />
</FormField>
<FormField label="Remote ASN" id="p-asn" required>
<AppInput id="p-asn" type="number" placeholder="65000" bind:value={form.remote_asn} />
</FormField>
<FormField label="Спикер (опционально)" id="p-speaker">
<Select
type="single"
value={form.bgp_speaker_id ?? ''}
onValueChange={(v) => {
form = { ...form, bgp_speaker_id: v || null };
}}
>
<SelectTrigger id="p-speaker" class="w-full">
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Все спикеры'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Не выбрано</SelectItem>
{#each speakers as s (s.id)}
<SelectItem value={s.id}>{s.endpoint} ({s.id.slice(0, 8)}…)</SelectItem>
{/each}
</SelectContent>
</Select>
</FormField>
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="p-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Выключенный пир не попадает в конфиг BIRD до следующей ревизии.
</p>
</div>
<Switch
id="p-enabled"
class="shrink-0"
checked={form.enabled !== false}
onCheckedChange={(v) => {
form = { ...form, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,203 @@
<script lang="ts">
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
import {
peersForSpeaker,
speakerDisplayStatus,
speakerDispatchError,
speakerHasDrift,
speakerLabel,
speakerLiveAgentError,
speakerLiveBgpError
} from '$lib/network/network-metrics.js';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Separator } from '@evobgp/ui/components/separator/index.js';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from '@evobgp/ui/components/sheet/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@evobgp/ui/components/table/index.js';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
type Props = {
speaker: SpeakerRow | null;
peers: PeerRow[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onApply?: (speaker: SpeakerRow) => void;
};
let { speaker, peers, open = $bindable(false), onOpenChange, onApply }: Props = $props();
const status = $derived(speaker ? speakerDisplayStatus(speaker) : null);
const label = $derived(speaker ? speakerLabel(speaker) : '');
const relatedPeers = $derived(speaker ? peersForSpeaker(peers, speaker.id) : []);
const sessions = $derived(speaker?.live?.sessions ?? []);
const dispatchError = $derived(speaker ? speakerDispatchError(speaker) : null);
const agentError = $derived(speaker ? speakerLiveAgentError(speaker) : null);
const bgpError = $derived(speaker ? speakerLiveBgpError(speaker) : null);
function driftLabel(s: SpeakerRow): string {
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
const app = s.last_applied_revision_id?.slice(0, 8) ?? '—';
return `${app} / ${pub}`;
}
function formatSyncAt(iso: string | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('ru-RU');
}
$effect(() => {
onOpenChange?.(open);
});
</script>
<Sheet bind:open>
<SheetContent class="flex w-full flex-col gap-0 overflow-y-auto p-0 sm:max-w-md">
{#if speaker}
<div class="flex min-w-0 flex-col gap-4 px-4 pt-4 pb-6">
<SheetHeader class="space-y-1 pr-8 text-left">
<SheetTitle class="truncate">{label}</SheetTitle>
<SheetDescription class="truncate">
{speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
</SheetDescription>
</SheetHeader>
<div class="flex flex-wrap items-center gap-2">
{#if status}
<Badge variant={status.variant}>{status.label}</Badge>
{/if}
{#if speakerHasDrift(speaker)}
<Badge variant="secondary">Drift</Badge>
{/if}
</div>
<dl class="grid grid-cols-[minmax(0,9rem)_1fr] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted-foreground">BGP Established</dt>
<dd class="text-right font-medium tabular-nums">
{speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
</dd>
{#if speaker.live?.agent_last_sync_at}
<dt class="text-muted-foreground">Последний sync</dt>
<dd class="text-right text-xs tabular-nums">
{formatSyncAt(speaker.live.agent_last_sync_at)}
</dd>
{/if}
<dt class="text-muted-foreground">Drift (app / pub)</dt>
<dd class="truncate text-right font-mono text-xs">{driftLabel(speaker)}</dd>
{#if speaker.last_dispatch_at}
<dt class="text-muted-foreground">Dispatch</dt>
<dd class="text-right text-xs tabular-nums">
{formatSyncAt(speaker.last_dispatch_at)}
</dd>
{/if}
</dl>
{#if dispatchError}
<Alert class="border-warning/30 bg-warning/5">
<AlertTriangle class="text-warning" />
<AlertTitle class="text-sm">{dispatchError.title}</AlertTitle>
<AlertDescription class="text-xs leading-relaxed"
>{dispatchError.detail}</AlertDescription
>
</Alert>
{/if}
{#if agentError}
<Alert variant="destructive">
<AlertTitle class="text-sm">{agentError.title}</AlertTitle>
<AlertDescription class="text-xs">{agentError.detail}</AlertDescription>
</Alert>
{/if}
{#if bgpError}
<Alert variant="destructive">
<AlertTitle class="text-sm">{bgpError.title}</AlertTitle>
<AlertDescription class="text-xs">{bgpError.detail}</AlertDescription>
</Alert>
{/if}
{#if onApply && speaker.published_revision_id}
<Button variant="outline" size="sm" class="w-fit" onclick={() => onApply(speaker)}>
Apply revision
</Button>
{/if}
<Separator />
<section class="min-w-0 space-y-2">
<h3 class="text-sm font-medium">BGP-сессии (live)</h3>
{#if sessions.length === 0}
<p class="text-sm text-muted-foreground">Нет данных или сессий нет.</p>
{:else}
<div class="rounded-md border">
<Table class="table-fixed">
<TableHeader>
<TableRow>
<TableHead class="w-[65%]">Имя</TableHead>
<TableHead class="w-[35%] text-right">Состояние</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each sessions as sess, i (sess.name + i)}
<TableRow>
<TableCell class="align-top">
<p class="truncate font-mono text-xs" title={sess.name}>{sess.name}</p>
{#if sess.neighbor}
<p class="truncate text-xs text-muted-foreground" title={sess.neighbor}>
{sess.neighbor}
</p>
{/if}
</TableCell>
<TableCell class="text-right align-top">
<Badge variant="outline" class="shrink-0">{sess.state}</Badge>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
{/if}
</section>
<Separator />
<section class="min-w-0 space-y-2">
<h3 class="text-sm font-medium">Пиры на ноде</h3>
{#if relatedPeers.length === 0}
<p class="text-sm text-muted-foreground">Нет привязанных пиров.</p>
{:else}
<ul class="divide-y rounded-md border">
{#each relatedPeers as p (p.id)}
<li class="flex min-w-0 items-start justify-between gap-3 px-3 py-2.5 text-sm">
<div class="min-w-0 flex-1">
<p class="truncate font-medium" title={p.name?.trim() || p.neighbor}>
{p.name?.trim() || p.neighbor}
</p>
{#if p.session_mismatch}
<p class="mt-0.5 text-xs text-warning">
Mismatch: сессия не на назначенной ноде
</p>
{/if}
</div>
<Badge variant="outline" class="shrink-0">{p.session_state || '—'}</Badge>
</li>
{/each}
</ul>
{/if}
</section>
</div>
{/if}
</SheetContent>
</Sheet>
@@ -0,0 +1,85 @@
<script lang="ts">
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
import {
speakerBgpText,
speakerDisplayStatus,
speakerHasDrift,
speakerLabel
} from '$lib/network/network-metrics.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import { cn } from '$lib/utils.js';
import Server from '@lucide/svelte/icons/server';
type Props = {
speaker: SpeakerRow;
peers?: PeerRow[];
onclick?: () => void;
class?: string;
};
let { speaker, peers = [], onclick, class: className }: Props = $props();
const status = $derived(speakerDisplayStatus(speaker));
const label = $derived(speakerLabel(speaker));
const drift = $derived(speakerHasDrift(speaker));
const peerCount = $derived(
peers.filter(
(p) =>
p.bgp_speaker_id === speaker.id ||
p.bgp_speaker_id === null ||
p.bgp_speaker_id === undefined
).length
);
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<Card
class={cn(
'flex h-full flex-col transition-colors',
onclick ? 'cursor-pointer hover:border-primary/35' : '',
className
)}
role={onclick ? 'button' : undefined}
tabindex={onclick ? 0 : undefined}
{onclick}
onkeydown={(e) => {
if (onclick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onclick();
}
}}
>
<CardHeader class="pb-2">
<div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<CardTitle class="flex items-center gap-2 text-sm">
<Server class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="truncate" title={label}>{label}</span>
</CardTitle>
<CardDescription class="truncate font-mono text-xs">{speaker.role}</CardDescription>
</div>
<Badge variant={status.variant} class="shrink-0">{status.label}</Badge>
</div>
</CardHeader>
<CardContent class="mt-auto pt-0">
<dl class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted-foreground">BGP</dt>
<dd class="font-medium tabular-nums">{speakerBgpText(speaker)}</dd>
<dt class="text-muted-foreground">Пиры</dt>
<dd class="tabular-nums">{peerCount}</dd>
<dt class="text-muted-foreground">Drift</dt>
<dd>
<Badge variant={drift ? 'secondary' : 'outline'} class="text-xs">
{drift ? 'есть' : 'нет'}
</Badge>
</dd>
</dl>
</CardContent>
</Card>
@@ -0,0 +1,526 @@
<script lang="ts">
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { SpeakerRow, BgpSpeakerCreate, BundleSigningPublicKey } from '$lib/api/types.js';
import {
speakerBgpText,
speakerDisplayStatus,
speakerHasDrift
} from '$lib/network/network-metrics.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '@evobgp/ui/components/dialog/index.js';
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import AppInput from '$lib/components/patterns/form/app-input.svelte';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Play from '@lucide/svelte/icons/play';
import Copy from '@lucide/svelte/icons/copy';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Eye from '@lucide/svelte/icons/eye';
type Props = {
items: SpeakerRow[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
onSpeakerSelect?: (speaker: SpeakerRow) => void;
};
let {
items,
loading = false,
initialLoading = false,
error = null,
onRefresh,
onSpeakerSelect
}: Props = $props();
type SpeakerForm = {
endpoint: string;
role: string;
agent_domain: string;
node_ipv4: string;
bird_bgp_source_ipv4: string;
bgpSourceManual: boolean;
};
let dialogOpen = $state(false);
let wizardOpen = $state(false);
let applyDialogOpen = $state(false);
let composeDialogOpen = $state(false);
let editTarget = $state<SpeakerRow | null>(null);
let applyTarget = $state<SpeakerRow | null>(null);
let composeTarget = $state<SpeakerRow | null>(null);
let applyRevisionId = $state('');
let composeText = $state('');
let createdSpeaker = $state<SpeakerRow | null>(null);
let form = $state<SpeakerForm>({
endpoint: '',
role: 'replica',
agent_domain: '',
node_ipv4: '',
bird_bgp_source_ipv4: '',
bgpSourceManual: false
});
let saving = $state(false);
let applyingId = $state<string | null>(null);
const columns = [
{ id: 'status', label: 'Статус' },
{ id: 'live_agent', label: 'Agent' },
{ id: 'bgp', label: 'BGP' },
{
id: 'agent_domain',
label: 'Agent domain',
sortable: true,
sortValue: (s: SpeakerRow) => s.agent_domain ?? s.endpoint
},
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
{ id: 'drift', label: 'Drift' },
{ id: 'actions', label: '', class: 'w-44' }
] as const;
function parseIpv4FromEndpoint(ep: string): string {
try {
const u = ep.includes('://') ? new URL(ep) : new URL(`https://${ep}`);
const host = u.hostname;
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host;
} catch {
/* ignore */
}
return '';
}
function onNodeIPv4Change(ip: string) {
form.node_ipv4 = ip;
if (!form.bgpSourceManual) {
form.bird_bgp_source_ipv4 = ip;
}
}
function onEndpointChange(ep: string) {
form.endpoint = ep;
const ip = parseIpv4FromEndpoint(ep);
if (ip && !form.node_ipv4) {
onNodeIPv4Change(ip);
}
}
function emptyForm(): SpeakerForm {
return {
endpoint: '',
role: 'replica',
agent_domain: '',
node_ipv4: '',
bird_bgp_source_ipv4: '',
bgpSourceManual: false
};
}
function formFromSpeaker(s: SpeakerRow): SpeakerForm {
return {
endpoint: s.endpoint,
role: s.role,
agent_domain: s.agent_domain ?? '',
node_ipv4: s.node_ipv4 ?? '',
bird_bgp_source_ipv4: s.bird_bgp_source_ipv4 ?? s.node_ipv4 ?? '',
bgpSourceManual: Boolean(
s.bird_bgp_source_ipv4 && s.node_ipv4 && s.bird_bgp_source_ipv4 !== s.node_ipv4
)
};
}
function buildMetaJson(f: SpeakerForm): string {
const meta: Record<string, string> = {};
if (f.agent_domain.trim()) meta.agent_domain = f.agent_domain.trim();
if (f.node_ipv4.trim()) meta.node_ipv4 = f.node_ipv4.trim();
if (f.bird_bgp_source_ipv4.trim()) meta.bird_bgp_source_ipv4 = f.bird_bgp_source_ipv4.trim();
return JSON.stringify(meta);
}
function buildApiBody(f: SpeakerForm): BgpSpeakerCreate {
const ep =
f.endpoint.trim() || (f.agent_domain.trim() ? `https://${f.agent_domain.trim()}` : '');
return {
endpoint: ep,
role: f.role.trim() || 'replica',
meta_json: buildMetaJson(f)
};
}
function statusVariant(s: SpeakerRow): 'default' | 'secondary' | 'destructive' | 'outline' {
return speakerDisplayStatus(s).variant;
}
function statusLabel(s: SpeakerRow): string {
return speakerDisplayStatus(s).label;
}
function liveAgentLabel(s: SpeakerRow): string {
if (!s.live) return '—';
if (s.live.agent_ok === true) return 'OK';
return s.live.agent_error ? 'Error' : 'Offline';
}
function driftLabel(s: SpeakerRow): string {
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
const app = s.last_applied_revision_id?.slice(0, 8) ?? '—';
return `${app} / ${pub}`;
}
function openCreate() {
editTarget = null;
form = emptyForm();
dialogOpen = true;
}
function openEdit(s: SpeakerRow) {
editTarget = s;
form = formFromSpeaker(s);
dialogOpen = true;
}
function requestDelete(s: SpeakerRow) {
const label = s.agent_domain ?? s.endpoint ?? s.id;
void confirm({
title: 'Удалить спикера?',
description: label,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/speakers/${s.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Спикер удалён');
await onRefresh();
}
});
}
function openApply(s: SpeakerRow) {
applyTarget = s;
applyRevisionId = s.published_revision_id ?? '';
applyDialogOpen = true;
}
async function buildComposeSnippet(s: SpeakerRow): Promise<string> {
let pubkey = '';
try {
const pk = await apiJSON<BundleSigningPublicKey>('/v1/bundle/signing-public-key');
pubkey = pk.public_key_base64;
} catch {
pubkey = '<GET /v1/bundle/signing-public-key>';
}
const domain = s.agent_domain ?? 'bgp-dc.example.com';
return `# deploy/compose/docker-compose.remote-speaker.yaml
# cp .env.remote-speaker.example .env.remote-speaker
# cp .env.remote-speaker-tls.example .env.remote-speaker-tls
EVOBGP_SPEAKER_ID=${s.id}
EVOBGP_AGENT_SECRET=<from UI wizard>
EVOBGP_NODE_TOKEN=<node API key from /access>
EVOBGP_BUNDLE_PUBKEY_BASE64=${pubkey}
EVOBGP_CONTROL_PLANE_URL=https://<your-cp-host>:8080
AGENT_DOMAIN=${domain}
PANEL_IP_WHITELIST=<CP public IP>/32
[email protected]
CF_DNS_API_TOKEN=<cloudflare token>
# docker compose -f docker-compose.remote-speaker.yaml \\
# --env-file .env.remote-speaker --env-file .env.remote-speaker-tls \\
# --profile production up -d`;
}
async function openCompose(s: SpeakerRow) {
composeTarget = s;
composeText = await buildComposeSnippet(s);
composeDialogOpen = true;
}
async function copyCompose() {
try {
await navigator.clipboard.writeText(composeText);
notify.success('Скопировано');
} catch {
notify.error('Не удалось скопировать');
}
}
async function applySpeaker() {
if (!applyTarget || !applyRevisionId.trim()) {
notify.error('Укажите revision_id');
return;
}
applyingId = applyTarget.id;
try {
await apiMutate(`/v1/speakers/${applyTarget.id}/apply`, 'POST', {
revision_id: applyRevisionId.trim()
});
notify.success('Apply запущен');
applyDialogOpen = false;
} catch (e) {
notifyApiError(e);
} finally {
applyingId = null;
}
}
async function save() {
const body = buildApiBody(form);
if (!body.endpoint.trim()) {
notify.error('Укажите endpoint или agent domain');
return;
}
saving = true;
try {
if (editTarget) {
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', body);
notify.success('Спикер обновлён');
dialogOpen = false;
} else {
const created = await apiMutate<SpeakerRow>('/v1/speakers', 'POST', body);
notify.success('Спикер создан');
dialogOpen = false;
createdSpeaker = created;
composeText = await buildComposeSnippet(created);
wizardOpen = true;
}
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
async function copyAgentSecret() {
const secret = createdSpeaker?.agent_secret;
if (!secret) return;
try {
await navigator.clipboard.writeText(secret);
notify.success('agent_secret скопирован');
} catch {
notify.error('Не удалось скопировать');
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Спикеры</CardTitle>
<CardDescription
>Удалённые BIRD-ноды (Remnawave-style Panel→Node + signed bundle)</CardDescription
>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(s) => s.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет спикеров"
emptyDescription="Добавьте реплику для применения signed bundle."
>
{#snippet cell({ row: s, column })}
{#if column.id === 'status'}
<Badge variant={statusVariant(s)}>{statusLabel(s)}</Badge>
{:else if column.id === 'live_agent'}
<Badge variant={s.live?.agent_ok ? 'outline' : 'destructive'}>{liveAgentLabel(s)}</Badge>
{:else if column.id === 'bgp'}
<span class="font-mono text-xs tabular-nums">{speakerBgpText(s)}</span>
{:else if column.id === 'agent_domain'}
<span class="font-mono text-sm">{s.agent_domain ?? s.endpoint}</span>
{:else if column.id === 'role'}
<Badge variant="outline">{s.role}</Badge>
{:else if column.id === 'drift'}
<span
class="font-mono text-xs text-muted-foreground"
title="applied / published"
class:text-warning={speakerHasDrift(s)}
>
{driftLabel(s)}
</span>
{:else if column.id === 'actions'}
<div class="flex flex-wrap gap-1">
{#if onSpeakerSelect}
<Button variant="outline" size="xs" title="Детали" onclick={() => onSpeakerSelect(s)}>
<Eye class="size-3" />
</Button>
{/if}
<Button variant="outline" size="xs" title="Copy compose" onclick={() => openCompose(s)}>
<Copy class="size-3" />
</Button>
<Button
variant="outline"
size="xs"
title="Apply revision (canary)"
onclick={() => openApply(s)}
disabled={applyingId === s.id}
>
<Play class="size-3" />
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
title="Удалить спикера"
onclick={() => requestDelete(s)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<FormField label="Agent domain (FQDN)" id="s-domain">
<AppInput id="s-domain" placeholder="bgp-dc2.example.com" bind:value={form.agent_domain} />
</FormField>
<FormField label="Endpoint" id="s-endpoint">
<AppInput
id="s-endpoint"
placeholder="https://bgp-dc2.example.com"
value={form.endpoint}
oninput={(e) => onEndpointChange((e.currentTarget as HTMLInputElement).value)}
/>
</FormField>
<FormField label="IP ноды (IPv4)" id="s-node-ip">
<AppInput
id="s-node-ip"
placeholder="203.0.113.10"
value={form.node_ipv4}
oninput={(e) => onNodeIPv4Change((e.currentTarget as HTMLInputElement).value)}
/>
</FormField>
<FormField label="BGP source IPv4" id="s-bgp-src">
<AppInput
id="s-bgp-src"
placeholder="= IP ноды"
bind:value={form.bird_bgp_source_ipv4}
disabled={!form.bgpSourceManual}
/>
</FormField>
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={form.bgpSourceManual} />
Задать BGP source вручную
</label>
<FormField label="Роль" id="s-role">
<AppInput id="s-role" placeholder="replica" bind:value={form.role} />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={wizardOpen}>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Спикер создан</DialogTitle>
<DialogDescription>
Сохраните agent_secret — он больше не отображается. Скопируйте compose на VPS реплики.
</DialogDescription>
</DialogHeader>
{#if createdSpeaker?.agent_secret}
<FormField label="agent_secret (один раз)" id="w-secret">
<div class="flex gap-2">
<AppInput
id="w-secret"
readonly
value={createdSpeaker.agent_secret}
class="font-mono text-xs"
/>
<Button variant="outline" size="icon-sm" onclick={copyAgentSecret}><Copy /></Button>
</div>
</FormField>
{/if}
<FormField label="docker-compose env" id="w-compose">
<textarea
id="w-compose"
class="min-h-[200px] w-full rounded-md border bg-muted/30 p-2 font-mono text-xs"
readonly
value={composeText}
></textarea>
</FormField>
<DialogFooter>
<Button variant="outline" onclick={copyCompose}><Copy />Copy compose</Button>
<Button onclick={() => (wizardOpen = false)}>Готово</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={applyDialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Apply на спикер</DialogTitle>
</DialogHeader>
<FormField label="revision_id" id="a-rev" required>
<AppInput id="a-rev" bind:value={applyRevisionId} class="font-mono text-xs" />
</FormField>
<DialogFooter>
<Button variant="outline" onclick={() => (applyDialogOpen = false)}>Отмена</Button>
<Button onclick={applySpeaker} disabled={applyingId != null}>Apply</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={composeDialogOpen}>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Copy docker-compose</DialogTitle>
<DialogDescription
>Спикер {composeTarget?.agent_domain ?? composeTarget?.id}</DialogDescription
>
</DialogHeader>
<textarea
class="min-h-[240px] w-full rounded-md border bg-muted/30 p-2 font-mono text-xs"
readonly
value={composeText}
></textarea>
<DialogFooter>
<Button variant="outline" onclick={copyCompose}><Copy />Копировать</Button>
<Button onclick={() => (composeDialogOpen = false)}>Закрыть</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,218 @@
<script lang="ts">
import type { RevisionDiff, RevisionPrefix, RevisionRow } from '$lib/api/types.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { formatDateTime } from '$lib/modules/display.js';
import { sortRevisionDiffItems } from '$lib/sort-prefixes.js';
type Props = {
revisions: RevisionRow[];
diffRevA: string;
diffRevB: string;
diffData: RevisionDiff | null;
diffLoading: boolean;
onDiffRevAChange: (value: string) => void;
onDiffRevBChange: (value: string) => void;
onLoadDiff: () => void;
};
let {
revisions,
diffRevA,
diffRevB,
diffData,
diffLoading,
onDiffRevAChange,
onDiffRevBChange,
onLoadDiff
}: Props = $props();
function revisionLabel(rev: RevisionRow): string {
return `${rev.id.slice(0, 8)}… (${formatDateTime(rev.created_at)})`;
}
function diffAddedRaw(d: RevisionDiff | null): (string | RevisionPrefix)[] {
if (!d) return [];
if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added;
if (Array.isArray(d.added)) return d.added;
return [];
}
function diffRemovedRaw(d: RevisionDiff | null): (string | RevisionPrefix)[] {
if (!d) return [];
if (d.prefixes && Array.isArray(d.prefixes.removed)) return d.prefixes.removed;
if (Array.isArray(d.removed)) return d.removed;
return [];
}
let addedSorted = $derived(diffData ? sortRevisionDiffItems(diffAddedRaw(diffData)) : []);
let removedSorted = $derived(diffData ? sortRevisionDiffItems(diffRemovedRaw(diffData)) : []);
let scrollAddedEl: HTMLDivElement | null = $state(null);
let scrollRemovedEl: HTMLDivElement | null = $state(null);
let syncScrollLock = false;
function syncFromAdded() {
if (syncScrollLock || !scrollAddedEl || !scrollRemovedEl) return;
syncScrollLock = true;
scrollRemovedEl.scrollTop = scrollAddedEl.scrollTop;
queueMicrotask(() => {
syncScrollLock = false;
});
}
function syncFromRemoved() {
if (syncScrollLock || !scrollAddedEl || !scrollRemovedEl) return;
syncScrollLock = true;
scrollAddedEl.scrollTop = scrollRemovedEl.scrollTop;
queueMicrotask(() => {
syncScrollLock = false;
});
}
</script>
<Card class="min-h-0">
<CardHeader>
<CardTitle class="text-base">Сравнение ревизий</CardTitle>
<CardDescription>Выберите ID двух ревизий для сравнения</CardDescription>
</CardHeader>
<CardContent class="min-h-0 space-y-4">
<div class="flex flex-col gap-2 sm:flex-row">
<Select type="single" value={diffRevA} onValueChange={(v) => onDiffRevAChange(v ?? '')}>
<SelectTrigger class="min-w-0 flex-1">
{diffRevA
? revisions.find((r) => r.id === diffRevA)
? revisionLabel(revisions.find((r) => r.id === diffRevA)!)
: diffRevA
: 'Ревизия A'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ревизия A</SelectItem>
{#each revisions as rev (rev.id)}
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
{/each}
</SelectContent>
</Select>
<Select type="single" value={diffRevB} onValueChange={(v) => onDiffRevBChange(v ?? '')}>
<SelectTrigger class="min-w-0 flex-1">
{diffRevB
? revisions.find((r) => r.id === diffRevB)
? revisionLabel(revisions.find((r) => r.id === diffRevB)!)
: diffRevB
: 'Ревизия B'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ревизия B</SelectItem>
{#each revisions as rev (rev.id)}
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
{/each}
</SelectContent>
</Select>
<Button
size="sm"
class="shrink-0 self-start sm:self-auto"
onclick={onLoadDiff}
disabled={diffLoading}
>
{diffLoading ? 'Загрузка…' : 'Сравнить'}
</Button>
</div>
{#if diffData}
<div
class="grid min-h-0 grid-cols-1 overflow-hidden rounded-md border border-border sm:grid-cols-2"
style="scrollbar-gutter: stable;"
>
<div class="flex min-h-0 min-w-0 flex-col border-b border-border sm:border-r sm:border-b-0">
<div
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-success"
>
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">+</span>
<span>Добавлено ({addedSorted.length})</span>
</div>
<div
bind:this={scrollAddedEl}
class="max-h-[min(28rem,70vh)] min-h-0 overflow-x-auto overflow-y-scroll overscroll-contain bg-background/50"
onscroll={syncFromAdded}
>
{#if addedSorted.length === 0}
<p class="p-3 font-mono text-xs text-muted-foreground">Нет изменений</p>
{:else}
<table class="w-full border-collapse font-mono text-xs">
<tbody>
{#each addedSorted as line, i (`a-${i}-${line}`)}
<tr
class="border-b border-l-2 border-success/30 bg-success/5 hover:bg-muted/30"
>
<td
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
>
{i + 1}
</td>
<td
class="max-w-0 py-0.5 pr-3 pl-1 break-all whitespace-pre-wrap text-foreground"
>
{line}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
<div class="flex min-h-0 min-w-0 flex-col">
<div
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-destructive"
>
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none"></span>
<span>Удалено ({removedSorted.length})</span>
</div>
<div
bind:this={scrollRemovedEl}
class="max-h-[min(28rem,70vh)] min-h-0 overflow-x-auto overflow-y-scroll overscroll-contain bg-background/50"
onscroll={syncFromRemoved}
>
{#if removedSorted.length === 0}
<p class="p-3 font-mono text-xs text-muted-foreground">Нет изменений</p>
{:else}
<table class="w-full border-collapse font-mono text-xs">
<tbody>
{#each removedSorted as line, i (`r-${i}-${line}`)}
<tr
class="border-b border-l-2 border-destructive/30 bg-destructive/5 hover:bg-muted/30"
>
<td
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
>
{i + 1}
</td>
<td
class="max-w-0 py-0.5 pr-3 pl-1 break-all whitespace-pre-wrap text-foreground"
>
{line}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</div>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,163 @@
<script lang="ts">
import { Button } from '@evobgp/ui/components/button/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import Filter from '@lucide/svelte/icons/filter';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
import { jobKindFilterRu, jobStatusRu } from '$lib/ui-labels.js';
type Props = {
searchQ: string;
onSearchQChange: (v: string) => void;
filterStatus: string;
onFilterStatusChange: (v: string) => void;
filterKind: string;
onFilterKindChange: (v: string) => void;
filterModuleId: string;
onFilterModuleIdChange: (v: string) => void;
activeOnly: boolean;
onErrorsChip: () => void;
onActiveChip: () => void;
onResetFilters: () => void;
moduleOptions: { id: string; name: string }[];
disabled?: boolean;
};
let {
searchQ,
onSearchQChange,
filterStatus,
onFilterStatusChange,
filterKind,
onFilterKindChange,
filterModuleId,
onFilterModuleIdChange,
activeOnly,
onErrorsChip,
onActiveChip,
onResetFilters,
moduleOptions,
disabled = false
}: Props = $props();
const statusOptions: { value: string; label: string }[] = [
{ value: '', label: 'Все статусы' },
{ value: 'queued', label: jobStatusRu('queued') },
{ value: 'running', label: jobStatusRu('running') },
{ value: 'succeeded', label: jobStatusRu('succeeded') },
{ value: 'failed', label: jobStatusRu('failed') },
{ value: 'cancelled', label: jobStatusRu('cancelled') }
];
const kindOptions: { value: string; label: string }[] = [
{ value: '', label: 'Все типы' },
{ value: 'module_refresh', label: jobKindFilterRu('module_refresh') },
{ value: 'deploy_apply', label: jobKindFilterRu('deploy_apply') },
{ value: 'revision_rollback', label: jobKindFilterRu('revision_rollback') },
{ value: 'bird_reload', label: jobKindFilterRu('bird_reload') }
];
</script>
<div class="space-y-3 rounded-lg border border-border/80 bg-muted/15 p-3">
<div class="flex flex-wrap items-center gap-2">
<Filter class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="text-sm font-medium">Фильтры задач</span>
<div class="ml-auto flex flex-wrap gap-1.5">
<Button
type="button"
variant={filterStatus === 'failed' && !activeOnly ? 'default' : 'outline'}
size="sm"
class="h-7 text-xs"
{disabled}
onclick={onErrorsChip}
>
Только ошибки
</Button>
<Button
type="button"
variant={activeOnly ? 'default' : 'outline'}
size="sm"
class="h-7 text-xs"
{disabled}
onclick={onActiveChip}
>
В работе
</Button>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 text-xs"
{disabled}
onclick={onResetFilters}
>
<X class="size-3.5" aria-hidden="true" />
Сброс
</Button>
</div>
</div>
<div class="relative">
<Search
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
type="search"
placeholder="Поиск по ID, типу, статусу, ошибке, meta…"
class="h-9 pl-9"
value={searchQ}
oninput={(e) => onSearchQChange((e.currentTarget as HTMLInputElement).value)}
{disabled}
autocomplete="off"
aria-label="Текстовый поиск по задачам"
/>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div class="space-y-1.5">
<Label for="job-filter-status" class="text-xs">Статус</Label>
<select
id="job-filter-status"
class="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
value={filterStatus}
onchange={(e) => onFilterStatusChange((e.currentTarget as HTMLSelectElement).value)}
{disabled}
>
{#each statusOptions as o (o.value)}
<option value={o.value}>{o.label}</option>
{/each}
</select>
</div>
<div class="space-y-1.5">
<Label for="job-filter-kind" class="text-xs">Тип задачи</Label>
<select
id="job-filter-kind"
class="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
value={filterKind}
onchange={(e) => onFilterKindChange((e.currentTarget as HTMLSelectElement).value)}
{disabled}
>
{#each kindOptions as o (o.value)}
<option value={o.value}>{o.label}</option>
{/each}
</select>
</div>
<div class="space-y-1.5">
<Label for="job-filter-module" class="text-xs">Модуль (локально)</Label>
<select
id="job-filter-module"
class="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
value={filterModuleId}
onchange={(e) => onFilterModuleIdChange((e.currentTarget as HTMLSelectElement).value)}
{disabled}
>
<option value="">Все модули</option>
{#each moduleOptions as m (m.id)}
<option value={m.id}>{m.name}</option>
{/each}
</select>
</div>
</div>
</div>
@@ -0,0 +1,463 @@
<script lang="ts">
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { JobRow } from '$lib/api/types.js';
import type { JobDetailedReport, JobLogEntry } from './types.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import EmptyState from '$lib/components/patterns/empty-state/empty-state.svelte';
import { formatDateTime } from '$lib/modules/display.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Eye from '@lucide/svelte/icons/eye';
import X from '@lucide/svelte/icons/x';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import CircleDot from '@lucide/svelte/icons/circle-dot';
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
import PlayCircle from '@lucide/svelte/icons/play-circle';
import Flag from '@lucide/svelte/icons/flag';
import Layers from '@lucide/svelte/icons/layers';
import Globe from '@lucide/svelte/icons/globe';
import Binary from '@lucide/svelte/icons/binary';
import Link2 from '@lucide/svelte/icons/link-2';
import { cn } from '$lib/utils.js';
import { jobKindSubtitle, jobKindTitle } from '$lib/operations/job-kind-label.js';
import { jobStatusRu, logKindRu, moduleTypeRu } from '$lib/ui-labels.js';
import JobReportTableBlock from './job-report-table-block.svelte';
import { asnReportColumns, reportRowColumns } from './job-report-columns.js';
import type { RowData } from '@tanstack/table-core';
type Props = {
jobs: JobRow[];
/** Сколько задач вернул API до клиентских фильтров */
jobsFetchedTotal?: number;
jobsLoading: boolean;
moduleNameById: ReadonlyMap<string, string>;
expandedJobIds: SvelteSet<string>;
jobDetailsById: SvelteMap<string, JobRow>;
jobDetailsLoading: SvelteSet<string>;
jobReportsById: SvelteMap<string, JobDetailedReport>;
jobReportsLoading: SvelteSet<string>;
onReloadJobs: () => void;
onOpenJobDetail: (job: JobRow) => void;
onRequestCancelJob: (job: JobRow) => void;
onToggleJobExpanded: (job: JobRow) => void | Promise<void>;
isJobExpanded: (jobId: string) => boolean;
getJobLogEntries: (job: JobRow) => JobLogEntry[];
getJobLogTotal: (job: JobRow, entries?: JobLogEntry[]) => number;
jobStatusVariant: (status: string) => 'default' | 'secondary' | 'outline' | 'destructive';
};
let {
jobs,
jobsFetchedTotal,
jobsLoading,
moduleNameById,
expandedJobIds,
jobDetailsById,
jobDetailsLoading,
jobReportsById,
jobReportsLoading,
onReloadJobs,
onOpenJobDetail,
onRequestCancelJob,
onToggleJobExpanded,
isJobExpanded,
getJobLogEntries,
getJobLogTotal,
jobStatusVariant
}: Props = $props();
const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef<
RowData,
unknown
>[];
const asnCols = asnReportColumns as import('@tanstack/table-core').ColumnDef<RowData, unknown>[];
</script>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Задачи</CardTitle>
<CardDescription class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>Фоновые задачи (ingest, применение, обновление)</span>
{#if jobsFetchedTotal !== undefined}
<span class="font-normal text-muted-foreground tabular-nums">
· Показано {jobs.length} из {jobsFetchedTotal}
</span>
{/if}
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
class="shrink-0 self-start sm:self-auto"
onclick={onReloadJobs}
disabled={jobsLoading}
>
<RefreshCw class={jobsLoading ? 'animate-spin' : ''} />
</Button>
</CardHeader>
<CardContent class="space-y-3 p-3 sm:p-4">
{#each jobs as job (job.job_id)}
{@const kindSub = jobKindSubtitle(job, moduleNameById)}
<div class="min-w-0 overflow-hidden rounded-lg border bg-card">
<div class="flex min-w-0 flex-col gap-3 p-3 sm:p-4">
<div class="flex min-w-0 flex-wrap items-start justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<Button
variant="ghost"
size="icon-sm"
class="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={isJobExpanded(job.job_id)
? 'Свернуть детали задачи'
: 'Развернуть детали задачи'}
aria-expanded={isJobExpanded(job.job_id)}
onclick={() => void onToggleJobExpanded(job)}
>
<ChevronDown
class={cn(
'size-4 transition-transform',
isJobExpanded(job.job_id) && 'rotate-180'
)}
/>
</Button>
<div class="min-w-0">
<p class="font-medium break-words">{jobKindTitle(job, moduleNameById)}</p>
{#if kindSub}
<p class="font-mono text-xs break-all text-muted-foreground">{kindSub}</p>
{/if}
<p class="font-mono text-xs break-all text-muted-foreground">{job.job_id}</p>
</div>
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onclick={() => onOpenJobDetail(job)}
aria-label="Открыть задачу в модальном окне"
>
<Eye class="size-3.5" />
</Button>
{#if job.status === 'running' || job.status === 'queued'}
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => onRequestCancelJob(job)}
aria-label="Отменить задачу"
>
<X class="size-3.5" />
</Button>
{/if}
</div>
</div>
<div class="grid gap-2 text-sm sm:grid-cols-2 xl:grid-cols-4">
<div
class="rounded-md border border-chart-1/25 bg-chart-1/5 px-2.5 py-2 dark:bg-chart-1/10"
>
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-1 uppercase">
<CircleDot class="size-3.5 shrink-0" aria-hidden="true" />
Статус
</p>
<Badge class="mt-1" variant={jobStatusVariant(job.status)}
>{jobStatusRu(job.status)}</Badge
>
</div>
<div
class="rounded-md border border-chart-2/25 bg-chart-2/5 px-2.5 py-2 dark:bg-chart-2/10"
>
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-2 uppercase">
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
Создана
</p>
<p class="mt-1">{formatDateTime(job.created_at)}</p>
</div>
<div
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
>
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-3 uppercase">
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
Запущена
</p>
<p class="mt-1">{formatDateTime(job.started_at)}</p>
</div>
<div
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
>
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-4 uppercase">
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
Завершена
</p>
<p class="mt-1">{formatDateTime(job.finished_at)}</p>
</div>
</div>
</div>
{#if expandedJobIds.has(job.job_id)}
{@const detailedJob = jobDetailsById.get(job.job_id) ?? job}
{@const isDetailsLoading = jobDetailsLoading.has(job.job_id)}
{@const isReportLoading = jobReportsLoading.has(job.job_id)}
{@const jobReport = jobReportsById.get(job.job_id)}
{@const logEntries = getJobLogEntries(detailedJob)}
{@const logTotal = getJobLogTotal(detailedJob, logEntries)}
<div class="border-t bg-muted/10 p-3 sm:p-4">
<div class="space-y-3 pr-1">
{#if isDetailsLoading}
<p class="text-xs text-muted-foreground">Догружаем свежие детали задачи…</p>
{/if}
{#if detailedJob.error}
<div class="rounded-md border border-destructive/30 bg-destructive/5 p-3">
<p class="mb-1 text-xs text-muted-foreground">Ошибка</p>
<p class="text-sm break-words text-destructive">{detailedJob.error}</p>
</div>
{/if}
{#if logEntries.length > 0}
<div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium">Журнал обработки</p>
<p
class="rounded-md border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground"
>
{logEntries.length} записей, всего {logTotal} префиксов
</p>
</div>
<div class="rounded-md border bg-muted/10 p-2">
<div class="space-y-2">
{#each logEntries as entry, idx (`${job.job_id}-${idx}`)}
<div
class="space-y-3 rounded-lg border border-border/70 bg-card p-3 shadow-sm"
>
<p class="text-sm leading-relaxed font-medium break-words">
{entry.message}
</p>
<div class="grid gap-2 md:grid-cols-2">
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span
class="text-[11px] tracking-wide text-muted-foreground uppercase"
>Источник</span
>
<p
class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs break-all"
>
{entry.source}
</p>
</div>
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span
class="text-[11px] tracking-wide text-muted-foreground uppercase"
>Тип</span
>
<p
class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs break-all"
>
{logKindRu(entry.kind)}
</p>
</div>
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span
class="text-[11px] tracking-wide text-muted-foreground uppercase"
>Сообщество BGP</span
>
<p
class="mt-1 rounded bg-background px-1.5 py-0.5 text-xs break-words"
title={entry.community !== 'none' ? entry.community : undefined}
>
{entry.community_label?.trim() || entry.community}
</p>
</div>
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span
class="text-[11px] tracking-wide text-muted-foreground uppercase"
>Префиксы</span
>
<p class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs">
{entry.prefix_count}
</p>
</div>
</div>
{#if entry.sample && entry.sample.length > 0}
<div class="space-y-1.5">
<p class="text-[11px] tracking-wide text-muted-foreground uppercase">
Примеры
</p>
<div class="rounded-md border bg-muted/35 p-2.5">
<div class="space-y-1.5">
{#each entry.sample as sampleValue, sampleIdx (`${job.job_id}-${idx}-sample-${sampleIdx}`)}
<p
class="rounded bg-background px-2 py-1 font-mono text-xs break-all"
>
{sampleValue}
</p>
{/each}
</div>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/if}
{#if isReportLoading}
<p class="text-xs text-muted-foreground">
Собираем подробный отчёт по источникам и агрегации…
</p>
{/if}
{#if jobReport}
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium">Операции по модулю</p>
<div class="flex flex-wrap gap-1.5">
{#if jobReport.module}
<Badge variant="outline">{moduleTypeRu(jobReport.module.type)}</Badge>
<Badge variant="secondary">{jobReport.module.name}</Badge>
{/if}
{#if jobReport.revisionId}
<Badge variant="outline" class="font-mono text-[10px]"
>{jobReport.revisionId.slice(0, 8)}</Badge
>
{/if}
</div>
</div>
<div class="grid min-w-0 gap-2 md:grid-cols-2 xl:grid-cols-4">
<div
class="min-w-0 overflow-hidden rounded-md border border-chart-1/30 bg-chart-1/5 p-2.5 dark:bg-chart-1/10"
>
<p
class="flex items-center gap-1 text-[11px] font-medium text-chart-1 uppercase"
>
<Layers class="size-3.5 shrink-0" aria-hidden="true" />
Агрегация
</p>
<p class="text-sm font-semibold">{jobReport.aggregationTotal} префиксов</p>
</div>
<div
class="min-w-0 overflow-hidden rounded-md border border-chart-2/30 bg-chart-2/5 p-2.5 dark:bg-chart-2/10"
>
<p
class="flex items-center gap-1 text-[11px] font-medium text-chart-2 uppercase"
>
<Globe class="size-3.5 shrink-0" aria-hidden="true" />
Домены
</p>
<p class="text-sm font-semibold">{jobReport.domains.length}</p>
</div>
<div
class="min-w-0 overflow-hidden rounded-md border border-chart-3/30 bg-chart-3/5 p-2.5 dark:bg-chart-3/10"
>
<p
class="flex items-center gap-1 text-[11px] font-medium text-chart-3 uppercase"
>
<Binary class="size-3.5 shrink-0" aria-hidden="true" />
ASN
</p>
<p class="text-sm font-semibold">{jobReport.asn.length}</p>
</div>
<div
class="min-w-0 overflow-hidden rounded-md border border-chart-4/30 bg-chart-4/5 p-2.5 dark:bg-chart-4/10"
>
<p
class="flex items-center gap-1 text-[11px] font-medium text-chart-4 uppercase"
>
<Link2 class="size-3.5 shrink-0" aria-hidden="true" />
CDN / IP-диапазоны
</p>
<p class="text-sm font-semibold">
{jobReport.cdn.length}/{jobReport.ipRanges.length}
</p>
</div>
</div>
{#if jobReport.aggregationByKind.length > 0}
<div class="space-y-1">
<p class="text-xs text-muted-foreground">Результат агрегации по типам</p>
<div class="flex flex-wrap gap-1.5">
{#each jobReport.aggregationByKind as row (`${job.job_id}-agg-${row.kind}`)}
<Badge variant="outline">{logKindRu(row.kind)}: {row.prefixCount}</Badge>
{/each}
</div>
</div>
{/if}
<div class="grid min-w-0 gap-3 xl:grid-cols-2">
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">
Домены: какой домен какие IP/префиксы вернул
</p>
<JobReportTableBlock
rows={jobReport.domains as RowData[]}
columns={reportCols}
emptyLabel="Нет данных по доменам"
/>
</div>
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">ASN: сколько префиксов получено по AS</p>
<JobReportTableBlock
rows={jobReport.asn as RowData[]}
columns={asnCols}
emptyLabel="Нет данных по ASN"
/>
</div>
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">
CDN: из каждой ссылки полученные IP/префиксы
</p>
<JobReportTableBlock
rows={jobReport.cdn as RowData[]}
columns={reportCols}
emptyLabel="Нет данных по CDN"
/>
</div>
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">
IP-диапазоны: итог по статическим диапазонам
</p>
<JobReportTableBlock
rows={jobReport.ipRanges as RowData[]}
columns={reportCols}
emptyLabel="Нет данных по IP range"
/>
</div>
</div>
</div>
{/if}
<div class="space-y-1">
<p class="text-xs text-muted-foreground">Meta (JSON)</p>
<div class="rounded-md border bg-muted/30 p-3">
<pre
class="font-mono text-xs [overflow-wrap:anywhere] whitespace-pre-wrap">{JSON.stringify(
detailedJob.meta ?? {},
null,
2
)}</pre>
</div>
</div>
</div>
</div>
{/if}
</div>
{:else}
{#if jobsLoading}
<div class="py-8 text-center text-sm text-muted-foreground">Загрузка…</div>
{:else}
<EmptyState
title="Нет задач"
description="Задачи появятся после refresh, apply или rollback."
/>
{/if}
{/each}
</CardContent>
</Card>
@@ -0,0 +1,174 @@
<script lang="ts">
import type { BirdStatus } from '$lib/api/types.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Card } from '@evobgp/ui/components/card/index.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Play from '@lucide/svelte/icons/play';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import Bird from '@lucide/svelte/icons/bird';
import { cn } from '$lib/utils.js';
type Props = {
applying: boolean;
reloading: boolean;
birdLoading: boolean;
birdStatus: BirdStatus | null;
onApply: () => void;
onReload: () => void;
onRefreshBirdStatus: () => void;
onOpenBirdProtocols: () => void;
birdHealthyBadgeVariant: (
h: boolean | null | undefined
) => 'default' | 'secondary' | 'outline' | 'destructive';
birdHealthyShortLabel: (h: boolean | null | undefined) => string;
};
let {
applying,
reloading,
birdLoading,
birdStatus,
onApply,
onReload,
onRefreshBirdStatus,
onOpenBirdProtocols,
birdHealthyBadgeVariant,
birdHealthyShortLabel
}: Props = $props();
</script>
<div class="grid grid-cols-1 items-stretch gap-3 sm:grid-cols-2 xl:grid-cols-3">
<Card
class={cn('min-w-0 overflow-hidden border-l-4 border-l-chart-1 bg-chart-1/5 p-4 shadow-sm')}
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 flex-1 gap-3">
<div
class="flex size-11 shrink-0 items-center justify-center rounded-xl bg-chart-1/20"
aria-hidden="true"
>
<Play class="size-5 text-chart-1" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<p class="font-semibold">Применить ко всем спикерам</p>
<p class="max-w-[42ch] text-sm text-muted-foreground">
Применить текущую конфигурацию на всех BIRD-спикерах
</p>
</div>
</div>
<Button
class="w-full shrink-0 self-start sm:w-auto sm:self-auto"
onclick={onApply}
disabled={applying}
>
<Play class="size-4" aria-hidden="true" />
Применить
</Button>
</div>
</Card>
<Card
class={cn('min-w-0 overflow-hidden border-l-4 border-l-chart-4 bg-chart-4/5 p-4 shadow-sm')}
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 flex-1 gap-3">
<div
class="flex size-11 shrink-0 items-center justify-center rounded-xl bg-chart-4/20"
aria-hidden="true"
>
<RotateCcw class="size-5 text-chart-4" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<p class="font-semibold">Перезагрузка BIRD</p>
<p class="max-w-[42ch] text-sm text-muted-foreground">
Перезагрузить конфигурацию BIRD на всех спикерах
</p>
</div>
</div>
<Button
variant="outline"
class="w-full shrink-0 self-start sm:w-auto sm:self-auto"
onclick={onReload}
disabled={reloading}
>
<RotateCcw class="size-4" aria-hidden="true" />
Перезагрузить
</Button>
</div>
</Card>
<Card
class={cn(
'min-w-0 overflow-hidden border-l-4 border-l-info bg-info/10 p-4 shadow-sm sm:col-span-2 xl:col-span-1'
)}
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<div
class="mr-0.5 flex size-9 items-center justify-center rounded-lg bg-info/15"
aria-hidden="true"
>
<Bird class="size-5 shrink-0 text-info" />
</div>
<p class="font-semibold">Состояние BIRD</p>
{#if birdStatus}
<Badge
variant={birdHealthyBadgeVariant(birdStatus.healthy)}
class={birdStatus.healthy === true
? 'border-success/30 bg-success/15 text-success'
: undefined}
>
{birdHealthyShortLabel(birdStatus.healthy)}
</Badge>
{/if}
</div>
<p class="max-w-[56ch] text-sm text-foreground/80">
Локально на хосте API: <code class="rounded bg-muted px-1 text-xs"
>birdc show protocols</code
>. Не заменяет мониторинг спикеров.
</p>
{#if birdLoading}
<p class="text-sm text-foreground/80">Загрузка…</p>
{:else if birdStatus}
{#if !birdStatus.birdc_configured}
<p class="text-sm text-foreground/80">
{birdStatus.message ?? 'birdc не настроен на API.'}
</p>
{:else if birdStatus.error}
<p class="text-sm text-destructive">{birdStatus.error}</p>
{:else}
<p class="text-sm">
<span class="text-muted-foreground">BGP сессий:</span>
<span class="font-medium">{birdStatus.bgp_established}</span>
<span class="text-muted-foreground">/</span>
<span class="font-medium">{birdStatus.bgp_sessions_total}</span>
<span class="text-muted-foreground"> установлено / всего</span>
</p>
{/if}
{:else}
<p class="text-sm text-foreground/80">Статус не загружен</p>
{/if}
</div>
<div class="flex w-full shrink-0 flex-wrap gap-2 sm:w-auto sm:flex-col sm:items-stretch">
<Button
variant="outline"
size="sm"
class="w-full sm:w-auto"
onclick={onRefreshBirdStatus}
disabled={birdLoading}
>
<RefreshCw class={birdLoading ? 'size-3.5 animate-spin' : 'size-3.5'} />
Обновить
</Button>
{#if birdStatus?.birdc_configured && birdStatus.protocols_excerpt}
<Button variant="ghost" size="sm" class="w-full sm:w-auto" onclick={onOpenBirdProtocols}>
Вывод birdc
</Button>
{/if}
</div>
</div>
</Card>
</div>
@@ -0,0 +1,120 @@
<script lang="ts">
import type { RevisionRow } from '$lib/api/types.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { formatDateTime } from '$lib/modules/display.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Undo from '@lucide/svelte/icons/undo';
import Eye from '@lucide/svelte/icons/eye';
import Download from '@lucide/svelte/icons/download';
type Props = {
revisions: RevisionRow[];
revLoading: boolean;
onReload: () => void;
onOpenPreview: (rev: RevisionRow) => void;
onRollbackRequest: (rev: RevisionRow) => void;
onDownloadDiagnosticLog: (rev: RevisionRow) => void;
};
let {
revisions,
revLoading,
onReload,
onOpenPreview,
onRollbackRequest,
onDownloadDiagnosticLog
}: Props = $props();
const columns = [
{
id: 'id',
label: 'ID',
sortable: true,
sortValue: (rev: RevisionRow) => rev.id
},
{
id: 'created',
label: 'Создана',
sortable: true,
sortValue: (rev: RevisionRow) => rev.created_at ?? ''
},
{
id: 'prefixes',
label: 'Префиксов',
sortable: true,
sortValue: (rev: RevisionRow) => rev.materialized_prefix_count ?? 0
},
{ id: 'hash', label: 'Хэш' },
{ id: 'actions', label: '', class: 'w-32' }
] as const;
</script>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">История ревизий</CardTitle>
<CardDescription class="max-w-[75ch]">
Создаются задачей module_refresh (CDN / IP / AS и т.д.); в превью есть полный текст BIRD с
инклюдами
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
class="shrink-0 self-start sm:self-auto"
onclick={onReload}
disabled={revLoading}
>
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
</Button>
</CardHeader>
<CardContent class="min-w-0 p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={revisions}
rowKey={(rev) => rev.id}
loading={revLoading}
emptyTitle="Нет ревизий"
emptyDescription="Ревизии появятся после обновления модулей."
>
{#snippet cell({ row: rev, column })}
{#if column.id === 'id'}
<span class="font-mono text-xs">{rev.id.slice(0, 8)}</span>
{:else if column.id === 'created'}
<span class="text-sm">{formatDateTime(rev.created_at)}</span>
{:else if column.id === 'prefixes'}
{rev.materialized_prefix_count}
{:else if column.id === 'hash'}
<span class="font-mono text-xs text-muted-foreground"
>{rev.content_hash.slice(0, 12)}</span
>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
<Eye class="size-3.5" />
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
<Undo class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Скачать диагностический лог"
onclick={() => onDownloadDiagnosticLog(rev)}
>
<Download class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
@@ -0,0 +1,42 @@
import type { ColumnDef } from '@tanstack/table-core';
import type { ReportRow } from './types.js';
export const reportRowColumns: ColumnDef<ReportRow>[] = [
{
accessorKey: 'label',
header: 'Метка',
cell: ({ row }) => {
const v = row.original.label;
return typeof v === 'string' ? v : '—';
}
},
{
accessorKey: 'prefixCount',
header: 'Префиксов',
cell: ({ row }) => String(row.original.prefixCount)
},
{
id: 'prefixes',
header: 'Префиксы',
cell: ({ row }) => {
const p = row.original.prefixes;
if (!p?.length) return '—';
return p.join(', ');
}
}
];
export type AsnReportRow = { asn: string; prefixCount: number };
export const asnReportColumns: ColumnDef<AsnReportRow>[] = [
{
accessorKey: 'asn',
header: 'ASN',
cell: ({ row }) => `AS${row.original.asn}`
},
{
accessorKey: 'prefixCount',
header: 'Префиксов',
cell: ({ row }) => String(row.original.prefixCount)
}
];
@@ -0,0 +1,107 @@
<script lang="ts">
import {
type ColumnDef,
type PaginationState,
type RowData,
getCoreRowModel,
getPaginationRowModel
} from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '@evobgp/ui/components/data-table/index.js';
import * as Table from '@evobgp/ui/components/table/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
type Props = {
rows: RowData[];
columns: ColumnDef<RowData, unknown>[];
emptyLabel: string;
};
let { rows, columns, emptyLabel }: Props = $props();
let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: 10 });
const table = createSvelteTable({
get data() {
return rows;
},
get columns() {
return columns;
},
state: {
get pagination() {
return pagination;
}
},
onPaginationChange: (updater) => {
pagination = typeof updater === 'function' ? updater(pagination) : updater;
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel()
});
</script>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head class="max-w-[min(28rem,40vw)]">
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell
class="max-w-[min(28rem,40vw)] align-top font-mono text-xs [overflow-wrap:anywhere] break-all whitespace-pre-wrap"
>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell
colspan={columns.length}
class="text-muted-foreground h-16 text-center text-sm"
>
{emptyLabel}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{#if table.getPageCount() > 1}
<div class="flex items-center justify-end gap-2 pt-2">
<p class="mr-auto text-xs text-muted-foreground">
Стр. {table.getState().pagination.pageIndex + 1} из {table.getPageCount()} ({rows.length} строк)
</p>
<Button
variant="outline"
size="sm"
onclick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Назад
</Button>
<Button
variant="outline"
size="sm"
onclick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Далее
</Button>
</div>
{/if}
@@ -0,0 +1,30 @@
import type { ModuleRow } from '$lib/api/types.js';
export type JobLogEntry = {
kind: string;
source: string;
community: string;
/** Человекочитаемое имя из справочника (title или BGP community). */
community_label?: string;
prefix_count: number;
sample?: string[];
message: string;
};
export type ReportRow = {
label: string;
source: string;
prefixes: string[];
prefixCount: number;
};
export type JobDetailedReport = {
revisionId: string | null;
module: ModuleRow | null;
aggregationTotal: number;
aggregationByKind: Array<{ kind: string; prefixCount: number }>;
domains: ReportRow[];
asn: Array<{ asn: string; prefixCount: number }>;
cdn: ReportRow[];
ipRanges: ReportRow[];
};
@@ -0,0 +1,120 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
import {
aggregateNetworkMetrics,
collectNetworkIssues,
deriveNetworkOverallStatus,
networkOverallStatusHint,
networkOverallStatusLabel
} from '$lib/network/network-metrics.js';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import CheckCircle from '@lucide/svelte/icons/check-circle';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
import XCircle from '@lucide/svelte/icons/x-circle';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import NetworkIcon from '@lucide/svelte/icons/network';
type Props = {
peers: PeerRow[];
speakers: SpeakerRow[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
};
let { peers, speakers, loading = false, initialLoading = false, error = null }: Props = $props();
const metrics = $derived(aggregateNetworkMetrics(peers, speakers));
const overallStatus = $derived(deriveNetworkOverallStatus(metrics));
const overallHint = $derived(networkOverallStatusHint(overallStatus, metrics));
const issues = $derived(collectNetworkIssues(peers, speakers, 3));
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="flex items-center gap-2 text-base">
<NetworkIcon class="size-4" />
Сеть (BGP)
</CardTitle>
<CardDescription>Live-статус пиров и спикеров</CardDescription>
</div>
<Button variant="outline" size="sm" href={resolve('/network?tab=overview')}>
Подробнее
<ArrowRight class="size-3.5" />
</Button>
</CardHeader>
<CardContent class="space-y-3 p-4 pt-4">
{#if error}
<p class="text-sm text-destructive">{error}</p>
{:else if initialLoading || loading}
<p class="text-sm text-muted-foreground">Загрузка live-метрик…</p>
{:else if overallStatus === 'ok'}
<Alert class="border-success/30 bg-success/5 py-3">
<CheckCircle class="text-success" />
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription class="text-xs">{overallHint}</AlertDescription>
</Alert>
{:else if overallStatus === 'warn'}
<Alert class="border-warning/30 bg-warning/5 py-3">
<AlertTriangle class="text-warning" />
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription class="text-xs">
{overallHint}
{#if issues.length > 0}
<ul class="mt-2 list-inside list-disc">
{#each issues as issue (issue.id)}
<li>{issue.message}</li>
{/each}
</ul>
{/if}
</AlertDescription>
</Alert>
{:else}
<Alert variant="destructive" class="py-3">
<XCircle />
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription class="text-xs">
{overallHint}
{#if issues.length > 0}
<ul class="mt-2 list-inside list-disc">
{#each issues as issue (issue.id)}
<li>{issue.message}</li>
{/each}
</ul>
{/if}
</AlertDescription>
</Alert>
{/if}
<div class="flex flex-wrap gap-4 text-sm">
<div>
<p class="text-muted-foreground">Пиры Established</p>
<p class="text-xl font-bold tabular-nums">
{initialLoading ? '—' : `${metrics.peersEstablished}/${metrics.peersEnabled}`}
</p>
</div>
<div>
<p class="text-muted-foreground">Спикеры online</p>
<p class="text-xl font-bold tabular-nums">
{initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`}
</p>
</div>
<div>
<p class="text-muted-foreground">Drift</p>
<p class="text-xl font-bold tabular-nums">{initialLoading ? '—' : metrics.speakersDrift}</p>
</div>
</div>
</CardContent>
</Card>
@@ -0,0 +1,94 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { JobRow } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
import { jobStatusRu, jobStatusBadgeVariant } from '$lib/ui-labels.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import ExternalLink from '@lucide/svelte/icons/external-link';
type Props = {
items: JobRow[];
moduleNameById: ReadonlyMap<string, string>;
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
};
let {
items,
moduleNameById,
loading = false,
initialLoading = false,
error = null
}: Props = $props();
const columns = [
{ id: 'kind', label: 'Вид', sortable: true, sortValue: (j: JobRow) => j.kind },
{
id: 'status',
label: 'Статус',
sortable: true,
sortValue: (j: JobRow) => j.status
},
{
id: 'created',
label: 'Создана',
sortable: true,
sortValue: (j: JobRow) => j.created_at ?? ''
},
{ id: 'actions', label: '', class: 'w-10' }
] as const;
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Последние задачи</CardTitle>
<CardDescription>Фоновые задачи ingest, refresh и apply</CardDescription>
</div>
<Button variant="outline" size="sm" href={resolve('/operations?tab=jobs')}>
Все
<ArrowRight class="size-3.5" />
</Button>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(j) => j.job_id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет задач"
emptyDescription="Задачи появятся после refresh или деплоя."
>
{#snippet cell({ row: j, column })}
{#if column.id === 'kind'}
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
{:else if column.id === 'status'}
<Badge variant={jobStatusBadgeVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
{:else if column.id === 'created'}
<span class="text-xs whitespace-nowrap text-muted-foreground">
{formatDateTime(j.created_at)}
</span>
{:else if column.id === 'actions'}
<Button variant="ghost" size="icon-sm" href={resolve('/operations?tab=jobs')}>
<ExternalLink class="size-3.5" />
</Button>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
@@ -0,0 +1,89 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { RevisionRow } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import ExternalLink from '@lucide/svelte/icons/external-link';
type Props = {
items: RevisionRow[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
};
let { items, loading = false, initialLoading = false, error = null }: Props = $props();
const columns = [
{
id: 'id',
label: 'ID',
sortable: true,
sortValue: (rev: RevisionRow) => rev.id
},
{
id: 'created',
label: 'Создана',
sortable: true,
sortValue: (rev: RevisionRow) => rev.created_at ?? ''
},
{
id: 'prefixes',
label: 'Префиксов',
sortable: true,
sortValue: (rev: RevisionRow) => rev.materialized_prefix_count ?? 0
},
{ id: 'actions', label: '', class: 'w-10' }
] as const;
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Последние ревизии</CardTitle>
<CardDescription>Снимки конфигурации BIRD после обновления модулей</CardDescription>
</div>
<Button variant="outline" size="sm" href={resolve('/operations')}>
Все
<ArrowRight class="size-3.5" />
</Button>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(rev) => rev.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет ревизий"
emptyDescription="Ревизии появятся после обновления модулей."
>
{#snippet cell({ row: rev, column })}
{#if column.id === 'id'}
<span class="font-mono text-xs">{rev.id.slice(0, 8)}</span>
{:else if column.id === 'created'}
<span class="text-sm whitespace-nowrap text-muted-foreground">
{formatDateTime(rev.created_at)}
</span>
{:else if column.id === 'prefixes'}
<span class="tabular-nums">{rev.materialized_prefix_count}</span>
{:else if column.id === 'actions'}
<Button variant="ghost" size="icon-sm" href={resolve('/operations')}>
<ExternalLink class="size-3.5" />
</Button>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
type Props = {
children: Snippet;
class?: string;
};
let { children, class: className }: Props = $props();
</script>
<div class={cn('flex flex-col gap-4 md:gap-6', className)}>
{@render children()}
</div>
@@ -0,0 +1,55 @@
<script lang="ts">
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@evobgp/ui/components/alert-dialog/index.js';
import { closeConfirm, confirmState } from './confirm-state.svelte.js';
const state = $derived(confirmState.current);
const open = $derived(!!state?.open);
function onOpenChange(v: boolean) {
if (!v && state && !state.loading) closeConfirm();
}
async function handleConfirm() {
if (!state || state.loading) return;
await state.onConfirm();
}
</script>
<AlertDialog {open} {onOpenChange}>
{#if state}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{state.title}</AlertDialogTitle>
{#if state.description}
<AlertDialogDescription>{state.description}</AlertDialogDescription>
{/if}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={state.loading} onclick={() => closeConfirm()}>
{state.cancelLabel ?? 'Отмена'}
</AlertDialogCancel>
<AlertDialogAction
class={state.destructive
? 'text-destructive-foreground bg-destructive hover:bg-destructive/90'
: ''}
disabled={state.loading}
onclick={(e) => {
e.preventDefault();
void handleConfirm();
}}
>
{state.loading ? '…' : (state.confirmLabel ?? 'Подтвердить')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
{/if}
</AlertDialog>
@@ -0,0 +1,42 @@
export type ConfirmOptions = {
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void | Promise<void>;
};
export type ConfirmState = ConfirmOptions & {
open: boolean;
loading: boolean;
};
export const confirmState = $state<{ current: ConfirmState | null }>({ current: null });
export function confirm(options: ConfirmOptions) {
return new Promise<boolean>((resolve) => {
confirmState.current = {
...options,
open: true,
loading: false,
onConfirm: async () => {
const current = confirmState.current;
if (!current || current.loading) return;
confirmState.current = { ...current, loading: true };
try {
await options.onConfirm();
resolve(true);
} catch {
resolve(false);
} finally {
confirmState.current = null;
}
}
};
});
}
export function closeConfirm() {
confirmState.current = null;
}
@@ -0,0 +1,189 @@
<script lang="ts" generics="T extends Record<string, unknown>">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
import * as Table from '@evobgp/ui/components/table/index.js';
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import EmptyState from '$lib/components/patterns/empty-state/empty-state.svelte';
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import { Button } from '@evobgp/ui/components/button/index.js';
import type { DataTableColumn } from './types.js';
type Props = {
columns: DataTableColumn<T>[];
rows: T[];
rowKey: (row: T) => string;
loading?: boolean;
error?: string | null;
emptyTitle?: string;
emptyDescription?: string;
/** Client-side page size; 0 = all rows */
pageSize?: number;
toolbar?: Snippet;
cell: Snippet<[{ row: T; column: DataTableColumn<T> }]>;
class?: string;
};
let {
columns,
rows,
rowKey,
loading = false,
error = null,
emptyTitle = 'Нет записей',
emptyDescription,
pageSize = 0,
toolbar,
cell,
class: className
}: Props = $props();
let sortColumnId = $state<string | null>(null);
let sortDir = $state<'asc' | 'desc'>('asc');
let pageIndex = $state(0);
const sortedRows = $derived.by(() => {
if (!sortColumnId) return rows;
const col = columns.find((c) => c.id === sortColumnId);
if (!col?.sortValue) return rows;
const getter = col.sortValue;
const copy = [...rows];
copy.sort((a, b) => {
const av = getter(a);
const bv = getter(b);
const as = av == null ? '' : String(av);
const bs = bv == null ? '' : String(bv);
const cmp = as.localeCompare(bs, 'ru', { numeric: true });
return sortDir === 'asc' ? cmp : -cmp;
});
return copy;
});
const paginatedRows = $derived.by(() => {
if (!pageSize || pageSize <= 0) return sortedRows;
const start = pageIndex * pageSize;
return sortedRows.slice(start, start + pageSize);
});
const pageCount = $derived(
pageSize > 0 ? Math.max(1, Math.ceil(sortedRows.length / pageSize)) : 1
);
$effect(() => {
rows;
pageIndex = 0;
});
function toggleSort(col: DataTableColumn<T>) {
if (!col.sortable) return;
if (sortColumnId === col.id) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortColumnId = col.id;
sortDir = 'asc';
}
}
const skeletonRows = 5;
</script>
<div class={cn('flex flex-col gap-3', className)}>
{#if toolbar}
<div class="flex flex-wrap items-center justify-between gap-2">{@render toolbar()}</div>
{/if}
{#if error}
<Alert variant="destructive">
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
{/if}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each columns as col (col.id)}
<Table.Head class={col.headerClass ?? col.class}>
{#if col.sortable}
<Button
variant="ghost"
size="sm"
class="-ms-2 h-8 gap-1 px-2 font-medium"
onclick={() => toggleSort(col)}
>
{col.label}
{#if sortColumnId === col.id}
{#if sortDir === 'asc'}
<ArrowUp class="size-3.5" />
{:else}
<ArrowDown class="size-3.5" />
{/if}
{:else}
<ArrowUpDown class="size-3.5 text-muted-foreground" />
{/if}
</Button>
{:else}
{col.label}
{/if}
</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
{#each Array(skeletonRows) as _, i (i)}
<Table.Row>
{#each columns as col (col.id)}
<Table.Cell class={col.class}>
<Skeleton class="h-5 w-full max-w-[12rem]" />
</Table.Cell>
{/each}
</Table.Row>
{/each}
{:else if sortedRows.length === 0}
<Table.Row>
<Table.Cell colspan={columns.length} class="p-0">
<EmptyState title={emptyTitle} description={emptyDescription} />
</Table.Cell>
</Table.Row>
{:else}
{#each paginatedRows as row (rowKey(row))}
<Table.Row>
{#each columns as col (col.id)}
<Table.Cell class={col.class}>
{@render cell({ row, column: col })}
</Table.Cell>
{/each}
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
{#if pageSize > 0 && sortedRows.length > pageSize}
<div class="flex items-center justify-between gap-2 text-sm text-muted-foreground">
<span>
{pageIndex * pageSize + 1}{Math.min((pageIndex + 1) * pageSize, sortedRows.length)} из
{sortedRows.length}
</span>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={pageIndex === 0}
onclick={() => (pageIndex -= 1)}>Назад</Button
>
<Button
variant="outline"
size="sm"
disabled={pageIndex >= pageCount - 1}
onclick={() => (pageIndex += 1)}>Вперёд</Button
>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,8 @@
export type DataTableColumn<T> = {
id: string;
label: string;
sortable?: boolean;
sortValue?: (row: T) => string | number | null | undefined;
class?: string;
headerClass?: string;
};
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Component, Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
type Props = {
title?: string;
description?: string;
icon?: Component;
action?: Snippet;
class?: string;
};
let { title = 'Нет данных', description, icon: Icon, action, class: className }: Props = $props();
</script>
<div
class={cn('flex flex-col items-center justify-center gap-2 px-4 py-12 text-center', className)}
>
{#if Icon}
<div class="mb-1 text-muted-foreground/60" aria-hidden="true">
<Icon class="size-10" />
</div>
{/if}
<p class="text-sm font-medium">{title}</p>
{#if description}
<p class="max-w-sm text-sm text-muted-foreground">{description}</p>
{/if}
{#if action}
<div class="mt-2">{@render action()}</div>
{/if}
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import { Card, CardContent, CardHeader } from '@evobgp/ui/components/card/index.js';
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
</script>
<Card>
<CardHeader class="gap-2">
<Skeleton class="h-5 w-40" />
<Skeleton class="h-4 w-64" />
</CardHeader>
<CardContent class="flex flex-col gap-3">
<Skeleton class="h-24 w-full" />
<Skeleton class="h-4 w-3/4" />
</CardContent>
</Card>
@@ -0,0 +1,27 @@
<script lang="ts">
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
import * as Table from '@evobgp/ui/components/table/index.js';
type Props = {
columns?: number;
rows?: number;
};
let { columns = 4, rows = 5 }: Props = $props();
</script>
<div class="rounded-md border">
<Table.Root>
<Table.Body>
{#each Array(rows) as _, ri (ri)}
<Table.Row>
{#each Array(columns) as _, ci (ci)}
<Table.Cell>
<Skeleton class="h-5 w-full max-w-[10rem]" />
</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Input } from '@evobgp/ui/components/input/index.js';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
type Props = ComponentProps<typeof Input> & {
value?: string | number;
error?: boolean;
};
let { class: className, value = $bindable(''), error = false, ...rest }: Props = $props();
</script>
<Input
class={cn(error && 'border-destructive aria-invalid:border-destructive', className)}
aria-invalid={error || undefined}
bind:value
{...rest}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Textarea } from '@evobgp/ui/components/textarea/index.js';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
type Props = ComponentProps<typeof Textarea> & {
error?: boolean;
};
let { class: className, error = false, ...rest }: Props = $props();
</script>
<Textarea
class={cn(error && 'border-destructive aria-invalid:border-destructive', className)}
aria-invalid={error || undefined}
{...rest}
/>
@@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
import { Label } from '@evobgp/ui/components/label/index.js';
type Props = {
label: string;
id: string;
description?: string;
error?: string | null;
required?: boolean;
class?: string;
children: Snippet;
};
let {
label,
id,
description,
error,
required = false,
class: className,
children
}: Props = $props();
</script>
<div class={cn('flex flex-col gap-1.5', className)}>
<Label for={id}>
{label}
{#if required}<span class="text-destructive" aria-hidden="true"> *</span>{/if}
</Label>
{@render children()}
{#if error}
<p class="text-xs text-destructive" role="alert">{error}</p>
{:else if description}
<p class="text-xs text-muted-foreground">{description}</p>
{/if}
</div>
@@ -0,0 +1,112 @@
<script lang="ts">
import type { Component } from 'svelte';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import CardSkeleton from '$lib/components/patterns/feedback/card-skeleton.svelte';
import { cn } from '$lib/utils.js';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
export type KpiAccent = {
border: string;
bg: string;
iconBg: string;
iconText: string;
};
export type KpiCardItem = {
id: string;
label: string;
value: string;
description: string;
icon: Component;
accent: KpiAccent;
badge: string;
badgeClass?: string;
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
valueClass?: string;
error?: string | null;
href?: string;
};
type Props = {
cards: KpiCardItem[];
loading?: boolean;
skeletonCount?: number;
class?: string;
};
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
</script>
<div class={cn('grid auto-rows-fr gap-4', className)}>
{#if loading}
{#each Array(skeletonCount) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each cards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'flex h-full flex-col overflow-hidden border-l-4 shadow-sm',
card.href ? 'transition-colors hover:border-primary/35' : '',
a.border,
a.bg
)}
>
<CardHeader class="pb-2">
{#if card.href}
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
<Button variant="ghost" size="icon-sm" href={card.href}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
</div>
{:else}
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
{/if}
<CardTitle class={cn('font-bold tabular-nums', card.valueClass ?? 'text-3xl')}
>{card.value}</CardTitle
>
</CardHeader>
<CardContent class="mt-auto space-y-2">
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
>{card.badge}</Badge
>
{#if card.error}
<p class="text-xs text-destructive">{card.error}</p>
{:else}
<p class="text-xs text-muted-foreground">{card.description}</p>
{/if}
</CardContent>
</Card>
{/each}
{/if}
</div>
@@ -0,0 +1,38 @@
<script lang="ts">
type Props = {
items: readonly string[];
rowHeight?: number;
viewportHeight?: number;
class?: string;
};
let { items, rowHeight = 20, viewportHeight = 320, class: className = '' }: Props = $props();
let scrollTop = $state(0);
const totalHeight = $derived(items.length * rowHeight);
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / rowHeight) - 2));
const visibleCount = $derived(Math.ceil(viewportHeight / rowHeight) + 4);
const visibleItems = $derived(items.slice(startIndex, startIndex + visibleCount));
</script>
<div
class="overflow-auto rounded-md border {className}"
style="height: {viewportHeight}px"
onscroll={(e) => {
scrollTop = e.currentTarget.scrollTop;
}}
>
<div style="height: {totalHeight}px; position: relative">
{#each visibleItems as pfx, i (`${startIndex + i}-${pfx}`)}
<p
class="truncate px-2 font-mono text-xs leading-5"
style="position: absolute; top: {(startIndex + i) *
rowHeight}px; left: 0; right: 0; height: {rowHeight}px"
>
{pfx}
</p>
{:else}
<p class="text-muted-foreground p-2 text-sm">Нет префиксов</p>
{/each}
</div>
</div>
@@ -0,0 +1,69 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import AlertCircle from '@lucide/svelte/icons/alert-circle';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
import EmptyState from '$lib/components/empty-state.svelte';
type Props = {
data: T | undefined;
isLoading: boolean;
isError: boolean;
error?: unknown;
empty?: boolean;
emptyTitle?: string;
emptyDescription?: string;
emptyAction?: Snippet;
onRetry?: () => void;
skeleton?: Snippet;
children: Snippet<[T]>;
};
let {
data,
isLoading,
isError,
error,
empty = false,
emptyTitle = 'Нет данных',
emptyDescription,
emptyAction,
onRetry,
skeleton,
children
}: Props = $props();
const errorMessage = $derived(
error instanceof Error ? error.message : 'Не удалось загрузить данные'
);
</script>
{#if isLoading}
{#if skeleton}
{@render skeleton()}
{:else}
<div class="flex flex-col gap-3">
<Skeleton class="h-8 w-48" />
<Skeleton class="h-32 w-full" />
</div>
{/if}
{:else if isError}
<EmptyState
title="Ошибка загрузки"
description={errorMessage}
icon={AlertCircle}
action={onRetry ? retryAction : undefined}
/>
{:else if empty || data == null}
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
{:else}
{@render children(data)}
{/if}
{#snippet retryAction()}
<Button variant="outline" size="sm" onclick={onRetry}>
<RefreshCw class="size-4" />
Повторить
</Button>
{/snippet}
@@ -0,0 +1,26 @@
<script lang="ts">
import { Card, CardContent } from '@evobgp/ui/components/card/index.js';
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
import { cn } from '$lib/utils.js';
type Props = {
count?: number;
class?: string;
};
let { count = 4, class: className }: Props = $props();
</script>
<div class={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-3', className)}>
{#each Array.from({ length: count }) as _, i (i)}
<Card>
<CardContent class="flex items-start gap-2.5 px-3 py-2.5">
<Skeleton class="size-7 shrink-0 rounded-md" />
<div class="flex flex-1 flex-col gap-2">
<Skeleton class="h-3 w-20" />
<Skeleton class="h-6 w-16" />
</div>
</CardContent>
</Card>
{/each}
</div>
@@ -0,0 +1,111 @@
<script lang="ts">
import type { Component, Snippet } from 'svelte';
import { Card, CardContent } from '@evobgp/ui/components/card/index.js';
import { cn } from '$lib/utils.js';
export type SectionCardItem = {
label: string | Snippet;
value: string | number | Snippet;
hint?: string | Snippet;
icon?: Component;
badge?: Snippet;
variant?: 'default' | 'warning' | 'destructive';
active?: boolean;
onClick?: () => void;
};
type Props = {
items: SectionCardItem[];
class?: string;
};
let { items, class: className }: Props = $props();
const variantClass: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'border-warning/50',
destructive: 'border-destructive/50'
};
const valueVariantClass: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'text-warning',
destructive: 'text-destructive'
};
function sectionGridClass(count: number): string {
if (count <= 1) return 'grid-cols-1';
if (count === 2) return 'sm:grid-cols-2';
if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3';
if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4';
if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5';
if (count === 6) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6';
return 'sm:grid-cols-2 lg:grid-cols-3';
}
</script>
<div class={cn('grid gap-3', sectionGridClass(items.length), className)}>
{#each items as item, idx (typeof item.label === 'string' ? item.label : idx)}
{@const clickable = Boolean(item.onClick)}
<Card
class={cn(
'gap-0',
variantClass[item.variant ?? 'default'],
item.active && 'border-primary ring-1 ring-primary/30',
clickable && 'cursor-pointer transition-colors hover:bg-muted/40'
)}
onclick={item.onClick}
role={clickable ? 'button' : undefined}
tabindex={clickable ? 0 : undefined}
onkeydown={(e) => {
if (clickable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
item.onClick?.();
}
}}
>
<CardContent class="flex items-start gap-2.5 px-3 py-2.5">
{#if item.icon}
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground"
>
<item.icon class="size-4" />
</span>
{/if}
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
{#if typeof item.label === 'string'}
<span class="truncate text-xs text-muted-foreground">{item.label}</span>
{:else}
<span class="truncate text-xs text-muted-foreground">{@render item.label()}</span>
{/if}
{#if item.badge}
<span class="shrink-0">{@render item.badge()}</span>
{/if}
</div>
<div class="flex min-w-0 items-baseline gap-1.5">
<span
class={cn(
'flex items-center gap-1 text-lg font-semibold tabular-nums',
valueVariantClass[item.variant ?? 'default']
)}
>
{#if typeof item.value === 'string' || typeof item.value === 'number'}
{item.value}
{:else}
{@render item.value()}
{/if}
</span>
{#if item.hint}
{#if typeof item.hint === 'string'}
<span class="truncate text-xs text-muted-foreground">· {item.hint}</span>
{:else}
<span class="truncate text-xs text-muted-foreground">· {@render item.hint()}</span>
{/if}
{/if}
</div>
</div>
</CardContent>
</Card>
{/each}
</div>
@@ -0,0 +1,30 @@
<script lang="ts">
import { Badge } from '@evobgp/ui/components/badge/index.js';
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'default',
ok: 'default',
enabled: 'default',
paused: 'secondary',
disabled: 'secondary',
error: 'destructive',
failed: 'destructive',
running: 'outline',
warning: 'outline',
stale: 'outline'
};
type Props = {
status: string;
label?: string;
};
let { status, label }: Props = $props();
const variant = $derived(STATUS_VARIANT[status] ?? 'outline');
const text = $derived(label ?? status);
</script>
<Badge {variant}>{text}</Badge>
@@ -0,0 +1,27 @@
<script lang="ts">
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
import * as Table from '@evobgp/ui/components/table/index.js';
type Props = {
columns?: number;
rows?: number;
};
let { columns = 4, rows = 5 }: Props = $props();
</script>
<div class="rounded-md border">
<Table.Root>
<Table.Body>
{#each Array(rows) as _, ri (ri)}
<Table.Row>
{#each Array(columns) as _, ci (ci)}
<Table.Cell>
<Skeleton class="h-5 w-full max-w-[10rem]" />
</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</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 '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import EmptyState from '$lib/components/patterns/empty-state/empty-state.svelte';
import { confirm } from '$lib/components/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>
@@ -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 '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import FormField from '$lib/components/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>BIRD 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-конфигурации (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 ? 'Сохранение…' : 'Применить параметры BIRD'}
</Button>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,277 @@
<script lang="ts">
import { onMount } from 'svelte';
import { defaults, superForm } from 'sveltekit-superforms';
import { zod4 } from 'sveltekit-superforms/adapters';
import type { AuthSession } from '$lib/api/types.js';
import { apiJSON } from '$lib/api/client.js';
import {
emptyRevisionSettingsForm,
revisionSettingsSchema
} from '$lib/settings/revision-settings.schema.js';
import {
fetchRevisionPruneEstimate,
pruneRevisionsNow,
type RevisionPruneEstimate
} from '$lib/settings/revision-prune-api.js';
import {
buildPayloadFromFormFields,
loadSettings,
partitionSettings,
patchSettings
} from '$lib/settings/settings-api.js';
import { REVISION_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
import { formatBytes } from '$lib/monitoring/postgres.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Save from '@lucide/svelte/icons/save';
import Trash2 from '@lucide/svelte/icons/trash-2';
let loading = $state(false);
let saving = $state(false);
let pruning = $state(false);
let loaded = $state(false);
let session = $state<AuthSession | null>(null);
let estimateLoading = $state(false);
let estimate = $state<RevisionPruneEstimate | null>(null);
const { form, errors, reset, validateForm } = superForm(
defaults(emptyRevisionSettingsForm(), zod4(revisionSettingsSchema)),
{
validators: zod4(revisionSettingsSchema),
SPA: true,
dataType: 'json'
}
);
const isOperator = $derived(session?.role === 'operator');
const hasValidationErrors = $derived(Boolean($errors.revision_retention_minutes?.length));
const parsedRetentionMinutes = $derived.by(() => {
const s = String($form.revision_retention_minutes ?? '').trim();
if (s === '' || !/^\d+$/.test(s)) return null;
const n = Number(s);
if (!Number.isInteger(n) || n < 15 || n > 43200) return null;
return n;
});
const canSave = $derived.by(() => {
if (loading || saving || hasValidationErrors || !loaded) return false;
return String($form.revision_retention_minutes ?? '').trim() !== '';
});
const canPruneNow = $derived.by(() => {
if (!isOperator || !loaded || pruning || saving || parsedRetentionMinutes === null)
return false;
return (estimate?.revision_count ?? 0) > 0;
});
async function loadSession() {
try {
session = await apiJSON<AuthSession>('/v1/auth/session');
} catch {
session = null;
}
}
async function refreshEstimate(minutes: number) {
estimateLoading = true;
try {
estimate = await fetchRevisionPruneEstimate(minutes);
} catch (e) {
estimate = null;
notifyApiError(e, 'Не удалось рассчитать оценку очистки');
} finally {
estimateLoading = false;
}
}
$effect(() => {
const minutes = parsedRetentionMinutes;
if (!loaded || minutes === null) {
estimate = null;
return;
}
const handle = setTimeout(() => {
void refreshEstimate(minutes);
}, 400);
return () => clearTimeout(handle);
});
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;
}
}
async function requestPruneNow() {
const minutes = parsedRetentionMinutes;
if (minutes === null) return;
let est: RevisionPruneEstimate;
try {
est = await fetchRevisionPruneEstimate(minutes);
} catch (e) {
notifyApiError(e);
return;
}
estimate = est;
if (est.revision_count === 0) {
notify.info('Нет ревизий для удаления по выбранному retention');
return;
}
void confirm({
title: 'Очистить старые ревизии?',
description: `Будет удалено ${est.revision_count} ревизий. Ориентировочно освободится ~${formatBytes(est.bytes_estimate)}. Действие необратимо.`,
confirmLabel: 'Очистить',
destructive: true,
onConfirm: async () => {
pruning = true;
try {
const res = await pruneRevisionsNow(minutes);
notify.success(
`Удалено ревизий: ${res.deleted_revisions}, освобождено ~${formatBytes(res.bytes_estimate)}`
);
await refreshEstimate(minutes);
} catch (e) {
notifyApiError(e);
throw e;
} finally {
pruning = false;
}
}
});
}
onMount(() => {
void loadSession();
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 parsedRetentionMinutes !== null}
<div class="space-y-1 rounded-md border bg-muted/30 p-4 text-sm">
<p class="font-medium">Оценка очистки по введённому retention</p>
{#if estimateLoading}
<p class="text-muted-foreground">Расчёт…</p>
{:else if estimate}
<p>
Будет удалено ревизий: <strong>{estimate.revision_count}</strong>
</p>
<p>
Освободится ориентировочно: <strong>~{formatBytes(estimate.bytes_estimate)}</strong>
</p>
{#if estimate.prefix_row_count > 0}
<p class="text-xs text-muted-foreground">
Строк префиксов в снимках: {estimate.prefix_row_count}
{#if estimate.orphan_snapshot_count > 0}
· снимков: {estimate.orphan_snapshot_count}
{/if}
</p>
{/if}
{:else}
<p class="text-muted-foreground">Оценка недоступна</p>
{/if}
<p class="pt-1 text-xs text-muted-foreground">
Учитываются те же правила, что при автоочистке: последняя ревизия и раскатанные на
спикерах не удаляются.
</p>
</div>
{/if}
{#if hasValidationErrors}
<p class="text-sm text-destructive">
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
</p>
{/if}
<div class="flex flex-wrap gap-2">
<Button onclick={save} disabled={!canSave}>
<Save />
{saving ? 'Сохранение…' : 'Применить параметры ревизий'}
</Button>
{#if isOperator}
<Button variant="destructive" disabled={!canPruneNow} onclick={requestPruneNow}>
<Trash2 />
{pruning ? 'Очистка…' : 'Очистить сейчас'}
</Button>
{/if}
</div>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,345 @@
<script lang="ts">
import { onMount } from 'svelte';
import { defaults, superForm } from 'sveltekit-superforms';
import { zod4 } from 'sveltekit-superforms/adapters';
import type { AuthSession } from '$lib/api/types.js';
import { apiJSON } from '$lib/api/client.js';
import {
emptyRuntimeLogsSettingsForm,
runtimeLogsSettingsSchema
} from '$lib/settings/runtime-logs-settings.schema.js';
import {
buildPayloadFromFormFields,
loadSettings,
partitionSettings,
patchSettings
} from '$lib/settings/settings-api.js';
import { RUNTIME_LOGS_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
import {
fetchRuntimeLogAutoEstimate,
runRuntimeLogAutoCleanup,
type RuntimeLogAutoEstimate
} from '$lib/runtime-logs/runtime-logs-auto-api.js';
import { isRuntimeLogsUnavailable } from '$lib/runtime-logs/runtime-logs-api.js';
import { formatBytes } from '$lib/monitoring/postgres.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Switch } from '@evobgp/ui/components/switch/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Save from '@lucide/svelte/icons/save';
import Play from '@lucide/svelte/icons/play';
let loading = $state(false);
let saving = $state(false);
let running = $state(false);
let loaded = $state(false);
let session = $state<AuthSession | null>(null);
let estimateLoading = $state(false);
let estimate = $state<RuntimeLogAutoEstimate | null>(null);
let fsUnavailable = $state(false);
const { form, errors, reset, validateForm } = superForm(
defaults(emptyRuntimeLogsSettingsForm(), zod4(runtimeLogsSettingsSchema)),
{
validators: zod4(runtimeLogsSettingsSchema),
SPA: true,
dataType: 'json'
}
);
const isOperator = $derived(session?.role === 'operator');
const autoEnabled = $derived($form.runtime_logs_auto_enabled === 'true');
const hasValidationErrors = $derived(
Boolean(
$errors.runtime_logs_max_file_mb?.length ||
$errors.runtime_logs_auto_schedule?.length ||
$errors.runtime_logs_auto_mode?.length
)
);
const parsedMaxMb = $derived.by(() => {
const s = String($form.runtime_logs_max_file_mb ?? '').trim();
if (s === '' || !/^\d+$/.test(s)) return null;
const n = Number(s);
if (!Number.isInteger(n) || n < 1 || n > 512) return null;
return n;
});
const canSave = $derived.by(() => {
if (loading || saving || hasValidationErrors || !loaded || !isOperator) return false;
return true;
});
const canRunNow = $derived.by(() => {
if (!isOperator || !loaded || running || saving || parsedMaxMb === null || fsUnavailable)
return false;
return (estimate?.would_count ?? 0) > 0;
});
async function loadSession() {
try {
session = await apiJSON<AuthSession>('/v1/auth/session');
} catch {
session = null;
}
}
async function refreshEstimate() {
if (fsUnavailable || parsedMaxMb === null) {
estimate = null;
return;
}
estimateLoading = true;
try {
estimate = await fetchRuntimeLogAutoEstimate();
fsUnavailable = false;
} catch (e) {
estimate = null;
if (isRuntimeLogsUnavailable(e)) {
fsUnavailable = true;
return;
}
notifyApiError(e, 'Оценка автоочистки');
} finally {
estimateLoading = false;
}
}
$effect(() => {
if (!loaded || parsedMaxMb === null) {
estimate = null;
return;
}
const handle = setTimeout(() => {
void refreshEstimate();
}, 400);
return () => clearTimeout(handle);
});
async function load() {
loading = true;
try {
const settings = await loadSettings();
const { partitioned } = partitionSettings(settings);
reset({ data: partitioned.runtimeLogs });
loaded = true;
} catch (e) {
notifyApiError(e);
} finally {
loading = false;
}
}
async function save() {
const validation = await validateForm({ update: true });
if (!validation.valid) {
notify.error('Исправьте ошибки в полях настроек');
return;
}
const payload = buildPayloadFromFormFields(
RUNTIME_LOGS_SETTING_KEYS,
$form as Record<string, string>,
$errors as Partial<Record<string, string[]>>
);
if ($form.runtime_logs_auto_mode === 'truncate' || $form.runtime_logs_auto_mode === 'delete') {
payload.runtime_logs_auto_mode = $form.runtime_logs_auto_mode;
}
saving = true;
try {
await patchSettings(payload);
notify.success('Параметры автоочистки логов сохранены');
await load();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
async function requestRunNow() {
if (parsedMaxMb === null) return;
let est: RuntimeLogAutoEstimate;
try {
est = await fetchRuntimeLogAutoEstimate();
} catch (e) {
notifyApiError(e);
return;
}
estimate = est;
const count = est.would_count ?? 0;
if (count === 0) {
notify.info('Нет файлов выше порога для очистки');
return;
}
const mode = $form.runtime_logs_auto_mode === 'delete' ? 'удаление' : 'truncate';
void confirm({
title: 'Запустить автоочистку сейчас?',
description: `Будет затронуто файлов: ${count}. Режим: ${mode}. Записи появятся в Monitoring → Файловые логи → Audit.`,
confirmLabel: 'Запустить',
destructive: true,
onConfirm: async () => {
running = true;
try {
const res = await runRuntimeLogAutoCleanup(false);
notify.success(`Очищено файлов: ${res.cleaned_count ?? 0}`);
await refreshEstimate();
} catch (e) {
notifyApiError(e);
throw e;
} finally {
running = false;
}
}
});
}
onMount(() => {
void loadSession();
void load();
});
</script>
<Card>
<CardHeader>
<CardTitle>Файловые логи (runtime-logs)</CardTitle>
<CardDescription>
Автоочистка <code class="text-xs">*.log</code> на диске evobgp-all (sidecar
stack-runtime-logs). Требуется volume <code class="text-xs">EVOBGP_RUNTIME_LOGS_DIR</code>.
</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="runtime-logs-auto-enabled"
label="Автоочистка по расписанию"
description="Scheduler в evobgp-all (UTC cron ниже)."
>
<Switch
id="runtime-logs-auto-enabled"
checked={autoEnabled}
disabled={!isOperator}
onCheckedChange={(v) => {
$form.runtime_logs_auto_enabled = v ? 'true' : 'false';
}}
/>
</FormField>
<FormField
id="runtime-logs-max-mb"
label="Порог размера файла, MiB"
error={$errors.runtime_logs_max_file_mb?.[0]}
description="Очищать файлы строго больше порога (1–512 MiB)."
>
<Input
id="runtime-logs-max-mb"
type="number"
min="1"
max="512"
bind:value={$form.runtime_logs_max_file_mb}
disabled={!isOperator}
/>
</FormField>
<FormField
id="runtime-logs-schedule"
label="Расписание (UTC cron)"
error={$errors.runtime_logs_auto_schedule?.[0]}
description="5 полей: минута час день месяц день_недели. По умолчанию каждые 6 часов."
>
<Input
id="runtime-logs-schedule"
bind:value={$form.runtime_logs_auto_schedule}
placeholder="0 */6 * * *"
disabled={!isOperator}
/>
</FormField>
<FormField id="runtime-logs-mode" label="Режим очистки">
<Select
type="single"
value={$form.runtime_logs_auto_mode || 'truncate'}
disabled={!isOperator}
onValueChange={(v) => {
if (v === 'truncate' || v === 'delete') $form.runtime_logs_auto_mode = v;
}}
>
<SelectTrigger id="runtime-logs-mode" class="w-full max-w-xs">
{$form.runtime_logs_auto_mode === 'delete'
? 'delete — удалить файл'
: 'truncate — обнулить'}
</SelectTrigger>
<SelectContent>
<SelectItem value="truncate">truncate — обнулить</SelectItem>
<SelectItem value="delete">delete — удалить файл</SelectItem>
</SelectContent>
</Select>
</FormField>
{#if fsUnavailable}
<p class="text-sm text-muted-foreground">
FS API недоступен (не evobgp-all или нет volume). Оценка и «Запустить сейчас» недоступны;
настройки сохраняются для будущего прогона scheduler.
</p>
{:else if parsedMaxMb !== null}
<div class="space-y-1 rounded-md border bg-muted/30 p-4 text-sm">
<p class="font-medium">Оценка по текущему порогу</p>
{#if estimateLoading}
<p class="text-muted-foreground">Расчёт…</p>
{:else if estimate}
<p>
Файлов к очистке: <strong>{estimate.would_count ?? 0}</strong>
</p>
{#if estimate.items?.length}
<ul class="mt-2 space-y-1 text-xs text-muted-foreground">
{#each estimate.items.filter((i) => i.would_cleanup) as item (item.filename)}
<li class="font-mono">
{item.filename} · {formatBytes(item.size_bytes)}
</li>
{/each}
</ul>
{/if}
{:else}
<p class="text-muted-foreground">Оценка недоступна</p>
{/if}
</div>
{/if}
{#if hasValidationErrors}
<p class="text-sm text-destructive">Исправьте ошибки в полях перед сохранением.</p>
{/if}
<div class="flex flex-wrap gap-2">
{#if isOperator}
<Button onclick={save} disabled={!canSave}>
<Save />
{saving ? 'Сохранение…' : 'Сохранить'}
</Button>
<Button variant="destructive" disabled={!canRunNow} onclick={requestRunNow}>
<Play />
{running ? 'Запуск…' : 'Запустить сейчас'}
</Button>
{/if}
</div>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,91 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import PageShell from '$lib/components/page-shell.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/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 TenantRuntimeLogsSettingsCard from '$lib/components/tenant-settings/TenantRuntimeLogsSettingsCard.svelte';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import Info from '@lucide/svelte/icons/info';
type TenantSettingsTab = 'bird' | 'revision' | 'runtime-logs' | 'additional';
function parseTenantSettingsTab(value: string | null): TenantSettingsTab {
if (value === 'revision' || value === 'runtime-logs' || 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>
<PageShell>
<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="[scrollbar-gutter:stable] overflow-x-auto pb-1">
<TabsList class="inline-flex min-w-max">
<TabsTrigger value="bird">BIRD</TabsTrigger>
<TabsTrigger value="revision">Ревизии</TabsTrigger>
<TabsTrigger value="runtime-logs">Файловые логи</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="runtime-logs" class="mt-4">
<TenantRuntimeLogsSettingsCard />
</TabsContent>
<TabsContent value="additional" class="mt-4">
<TenantAdditionalSettingsCard />
</TabsContent>
</Tabs>
</PageShell>