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
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:
@@ -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 (1–4294967295)');
|
||||
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">часа (0–59)</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>
|
||||
Reference in New Issue
Block a user