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

Refactored the project structure to support a monorepo setup, moving the web application to `apps/web/` and updating related configurations. Adjusted pre-commit hooks to use `pnpm` for linting and formatting. Updated CI workflows to reflect the new directory structure and dependencies. Removed legacy files and configurations from the previous `web/` directory, streamlining the project for better maintainability and clarity.
This commit is contained in:
Denozordec
2026-06-30 23:54:28 +07:00
parent db75126bea
commit a37c931ee7
377 changed files with 8537 additions and 1093 deletions
+23
View File
@@ -0,0 +1,23 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+9
View File
@@ -0,0 +1,9 @@
# Package Managers
package-lock.json
pnpm-lock.yaml
yarn.lock
bun.lock
bun.lockb
# Miscellaneous
/static/
+16
View File
@@ -0,0 +1,16 @@
{
"useTabs": true,
"singleQuote": true,
"trailingComma": "none",
"printWidth": 100,
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/routes/layout.css",
"overrides": [
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode", "bradlc.vscode-tailwindcss", "esbenp.prettier-vscode"]
}
+5
View File
@@ -0,0 +1,5 @@
{
"files.associations": {
"*.css": "tailwindcss"
}
}
+40
View File
@@ -0,0 +1,40 @@
# EvoBGP WebUI (`@evobgp/web`)
SvelteKit-панель управления EvoBGP. Monorepo: `apps/web` + `packages/ui` + `packages/shared`.
Правила: [.cursor/rules/web-shadcn.mdc](../../.cursor/rules/web-shadcn.mdc), [frontend-monorepo.mdc](../../.cursor/rules/frontend-monorepo.mdc).
## Структура
| Путь | Назначение |
| ------------------------------ | ------------------------------------------- |
| `packages/ui/src/components/` | shadcn-svelte примитивы (`@evobgp/ui`) |
| `src/lib/components/` | PageShell, QueryState, SectionCards, domain |
| `src/lib/components/patterns/` | AppDataTable, FormField, ConfirmDialog |
| `src/lib/ui/app/` | PageHeader, nav, toast |
| `src/lib/queries/` | TanStack Query factories |
| `packages/shared/` | API types + Zod contracts |
## Разработка
Из корня репозитория:
```powershell
pnpm install
pnpm --filter @evobgp/web dev
pnpm --filter @evobgp/web check
pnpm --filter @evobgp/web lint
pnpm --filter @evobgp/web build
```
Добавление shadcn-svelte (из `apps/web/`):
```powershell
pnpm dlx shadcn-svelte@latest add <component> -y -o
```
Тема: `packages/ui/src/styles/globals.css` → импорт в `src/routes/layout.css`.
## Проверка перед PR
`pnpm --filter @evobgp/web check` и `lint` — exit 0 (WEB-19).
+16
View File
@@ -0,0 +1,16 @@
{
"$schema": "https://www.shadcn-svelte.com/schema.json",
"tailwind": {
"css": "../../packages/ui/src/styles/globals.css",
"baseColor": "neutral"
},
"aliases": {
"components": "$lib/components",
"utils": "$lib/utils",
"ui": "@evobgp/ui/components",
"hooks": "$lib/hooks",
"lib": "$lib"
},
"typescript": true,
"iconLibrary": "lucide"
}
+3158
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "@evobgp/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"lint": "prettier --check .",
"format": "prettier --write ."
},
"devDependencies": {
"@internationalized/date": "^3.12.0",
"@lucide/svelte": "^1.16.0",
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.50.2",
"@sveltejs/vite-plugin-svelte": "^6.2.4",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^22.15.0",
"formsnap": "^2.0.1",
"prettier": "^3.8.1",
"prettier-plugin-svelte": "^3.4.1",
"prettier-plugin-tailwindcss": "^0.7.2",
"svelte": "^5.54.0",
"svelte-check": "^4.4.2",
"sveltekit-superforms": "^2.30.1",
"tailwindcss": "^4.1.18",
"typescript": "^5.9.3",
"vite": "^7.3.1"
},
"dependencies": {
"@evobgp/shared": "workspace:*",
"@evobgp/ui": "workspace:*",
"@tanstack/svelte-query": "^5.90.2",
"@tanstack/table-core": "^8.21.3",
"bits-ui": "^2.17.2",
"clsx": "^2.1.1",
"svelte-sonner": "^1.1.0",
"tailwind-merge": "^3.5.0",
"tailwind-variants": "^3.2.2",
"tw-animate-css": "^1.4.0",
"zod": "^4.4.3"
}
}
+13
View File
@@ -0,0 +1,13 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
declare global {
namespace App {
// interface Error {}
// interface Locals {}
// interface PageData {}
// interface PageState {}
// interface Platform {}
}
}
export {};
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="text-scale" content="scale" />
<script>
(function () {
try {
var k = 'evobgp-theme';
var t = localStorage.getItem(k);
var dark =
t === 'dark' ||
(t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) document.documentElement.classList.add('dark');
} catch (e) {}
})();
</script>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover">
<div style="display: contents">%sveltekit.body%</div>
</body>
</html>
+185
View File
@@ -0,0 +1,185 @@
import { browser } from '$app/environment';
import type {
AsEntriesResponse,
CdnSourcesResponse,
DomainEntriesResponse,
IpRangeEntriesResponse,
JobRow,
ModuleType,
RevisionPrefix,
RevisionPrefixesResponse
} from './types.js';
export const TOKEN_STORAGE_KEY = 'evobgp_api_token';
export type Problem = {
type?: string;
title?: string;
status?: number;
detail?: string;
};
function getToken(): string | null {
if (!browser) return null;
return localStorage.getItem(TOKEN_STORAGE_KEY);
}
function mergeHeaders(init?: RequestInit, extraHeaders?: Record<string, string>): Headers {
const h = new Headers(init?.headers);
if (!h.has('Accept')) h.set('Accept', 'application/json');
const t = getToken();
if (t && !h.has('Authorization')) h.set('Authorization', `Bearer ${t}`);
if (extraHeaders) {
for (const [k, v] of Object.entries(extraHeaders)) {
if (!h.has(k)) h.set(k, v);
}
}
return h;
}
/**
* Idempotency keys: `crypto.randomUUID()` exists only in secure contexts (HTTPS / localhost).
* Over plain HTTP to a LAN IP it is often undefined — use getRandomValues or a fallback.
*/
function newIdempotencyKey(): string {
const c = typeof globalThis !== 'undefined' ? globalThis.crypto : undefined;
if (c?.randomUUID) return c.randomUUID();
if (c?.getRandomValues) {
const buf = new Uint8Array(16);
c.getRandomValues(buf);
buf[6] = (buf[6]! & 0x0f) | 0x40;
buf[8] = (buf[8]! & 0x3f) | 0x80;
const hex = [...buf].map((b) => b.toString(16).padStart(2, '0')).join('');
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
return `idem-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 14)}`;
}
export class ApiError extends Error {
constructor(
public readonly status: number,
message: string,
public readonly problem?: Problem
) {
super(message);
}
}
export async function apiFetch(path: string, init?: RequestInit): Promise<Response> {
if (!browser) throw new Error('API is only available in the browser');
return fetch(path, { ...init, headers: mergeHeaders(init) });
}
/** GET / DELETE без тела */
export async function apiJSON<T>(path: string, init?: RequestInit): Promise<T> {
const res = await apiFetch(path, init);
return parseResponse<T>(res);
}
/** POST / PATCH / PUT с JSON-телом и автоматическим Idempotency-Key */
export async function apiMutate<T = void>(
path: string,
method: 'POST' | 'PATCH' | 'PUT' | 'DELETE',
body?: unknown,
opts?: { idempotent?: boolean }
): Promise<T> {
const headers: Record<string, string> = {};
if (body !== undefined) headers['Content-Type'] = 'application/json';
if (opts?.idempotent !== false) {
headers['Idempotency-Key'] = newIdempotencyKey();
}
const res = await fetch(path, {
method,
headers: mergeHeaders({ headers }, headers),
body: body !== undefined ? JSON.stringify(body) : undefined
});
return parseResponse<T>(res);
}
async function parseResponse<T>(res: Response): Promise<T> {
if (res.status === 204 || res.status === 205) return undefined as T;
const text = await res.text();
if (!res.ok) {
let problem: Problem | undefined;
let detail = `HTTP ${res.status}`;
try {
problem = JSON.parse(text) as Problem;
detail = problem.detail ?? problem.title ?? detail;
} catch {
if (text) detail = text;
}
throw new ApiError(res.status, detail, problem);
}
if (!text) return undefined as T;
return JSON.parse(text) as T;
}
const terminalJobStatuses = new Set(['succeeded', 'failed', 'cancelled']);
/** Ожидает завершения фоновой задачи (poll GET /v1/jobs/{id}). */
export async function waitForJob(
jobId: string,
opts?: { pollMs?: number; timeoutMs?: number }
): Promise<JobRow> {
const pollMs = opts?.pollMs ?? 400;
const timeoutMs = opts?.timeoutMs ?? 120000;
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const j = await apiJSON<JobRow>(`/v1/jobs/${jobId}`);
if (terminalJobStatuses.has(j.status)) return j;
await new Promise((r) => setTimeout(r, pollMs));
}
throw new Error(`Таймаут ожидания задачи ${jobId}`);
}
export async function apiPageAll<T>(path: string, limit = 500): Promise<T[]> {
const items: T[] = [];
let cursor: string | null = null;
while (true) {
const [basePath, rawQuery = ''] = path.split('?');
const query = new URLSearchParams(rawQuery);
if (!query.has('limit')) query.set('limit', String(limit));
if (cursor) query.set('cursor', cursor);
else query.delete('cursor');
const page = await apiJSON<{ items?: T[]; next_cursor?: string | null; has_more?: boolean }>(
`${basePath}?${query.toString()}`
);
items.push(...(page.items ?? []));
if (!page.has_more || !page.next_cursor) break;
cursor = page.next_cursor;
}
return items;
}
export async function fetchRevisionPrefixesAll(revisionId: string): Promise<RevisionPrefix[]> {
const items = await apiPageAll<RevisionPrefix>(`/v1/revisions/${revisionId}/prefixes`);
return items;
}
export async function fetchModuleSourceCatalog(moduleId: string, moduleType: ModuleType) {
if (moduleType === 'DOMAINS') {
const entries = await apiPageAll<DomainEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/domain-entries`
);
return { domains: entries };
}
if (moduleType === 'AS_PREFIXES') {
const entries = await apiPageAll<AsEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/as-entries`
);
return { asns: entries };
}
if (moduleType === 'CDN_CIDRS') {
const entries = await apiPageAll<CdnSourcesResponse['items'][number]>(
`/v1/modules/${moduleId}/cdn-sources`
);
return { cdnSources: entries };
}
if (moduleType === 'IP_RANGES') {
const entries = await apiPageAll<IpRangeEntriesResponse['items'][number]>(
`/v1/modules/${moduleId}/ip-range-entries`
);
return { ipRanges: entries };
}
return {};
}
+1
View File
@@ -0,0 +1 @@
export * from '@evobgp/shared/types/api.js';
+49
View File
@@ -0,0 +1,49 @@
/** Ответ GET /v1/version (см. docs/openapi.yaml VersionInfo). */
export type VersionInfo = {
version?: string;
api_version?: string;
git_sha?: string;
build_time?: string;
};
const PLACEHOLDER_SHA = new Set(['', 'unknown']);
function nonEmptyString(value: unknown): string {
if (typeof value === 'string') {
const s = value.trim();
return s;
}
if (value == null) return '';
return String(value).trim();
}
/** Semver или legacy api_version; пустая строка, если полей нет. */
export function resolveVersionString(info: VersionInfo | null | undefined): string {
if (!info) return '';
return nonEmptyString(info.version) || nonEmptyString(info.api_version);
}
/** Короткий git SHA; пусто для placeholder «unknown». */
export function resolveGitSha(info: VersionInfo | null | undefined): string {
if (!info) return '';
const sha = nonEmptyString(info.git_sha);
if (!sha || PLACEHOLDER_SHA.has(sha.toLowerCase())) return '';
return sha;
}
/** Заголовок карточки «Версия» на /monitoring. */
export function formatVersionHeadline(info: VersionInfo | null | undefined): string {
if (!info) return '—';
const ver = resolveVersionString(info);
const sha = resolveGitSha(info);
if (ver && sha) return `${ver} (${sha.slice(0, 12)})`;
if (ver) return ver;
if (sha) return sha.slice(0, 12);
return '—';
}
/** Подпись в сайдбаре (v1.2.3). */
export function formatVersionSidebarLabel(info: VersionInfo | null | undefined): string | null {
const ver = resolveVersionString(info);
return ver ? `v${ver}` : null;
}
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,284 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { ApiKey, ApiKeyCreate, ApiKeyCreated, ApiKeyRole } from '$lib/api/types.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import AppInput from '$lib/components/patterns/form/app-input.svelte';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Trash2 from '@lucide/svelte/icons/trash-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Copy from '@lucide/svelte/icons/copy';
type Props = {
items: ApiKey[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
const roleOptions: Array<{ value: ApiKeyRole; label: string }> = [
{ value: 'viewer', label: 'viewer — только чтение' },
{ value: 'editor', label: 'editor — CRUD без apply' },
{ value: 'operator', label: 'operator — полный доступ' },
{ value: 'node', label: 'node — только API ноды' }
];
let dialogOpen = $state(false);
let tokenDialogOpen = $state(false);
let revealedToken = $state('');
let form = $state<ApiKeyCreate>({ name: '', role: 'editor' });
let expiresLocal = $state('');
let saving = $state(false);
const columns = [
{ id: 'name', label: 'Имя', sortable: true, sortValue: (k: ApiKey) => k.name },
{ id: 'role', label: 'Роль', sortable: true, sortValue: (k: ApiKey) => k.role },
{ id: 'prefix', label: 'Префикс', sortable: true, sortValue: (k: ApiKey) => k.prefix },
{
id: 'revoked',
label: 'Статус',
sortable: true,
sortValue: (k: ApiKey) => (k.revoked_at ? 1 : 0)
},
{ id: 'actions', label: '', class: 'w-24' }
] as const;
function openCreate() {
form = { name: '', role: 'editor' };
expiresLocal = '';
dialogOpen = true;
}
function showToken(created: ApiKeyCreated) {
revealedToken = created.token;
tokenDialogOpen = true;
}
async function copyToken() {
try {
await navigator.clipboard.writeText(revealedToken);
notify.success('Скопировано');
} catch {
notify.error('Не удалось скопировать');
}
}
function requestRevoke(k: ApiKey) {
if (k.revoked_at) return;
void confirm({
title: 'Отозвать API-ключ?',
description: `${k.name} (${k.prefix}…)`,
confirmLabel: 'Отозвать',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/api-keys/${k.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Ключ отозван');
await onRefresh();
}
});
}
function requestRotate(k: ApiKey) {
if (k.revoked_at) return;
void confirm({
title: 'Ротировать ключ?',
description: 'Старый токен перестанет работать сразу.',
confirmLabel: 'Ротировать',
onConfirm: async () => {
try {
const out = await apiMutate<ApiKeyCreated>(
`/v1/api-keys/${k.id}/rotate`,
'POST',
undefined,
{ idempotent: false }
);
notify.success('Ключ обновлён');
showToken(out);
await onRefresh();
} catch (e) {
notifyApiError(e);
}
}
});
}
async function save() {
if (!form.name.trim()) {
notify.error('Укажите имя');
return;
}
saving = true;
try {
const body: ApiKeyCreate = {
name: form.name.trim(),
role: form.role
};
if (expiresLocal.trim()) {
const d = new Date(expiresLocal);
if (Number.isNaN(d.getTime())) {
notify.error('Некорректная дата истечения');
return;
}
body.expires_at = d.toISOString();
}
const created = await apiMutate<ApiKeyCreated>('/v1/api-keys', 'POST', body);
notify.success('Ключ создан');
dialogOpen = false;
showToken(created);
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">API-ключи</CardTitle>
<CardDescription>
Управление ключами tenant. Полный токен показывается только при создании и ротации.
</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" variant="outline" onclick={() => onRefresh()} disabled={loading}>
<RefreshCw class={loading ? 'animate-spin' : ''} />
Обновить
</Button>
<Button size="sm" onclick={openCreate}><Plus />Создать</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(k) => k.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет ключей"
emptyDescription="Создайте API-ключ для автоматизации или отдельного доступа."
>
{#snippet cell({ row: k, column })}
{#if column.id === 'name'}
<span class="font-medium">{k.name}</span>
{:else if column.id === 'role'}
<span class="font-mono text-sm">{k.role}</span>
{:else if column.id === 'prefix'}
<span class="font-mono text-xs text-muted-foreground">{k.prefix}</span>
{:else if column.id === 'revoked'}
{#if k.revoked_at}
<span class="text-sm text-destructive">отозван</span>
{:else}
<span class="text-sm text-muted-foreground">активен</span>
{/if}
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button
variant="ghost"
size="icon-sm"
title="Ротировать"
disabled={!!k.revoked_at}
onclick={() => requestRotate(k)}
>
<RefreshCw class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
disabled={!!k.revoked_at}
onclick={() => requestRevoke(k)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Новый API-ключ</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="Имя" id="key-name" required>
<AppInput id="key-name" bind:value={form.name} placeholder="CI / оператор UI" />
</FormField>
<FormField label="Роль" id="key-role" required>
<Select
type="single"
value={form.role}
onValueChange={(v) => (form.role = v as ApiKeyRole)}
>
<SelectTrigger id="key-role" class="w-full">
{roleOptions.find((o) => o.value === form.role)?.label ?? form.role}
</SelectTrigger>
<SelectContent>
{#each roleOptions as opt (opt.value)}
<SelectItem value={opt.value} label={opt.label}>{opt.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</FormField>
<FormField label="Истекает (опционально)" id="key-expires">
<AppInput id="key-expires" type="datetime-local" bind:value={expiresLocal} />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Создание…' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={tokenDialogOpen}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Сохраните токен</DialogTitle>
<DialogDescription
>Он больше не будет показан. Скопируйте в безопасное хранилище.</DialogDescription
>
</DialogHeader>
<div class="rounded-md border bg-muted/40 p-3 font-mono text-xs break-all">{revealedToken}</div>
<DialogFooter>
<Button variant="outline" onclick={copyToken}><Copy />Копировать</Button>
<Button onclick={() => (tokenDialogOpen = false)}>Готово</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,29 @@
<script lang="ts">
import { cn } from '$lib/utils.js';
/** preserve — вывод CLI (колонки birdc); wrap — конфиги и JSON с длинными строками */
type Variant = 'preserve' | 'wrap';
let {
variant = 'preserve',
text,
class: className = ''
}: {
variant?: Variant;
text: string;
class?: string;
} = $props();
const shell =
'border-border bg-muted/40 relative isolate min-w-0 overflow-auto rounded-lg border [scrollbar-gutter:stable] overscroll-contain';
const prePreserve =
'text-foreground m-0 block w-max min-w-full p-4 font-mono text-[0.8125rem] leading-normal whitespace-pre select-text';
const preWrap =
'text-foreground m-0 block min-w-0 w-full max-w-none p-4 font-mono text-[0.8125rem] leading-relaxed whitespace-pre-wrap break-words select-text';
</script>
<div class={cn(shell, className)} data-slot="scroll-pre-block">
<pre class={variant === 'preserve' ? prePreserve : preWrap}>{text}</pre>
</div>
@@ -0,0 +1,53 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import PageShell from '$lib/components/page-shell.svelte';
import QueryState from '$lib/components/query-state.svelte';
type Props = {
title: string;
description?: string;
actions?: Snippet;
data: unknown;
isLoading: boolean;
isError: boolean;
error?: unknown;
onRetry?: () => void;
skeleton?: Snippet;
content: Snippet;
};
let {
title,
description,
actions,
data,
isLoading,
isError,
error,
onRetry,
skeleton,
content
}: Props = $props();
</script>
<PageShell>
<PageHeader {title} {description}>
{#snippet actions()}
{#if actions}
{@render actions()}
{/if}
{/snippet}
</PageHeader>
{#if isLoading}
{#if skeleton}
{@render skeleton()}
{/if}
{:else if isError}
<QueryState {data} isLoading={false} isError={true} {error} {onRetry} children={emptyChild} />
{:else}
{@render content()}
{/if}
</PageShell>
{#snippet emptyChild()}{/snippet}
@@ -0,0 +1,42 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import { cn } from '$lib/utils.js';
type Props = {
title?: string;
description?: string;
toolbar?: Snippet;
class?: string;
children: Snippet;
};
let { title, description, toolbar, class: className, children }: Props = $props();
</script>
<Card class={cn('gap-0 py-0', className)}>
{#if title || description || toolbar}
<CardHeader
class="flex flex-row flex-wrap items-start justify-between gap-2 border-b px-4 py-3"
>
<div class="flex min-w-0 flex-col gap-0.5">
{#if title}
<CardTitle class="text-base">{title}</CardTitle>
{/if}
{#if description}
<CardDescription>{description}</CardDescription>
{/if}
</div>
{#if toolbar}
<div class="flex shrink-0 items-center gap-2">{@render toolbar()}</div>
{/if}
</CardHeader>
{/if}
<CardContent class="p-0">{@render children()}</CardContent>
</Card>
@@ -0,0 +1,188 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, BgpCommunityCreate } from '$lib/api/types.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@evobgp/ui/components/dialog/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import AppInput from '$lib/components/patterns/form/app-input.svelte';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
items: BgpCommunity[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<BgpCommunity | null>(null);
let form = $state<BgpCommunityCreate>({ community: '', title: '' });
let saving = $state(false);
const columns = [
{
id: 'community',
label: 'Код сообщества',
sortable: true,
sortValue: (c: BgpCommunity) => c.community
},
{
id: 'title',
label: 'Название',
sortable: true,
sortValue: (c: BgpCommunity) => c.title ?? ''
},
{ id: 'id', label: 'ID' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function displayName(c: BgpCommunity | null) {
if (!c) return '';
const t = c.title?.trim();
return t || c.community;
}
function openCreate() {
editTarget = null;
form = { community: '', title: '' };
dialogOpen = true;
}
function openEdit(c: BgpCommunity) {
editTarget = c;
form = { community: c.community, title: c.title ?? '' };
dialogOpen = true;
}
function requestDelete(c: BgpCommunity) {
void confirm({
title: `Удалить сообщество «${displayName(c)}»?`,
description: 'Это приведёт к удалению привязки во всех модулях.',
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/communities/${c.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Удалено');
await onRefresh();
}
});
}
async function save() {
if (!form.community.trim()) {
notify.error('Укажите community');
return;
}
saving = true;
try {
const body = { ...form, title: form.title?.trim() || undefined };
if (editTarget) {
await apiMutate(`/v1/communities/${editTarget.id}`, 'PATCH', body);
notify.success('Запись сообщества обновлена');
} else {
await apiMutate('/v1/communities', 'POST', body);
notify.success('Сообщество создано');
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Сообщества BGP</CardTitle>
<CardDescription>Используются для тегирования префиксов в AS- и CDN-модулях</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(c) => c.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет сообществ BGP"
emptyDescription="Создайте первое сообщество для тегирования префиксов."
>
{#snippet cell({ row: c, column })}
{#if column.id === 'community'}
<span class="font-mono text-sm font-medium">{c.community}</span>
{:else if column.id === 'title'}
<span>{c.title?.trim() || '—'}</span>
{:else if column.id === 'id'}
<span class="font-mono text-xs text-muted-foreground">{c.id}</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(c)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(c)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle
>{editTarget ? 'Редактировать сообщество BGP' : 'Новое сообщество BGP'}</DialogTitle
>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="Код сообщества" id="c-community" required>
<AppInput id="c-community" bind:value={form.community} placeholder="65001:120" />
</FormField>
<FormField label="Название" id="c-title" description="Человекочитаемое имя для списков">
<AppInput id="c-title" bind:value={form.title} placeholder="Название" />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,178 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { DohProfile, DohProfileCreate } from '$lib/api/types.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@evobgp/ui/components/dialog/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import AppInput from '$lib/components/patterns/form/app-input.svelte';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
items: DohProfile[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let { items, loading = false, initialLoading = false, error = null, onRefresh }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<DohProfile | null>(null);
let form = $state<DohProfileCreate & { timeout_ms?: number | null }>({
url: '',
timeout_ms: null,
vault_secret_ref: null
});
let saving = $state(false);
const columns = [
{ id: 'url', label: 'URL', sortable: true, sortValue: (d: DohProfile) => d.url },
{
id: 'timeout_ms',
label: 'Таймаут (мс)',
sortable: true,
sortValue: (d: DohProfile) => d.timeout_ms ?? 0
},
{ id: 'id', label: 'ID' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function openCreate() {
editTarget = null;
form = { url: '', timeout_ms: null, vault_secret_ref: null };
dialogOpen = true;
}
function openEdit(d: DohProfile) {
editTarget = d;
form = { url: d.url, timeout_ms: d.timeout_ms, vault_secret_ref: d.vault_secret_ref };
dialogOpen = true;
}
function requestDelete(d: DohProfile) {
void confirm({
title: 'Удалить DoH профиль?',
description: d.url,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/doh-profiles/${d.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Удалено');
await onRefresh();
}
});
}
async function save() {
if (!form.url.trim()) {
notify.error('Укажите URL');
return;
}
saving = true;
try {
if (editTarget) {
await apiMutate(`/v1/doh-profiles/${editTarget.id}`, 'PATCH', form);
notify.success('DoH профиль обновлён');
} else {
await apiMutate('/v1/doh-profiles', 'POST', form);
notify.success('DoH профиль создан');
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">DoH профили</CardTitle>
<CardDescription>DNS-over-HTTPS серверы для резолвинга доменных модулей</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(d) => d.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет DoH профилей"
emptyDescription="Добавьте DNS-over-HTTPS сервер для доменных модулей."
>
{#snippet cell({ row: d, column })}
{#if column.id === 'url'}
<span class="font-mono text-sm">{d.url}</span>
{:else if column.id === 'timeout_ms'}
<span class="text-muted-foreground">{d.timeout_ms ?? '—'}</span>
{:else if column.id === 'id'}
<span class="font-mono text-xs text-muted-foreground">{d.id}</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(d)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(d)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать' : 'Новый'} DoH профиль</DialogTitle>
</DialogHeader>
<div class="flex flex-col gap-4 py-2">
<FormField label="URL" id="doh-url" required>
<AppInput id="doh-url" bind:value={form.url} placeholder="https://dns.google/dns-query" />
</FormField>
<FormField label="Таймаут (мс)" id="doh-timeout">
<AppInput id="doh-timeout" type="number" bind:value={form.timeout_ms} placeholder="5000" />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Component, Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
type Props = {
title?: string;
description?: string;
icon?: Component;
action?: Snippet;
class?: string;
};
let { title = 'Нет данных', description, icon: Icon, action, class: className }: Props = $props();
</script>
<div
class={cn('flex flex-col items-center justify-center gap-2 px-4 py-12 text-center', className)}
>
{#if Icon}
<div class="mb-1 text-muted-foreground/60" aria-hidden="true">
<Icon class="size-10" />
</div>
{/if}
<p class="text-sm font-medium">{title}</p>
{#if description}
<p class="max-w-sm text-sm text-muted-foreground">{description}</p>
{/if}
{#if action}
<div class="mt-2">{@render action()}</div>
{/if}
</div>
@@ -0,0 +1,46 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import * as Sheet from '@evobgp/ui/components/sheet/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
type Props = {
open: boolean;
title: string;
description?: string;
onOpenChange: (open: boolean) => void;
onSubmit?: () => void;
submitLabel?: string;
submitting?: boolean;
children: Snippet;
};
let {
open,
title,
description,
onOpenChange,
onSubmit,
submitLabel = 'Сохранить',
submitting = false,
children
}: Props = $props();
</script>
<Sheet.Root {open} {onOpenChange}>
<Sheet.Content class="flex w-full flex-col gap-0 sm:max-w-lg">
<Sheet.Header>
<Sheet.Title>{title}</Sheet.Title>
{#if description}
<Sheet.Description>{description}</Sheet.Description>
{/if}
</Sheet.Header>
<div class="flex flex-1 flex-col gap-4 overflow-y-auto px-4 py-4">
{@render children()}
</div>
{#if onSubmit}
<Sheet.Footer>
<Button onclick={onSubmit} disabled={submitting}>{submitLabel}</Button>
</Sheet.Footer>
{/if}
</Sheet.Content>
</Sheet.Root>
@@ -0,0 +1,116 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { page } from '$app/state';
import { resolve } from '$app/paths';
import * as Sidebar from '@evobgp/ui/components/sidebar/index.js';
import * as Breadcrumb from '@evobgp/ui/components/breadcrumb/index.js';
import { Separator } from '@evobgp/ui/components/separator/index.js';
import type { ThemePreference } from '$lib/theme.js';
import { mainNav, bottomNav } from '$lib/ui/app/layout/nav.js';
import ThemeMenu from '$lib/ui/app/layout/theme-menu.svelte';
import AppVersion from '$lib/ui/app/layout/app-version.svelte';
import AppMobileNav from '$lib/ui/app/layout/app-mobile-nav.svelte';
type Props = {
children: Snippet;
theme?: ThemePreference;
};
let { children, theme = $bindable<ThemePreference>('system') }: Props = $props();
let mobileNavOpen = $state(false);
const routeLabels: Record<string, string> = {
'/': 'Обзор',
...Object.fromEntries(mainNav.map((i) => [i.href, i.label])),
...Object.fromEntries(bottomNav.map((i) => [i.href, i.label]))
};
const breadcrumbLabel = $derived(routeLabels[page.url.pathname] ?? 'EvoBGP');
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const navHref = (href: string) => resolve(href as any);
function isActive(href: string) {
const pathname = page.url.pathname;
if (href === '/') return pathname === '/';
return pathname === href || pathname.startsWith(href + '/');
}
</script>
<Sidebar.Provider>
<Sidebar.Root>
<Sidebar.Header class="border-b border-sidebar-border">
<div class="flex items-center gap-2 px-2 py-1">
<div class="flex min-w-0 flex-1 flex-col group-data-[collapsible=icon]:hidden">
<a href={navHref('/')} class="truncate font-semibold tracking-tight">EvoBGP</a>
<p class="truncate text-xs text-muted-foreground">Панель управления</p>
</div>
<ThemeMenu bind:theme />
</div>
</Sidebar.Header>
<Sidebar.Content>
<Sidebar.Group>
<Sidebar.GroupLabel>Операции</Sidebar.GroupLabel>
<Sidebar.GroupContent>
<Sidebar.Menu>
{#each mainNav as item (item.href)}
{@const Icon = item.icon}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive(item.href)}>
{#snippet child({ props })}
<a href={navHref(item.href)} {...props}>
<Icon class="size-4" />
<span>{item.label}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
</Sidebar.GroupContent>
</Sidebar.Group>
</Sidebar.Content>
<Sidebar.Footer class="border-t border-sidebar-border">
<Sidebar.Menu>
{#each bottomNav as item (item.href)}
{@const Icon = item.icon}
<Sidebar.MenuItem>
<Sidebar.MenuButton isActive={isActive(item.href)}>
{#snippet child({ props })}
<a href={navHref(item.href)} {...props}>
<Icon class="size-4" />
<span>{item.label}</span>
</a>
{/snippet}
</Sidebar.MenuButton>
</Sidebar.MenuItem>
{/each}
</Sidebar.Menu>
<AppVersion />
</Sidebar.Footer>
<Sidebar.Rail />
</Sidebar.Root>
<Sidebar.Inset>
<header
class="sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<div class="flex items-center gap-2 md:hidden">
<AppMobileNav bind:open={mobileNavOpen} bind:theme />
<span class="font-semibold tracking-tight">EvoBGP</span>
</div>
<Sidebar.Trigger class="-ms-1 hidden md:flex" />
<Separator orientation="vertical" class="mx-2 hidden h-4 md:block" />
<Breadcrumb.Root class="hidden min-w-0 md:flex">
<Breadcrumb.List>
<Breadcrumb.Item>
<Breadcrumb.Page>{breadcrumbLabel}</Breadcrumb.Page>
</Breadcrumb.Item>
</Breadcrumb.List>
</Breadcrumb.Root>
</header>
<main class="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">
{@render children()}
</main>
</Sidebar.Inset>
</Sidebar.Provider>
@@ -0,0 +1,45 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import X from '@lucide/svelte/icons/x';
export type FilterChip = {
id: string;
label: string;
};
type Props = {
chips?: FilterChip[];
onRemoveChip?: (id: string) => void;
onClear?: () => void;
children?: Snippet;
};
let { chips = [], onRemoveChip, onClear, children }: Props = $props();
</script>
<div class="flex flex-wrap items-center gap-2">
{#if children}
{@render children()}
{/if}
{#each chips as chip (chip.id)}
<Badge variant="secondary" class="gap-1 pr-1">
{chip.label}
{#if onRemoveChip}
<Button
variant="ghost"
size="icon-sm"
class="size-5"
onclick={() => onRemoveChip(chip.id)}
aria-label="Убрать фильтр {chip.label}"
>
<X class="size-3" />
</Button>
{/if}
</Badge>
{/each}
{#if chips.length > 0 && onClear}
<Button variant="ghost" size="sm" onclick={onClear}>Сбросить</Button>
{/if}
</div>
@@ -0,0 +1,342 @@
<script lang="ts">
import { apiFetch, apiMutate } from '$lib/api/client.js';
import type { AsEntry, BgpCommunity, ModuleRow } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import {
communityLabel,
sanitizeFilenamePart,
supportsCsvIO
} from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleAsEntryDialog from '$lib/components/modules/ModuleAsEntryDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download';
type Props = {
moduleId: string;
mod: ModuleRow;
entries: AsEntry[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<AsEntry | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
let csvImporting = $state(false);
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const activeSelected = $derived.by(() => {
const allowed = new Set(entries.map((e) => e.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'asn', label: 'ASN', sortable: true, sortValue: (e: AsEntry) => e.asn },
{
id: 'name',
label: 'Название AS',
sortable: true,
sortValue: (e: AsEntry) => e.asn_name ?? ''
},
{
id: 'prefixes',
label: 'Префиксов',
sortable: true,
sortValue: (e: AsEntry) => e.prefix_count ?? 0,
class: 'text-right'
},
{
id: 'updated',
label: 'Обновлено',
sortable: true,
sortValue: (e: AsEntry) => e.asn_resolved_at ?? ''
},
{ id: 'community', label: 'Community' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(entry: AsEntry) {
editTarget = entry;
dialogOpen = true;
}
function requestDelete(entry: AsEntry) {
void confirm({
title: 'Удалить запись?',
description: `ASN: ${entry.asn}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${entry.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные AS-записи?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено AS-записей: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
async function readErrorText(res: Response): Promise<string> {
const body = (await res.text()).trim();
return body || `HTTP ${res.status}`;
}
async function exportCsv() {
if (!supportsCsvIO(mod.type) || csvExporting) return;
csvExporting = true;
try {
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'GET',
headers: { Accept: 'text/csv' }
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
notifyApiError(e);
} finally {
csvExporting = false;
}
}
function openImportPicker() {
if (!supportsCsvIO(mod.type) || csvImporting) return;
csvFileInput?.click();
}
async function handleImportChange(event: Event) {
const input = event.currentTarget as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file || csvImporting) return;
csvImporting = true;
try {
const fileText = await file.text();
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: fileText
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const payload = (await res.json()) as { imported?: number };
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
await onChanged();
} catch (e) {
notifyApiError(e);
} finally {
csvImporting = false;
if (input) input.value = '';
}
}
</script>
<input
class="hidden"
type="file"
accept=".csv,text/csv"
bind:this={csvFileInput}
onchange={handleImportChange}
/>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">AS-записи</CardTitle>
<CardDescription>
Номер AS и community; имя, число префиксов и дата обновляются при успешном refresh
(RIPEstat).
</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button
variant="outline"
size="sm"
onclick={openImportPicker}
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
>
<Upload />
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
</Button>
<Button
variant="outline"
size="sm"
onclick={exportCsv}
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
>
<Download />
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
</Button>
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={entries}
rowKey={(e) => e.id}
{loading}
emptyTitle="Нет AS-записей"
emptyDescription="Добавьте ASN или импортируйте CSV."
>
{#snippet toolbar()}
{#if entries.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все AS-записи"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: entry, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(entry.id)}
aria-label={`Выбрать AS ${entry.asn}`}
onCheckedChange={() => toggleSelection(entry.id)}
/>
{:else if column.id === 'asn'}
<span class="font-mono">{entry.asn}</span>
{:else if column.id === 'name'}
<span
class="max-w-[14rem] truncate text-sm text-muted-foreground"
title={entry.asn_name ?? ''}
>
{entry.asn_name?.trim() ? entry.asn_name : '—'}
</span>
{:else if column.id === 'prefixes'}
<span class="font-mono text-sm">
{entry.prefix_count != null ? entry.prefix_count : '—'}
</span>
{:else if column.id === 'updated'}
<span class="text-sm whitespace-nowrap text-muted-foreground">
{formatDateTime(entry.asn_resolved_at)}
</span>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(entry)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleAsEntryDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,142 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: AsEntry | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let form = $state<AsEntryCreate>({ asn: 0, community_id: null });
let initKey = $state('');
function resetForm() {
form = edit
? { asn: edit.asn, community_id: edit.community_id }
: { asn: 0, community_id: null };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function save() {
const asn = Number(form.asn);
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
notify.error('Укажите корректный ASN (14294967295)');
return;
}
saving = true;
try {
const body: AsEntryCreate | AsEntryPatch = { asn, community_id: form.community_id };
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${edit.id}`, 'PATCH', body);
notify.success('Запись обновлена');
} else {
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate);
notify.success('Запись добавлена');
}
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
<DialogDescription>
Номер автономной системы и community для политики анонса.
</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="as-asn">ASN</Label>
<Input
id="as-asn"
type="number"
placeholder="12345"
bind:value={form.asn}
min={1}
max={4294967295}
/>
</div>
<div class="space-y-1.5">
<Label for="as-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="as-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,275 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type {
BgpCommunity,
CdnPreviewResponse,
CdnSource,
CdnSourceCreate
} from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
normalizeCdnSourceKind,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: CdnSource | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let previewLoading = $state(false);
let previewItems = $state<string[]>([]);
let previewTotal = $state(0);
let previewTruncated = $state(false);
let previewError = $state<string | null>(null);
let previewOk = $state(false);
let form = $state<CdnSourceCreate & { refresh_interval_sec?: number | null }>({
url: '',
source_kind: 'plaintext',
prefix_path: '',
community_id: null
});
let initKey = $state('');
function clearPreview() {
previewLoading = false;
previewItems = [];
previewTotal = 0;
previewTruncated = false;
previewError = null;
previewOk = false;
}
function resetForm() {
clearPreview();
form = edit
? {
url: edit.url,
source_kind: normalizeCdnSourceKind(edit.source_kind),
prefix_path: edit.prefix_path ?? '',
community_id: edit.community_id,
refresh_interval_sec: edit.refresh_interval_sec
}
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function previewCdn() {
const urlTrim = form.url.trim();
if (!urlTrim) {
notify.error('Укажите URL');
return;
}
previewLoading = true;
previewError = null;
previewOk = false;
try {
const res = await apiMutate<CdnPreviewResponse>(
`/v1/modules/${moduleId}/cdn-sources/preview`,
'POST',
{
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? ''
}
);
previewItems = res.items;
previewTotal = res.total;
previewTruncated = res.truncated;
previewOk = true;
} catch (e) {
previewError = e instanceof Error ? e.message : String(e);
previewItems = [];
previewTotal = 0;
previewTruncated = false;
previewOk = false;
} finally {
previewLoading = false;
}
}
async function save() {
const urlTrim = form.url.trim();
if (!urlTrim) {
notify.error('Укажите URL');
return;
}
saving = true;
try {
const body = {
...form,
url: urlTrim,
source_kind: form.source_kind,
prefix_path: form.prefix_path?.trim() ?? ''
};
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body);
notify.success('Источник обновлён');
} else {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body);
notify.success('Источник добавлен');
}
clearPreview();
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) {
clearPreview();
onClose();
}
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="cdn-url">URL</Label>
<Input id="cdn-url" placeholder="https://example.com/list.txt" bind:value={form.url} />
</div>
<div class="space-y-1.5">
<Label for="cdn-kind">Тип источника</Label>
<Select
type="single"
value={form.source_kind}
onValueChange={(v) => {
form.source_kind = v || 'plaintext';
}}
>
<SelectTrigger id="cdn-kind" class="w-full">
{form.source_kind === 'json' ? 'json' : 'plaintext'}
</SelectTrigger>
<SelectContent>
<SelectItem value="plaintext">plaintext</SelectItem>
<SelectItem value="json">json</SelectItem>
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label for="cdn-prefix-path">JSON path (prefix_path)</Label>
<Input
id="cdn-prefix-path"
placeholder="напр. prefixes[] или data.items[].cidr"
bind:value={form.prefix_path}
/>
{#if form.source_kind === 'json' && !form.prefix_path?.trim()}
<p class="text-xs text-muted-foreground">
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
</p>
{/if}
</div>
<div class="space-y-1.5">
<Label for="cdn-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="cdn-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="space-y-1.5">
<Label for="cdn-interval">Интервал обновления (сек)</Label>
<Input
id="cdn-interval"
type="number"
placeholder="3600"
bind:value={form.refresh_interval_sec}
/>
</div>
<div class="flex flex-col gap-2 rounded-lg border border-border p-3">
<div class="flex flex-wrap items-center gap-2">
<Button
type="button"
variant="secondary"
size="sm"
onclick={previewCdn}
disabled={previewLoading}
>
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
</Button>
{#if previewError}
<span class="text-sm text-destructive">{previewError}</span>
{:else if previewOk}
<span class="text-sm text-muted-foreground">
Всего: {previewTotal}{#if previewTruncated}
<span class="text-amber-600 dark:text-amber-500"> (обрезано)</span>{/if}
</span>
{/if}
</div>
{#if previewItems.length}
<ul class="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
{#each previewItems as item, i (`${i}-${item}`)}
<li class="py-0.5">{item}</li>
{/each}
</ul>
{/if}
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,237 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, CdnSource } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import {
communityLabel,
normalizeCdnSourceKind
} from '$lib/components/modules/module-helpers.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleCdnSourceDialog from '$lib/components/modules/ModuleCdnSourceDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
moduleId: string;
sources: CdnSource[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, sources, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<CdnSource | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
const activeSelected = $derived.by(() => {
const allowed = new Set(sources.map((s) => s.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(sources.length > 0 && sources.every((s) => selectedIds.has(s.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'url', label: 'URL', sortable: true, sortValue: (s: CdnSource) => s.url },
{ id: 'kind', label: 'Тип', sortable: true, sortValue: (s: CdnSource) => s.source_kind },
{ id: 'community', label: 'Community' },
{
id: 'interval',
label: 'Интервал',
sortable: true,
sortValue: (s: CdnSource) => s.refresh_interval_sec ?? 0
},
{
id: 'refreshed',
label: 'Последнее обновление',
sortable: true,
sortValue: (s: CdnSource) => s.last_refreshed_at ?? ''
},
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(sources.map((s) => s.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(src: CdnSource) {
editTarget = src;
dialogOpen = true;
}
function requestDelete(src: CdnSource) {
void confirm({
title: 'Удалить CDN-источник?',
description: src.url,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${src.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные CDN-источники?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено CDN-источников: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
</script>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">CDN-источники</CardTitle>
<CardDescription>URL источников для скачивания списков CIDR.</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={sources}
rowKey={(s) => s.id}
{loading}
emptyTitle="Нет CDN-источников"
emptyDescription="Добавьте URL для загрузки списков CIDR."
>
{#snippet toolbar()}
{#if sources.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все CDN-источники"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: src, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(src.id)}
aria-label="Выбрать CDN-источник"
onCheckedChange={() => toggleSelection(src.id)}
/>
{:else if column.id === 'url'}
<span class="max-w-xs truncate font-mono text-xs" title={src.url}>{src.url}</span>
{:else if column.id === 'kind'}
<div class="flex flex-col gap-0.5">
<Badge variant="outline">{normalizeCdnSourceKind(src.source_kind)}</Badge>
{#if src.prefix_path?.trim()}
<span
class="font-mono text-xs break-all text-muted-foreground"
title={src.prefix_path}>{src.prefix_path}</span
>
{/if}
</div>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(src.community_id, communities)}
</span>
{:else if column.id === 'interval'}
<span class="text-sm text-muted-foreground">
{src.refresh_interval_sec != null ? `${src.refresh_interval_sec}с` : '—'}
</span>
{:else if column.id === 'refreshed'}
<span class="text-sm whitespace-nowrap text-muted-foreground">
{formatDateTime(src.last_refreshed_at)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(src)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(src)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleCdnSourceDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,149 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { ModuleCreate } from '$lib/api/types.js';
import { moduleTypeRu } from '$lib/ui-labels.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { Switch } from '@evobgp/ui/components/switch/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
onClose: () => void;
onCreated: () => void | Promise<void>;
};
let { open = $bindable(), onClose, onCreated }: Props = $props();
let saving = $state(false);
let form = $state<ModuleCreate>({
type: 'AS_PREFIXES',
name: '',
enabled: true,
priority: 0
});
const moduleTypes = [
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') }
] as const;
function resetForm() {
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
}
async function create() {
if (!form.name.trim()) {
notify.error('Укажите название модуля');
return;
}
saving = true;
try {
await apiMutate('/v1/modules', 'POST', form);
notify.success('Модуль создан');
open = false;
resetForm();
await onCreated();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) {
onClose();
resetForm();
}
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Новый модуль</DialogTitle>
<DialogDescription>Создание нового модуля префиксов.</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="m-name">Название</Label>
<Input id="m-name" bind:value={form.name} placeholder="my-asn-module" />
</div>
<div class="space-y-1.5">
<Label for="m-type">Тип</Label>
<Select
type="single"
value={form.type}
onValueChange={(v) => {
if (v) form.type = v as typeof form.type;
}}
>
<SelectTrigger id="m-type" class="w-full">
{moduleTypes.find((t) => t.value === form.type)?.label ?? 'Выберите тип'}
</SelectTrigger>
<SelectContent>
{#each moduleTypes as t (t.value)}
<SelectItem value={t.value}>{t.label}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="m-priority">Приоритет</Label>
<Input id="m-priority" type="number" bind:value={form.priority} />
</div>
<div class="space-y-1.5">
<Label for="m-interval">Интервал (сек)</Label>
<Input
id="m-interval"
type="number"
placeholder="3600"
bind:value={form.refresh_interval_sec}
/>
</div>
</div>
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="m-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Модуль участвует в сборке ревизий, если включён.
</p>
</div>
<Switch
id="m-enabled"
class="shrink-0"
checked={form.enabled !== false}
onCheckedChange={(v) => {
form = { ...form, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={create} disabled={saving}>{saving ? 'Создание…' : 'Создать'}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,57 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { ModuleRow } from '$lib/api/types.js';
import { moduleEnabledRu, moduleEnabledBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Blocks from '@lucide/svelte/icons/blocks';
type Props = {
mod: ModuleRow;
refreshing: boolean;
onRefresh: () => void;
onEdit: () => void;
onDelete: () => void;
};
let { mod, refreshing, onRefresh, onEdit, onDelete }: Props = $props();
</script>
<div class="flex min-w-0 items-start gap-2">
<Button variant="ghost" size="icon-sm" class="mt-1 shrink-0" href={resolve('/modules')}>
<ArrowLeft class="size-4" />
</Button>
<PageHeader
class="min-w-0 flex-1"
title={mod.name}
description={mod.id}
icon={Blocks}
iconClass="bg-chart-2/15 text-chart-2"
>
{#snippet actions()}
<div class="flex flex-wrap items-center gap-2">
<Badge variant="outline">{moduleTypeRu(mod.type)}</Badge>
<Badge variant={moduleEnabledBadgeVariant(!!mod.enabled)} class="text-xs">
{moduleEnabledRu(!!mod.enabled)}
</Badge>
<Button variant="outline" size="sm" onclick={() => onRefresh()} disabled={refreshing}>
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<Button variant="outline" size="sm" onclick={() => onEdit()}>
<Pencil />
Редактировать
</Button>
<Button variant="destructive" size="sm" onclick={() => onDelete()}>
<Trash2 />
Удалить
</Button>
</div>
{/snippet}
</PageHeader>
</div>
@@ -0,0 +1,302 @@
<script lang="ts">
import { apiFetch, apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, DomainEntry, ModuleRow } from '$lib/api/types.js';
import {
communityLabel,
sanitizeFilenamePart,
supportsCsvIO
} from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleDomainEntryDialog from '$lib/components/modules/ModuleDomainEntryDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download';
type Props = {
moduleId: string;
mod: ModuleRow;
entries: DomainEntry[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<DomainEntry | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
let csvImporting = $state(false);
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const activeSelected = $derived.by(() => {
const allowed = new Set(entries.map((e) => e.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{ id: 'fqdn', label: 'FQDN', sortable: true, sortValue: (e: DomainEntry) => e.fqdn },
{ id: 'community', label: 'Community' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(entry: DomainEntry) {
editTarget = entry;
dialogOpen = true;
}
function requestDelete(entry: DomainEntry) {
void confirm({
title: 'Удалить домен?',
description: entry.fqdn,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${entry.id}`, 'DELETE', undefined, {
idempotent: false
});
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные домены?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено доменов: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
async function readErrorText(res: Response): Promise<string> {
const body = (await res.text()).trim();
return body || `HTTP ${res.status}`;
}
async function exportCsv() {
if (!supportsCsvIO(mod.type) || csvExporting) return;
csvExporting = true;
try {
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'GET',
headers: { Accept: 'text/csv' }
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
notifyApiError(e);
} finally {
csvExporting = false;
}
}
function openImportPicker() {
if (!supportsCsvIO(mod.type) || csvImporting) return;
csvFileInput?.click();
}
async function handleImportChange(event: Event) {
const input = event.currentTarget as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file || csvImporting) return;
csvImporting = true;
try {
const fileText = await file.text();
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: fileText
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const payload = (await res.json()) as { imported?: number };
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
await onChanged();
} catch (e) {
notifyApiError(e);
} finally {
csvImporting = false;
if (input) input.value = '';
}
}
</script>
<input
class="hidden"
type="file"
accept=".csv,text/csv"
bind:this={csvFileInput}
onchange={handleImportChange}
/>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Домены</CardTitle>
<CardDescription>FQDN для резолвинга через DoH.</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button
variant="outline"
size="sm"
onclick={openImportPicker}
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
>
<Upload />
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
</Button>
<Button
variant="outline"
size="sm"
onclick={exportCsv}
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
>
<Download />
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
</Button>
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={entries}
rowKey={(e) => e.id}
{loading}
emptyTitle="Нет доменов"
emptyDescription="Добавьте FQDN или импортируйте CSV."
>
{#snippet toolbar()}
{#if entries.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все домены"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: entry, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(entry.id)}
aria-label={`Выбрать домен ${entry.fqdn}`}
onCheckedChange={() => toggleSelection(entry.id)}
/>
{:else if column.id === 'fqdn'}
<span class="font-mono">{entry.fqdn}</span>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(entry)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleDomainEntryDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,125 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '$lib/api/types.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: DomainEntry | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let form = $state<DomainEntryCreate>({ fqdn: '', community_id: null });
let initKey = $state('');
function resetForm() {
form = edit
? { fqdn: edit.fqdn, community_id: edit.community_id }
: { fqdn: '', community_id: null };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function save() {
saving = true;
try {
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', form);
notify.success('Домен обновлён');
} else {
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', form);
notify.success('Домен добавлен');
}
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="dom-fqdn">FQDN</Label>
<Input id="dom-fqdn" placeholder="example.com" bind:value={form.fqdn} />
</div>
<div class="space-y-1.5">
<Label for="dom-comm">Community</Label>
<Select
type="single"
value={nullableSelectValue(form.community_id)}
onValueChange={(v) => {
form.community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="dom-comm" class="w-full">
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,299 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type {
BgpCommunity,
DohProfile,
DohResolverPolicy,
ModulePatch,
ModuleRow
} from '$lib/api/types.js';
import { dohPolicyRu } from '$lib/ui-labels.js';
import {
communityLabel,
communityOptionLabel,
fromNullableSelect,
moduleDohProfileIds,
NONE_OPTION,
nullableSelectValue
} from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { Switch } from '@evobgp/ui/components/switch/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
const dohPolicyOptions: { value: DohResolverPolicy; label: string; hint: string }[] = [
{
value: 'primary_only',
label: 'Только первый',
hint: 'Используется первый выбранный DoH-профиль.'
},
{
value: 'failover',
label: 'Резервирование',
hint: 'Профили по порядку до первого успешного ответа.'
},
{
value: 'union',
label: 'Объединение',
hint: 'Все A/AAAA со всех профилей (geo-split DNS).'
}
];
type Props = {
open: boolean;
mod: ModuleRow;
moduleId: string;
communities: BgpCommunity[];
dohProfiles: DohProfile[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let {
open = $bindable(),
mod,
moduleId,
communities,
dohProfiles,
onSaved,
onClose
}: Props = $props();
let editForm = $state<ModulePatch>({});
let editSaving = $state(false);
let initKey = $state('');
function resetEditForm() {
editForm = {
name: mod.name,
enabled: mod.enabled,
priority: mod.priority,
refresh_interval_sec: mod.refresh_interval_sec,
cron_expr: mod.cron_expr,
default_community_id: mod.default_community_id,
doh_profile_ids: moduleDohProfileIds(mod),
doh_resolver_policy: mod.doh_resolver_policy ?? 'primary_only'
};
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = mod.id;
if (nextKey !== initKey) {
initKey = nextKey;
resetEditForm();
}
});
function toggleEditDohProfile(id: string, checked: boolean) {
let ids = [...(editForm.doh_profile_ids ?? [])];
if (checked) {
if (!ids.includes(id)) ids.push(id);
} else {
ids = ids.filter((x) => x !== id);
}
editForm = { ...editForm, doh_profile_ids: ids };
}
function isEditDohProfileSelected(id: string): boolean {
return (editForm.doh_profile_ids ?? []).includes(id);
}
async function saveMod() {
editSaving = true;
try {
const cron =
typeof editForm.cron_expr === 'string' ? editForm.cron_expr.trim() : editForm.cron_expr;
const intervalRaw = editForm.refresh_interval_sec;
const interval =
intervalRaw === null || intervalRaw === undefined ? null : Number(intervalRaw);
const payload: ModulePatch = {
...editForm,
cron_expr: cron ? cron : null,
refresh_interval_sec: Number.isFinite(interval) ? interval : null,
default_community_id: fromNullableSelect(
nullableSelectValue(editForm.default_community_id)
),
doh_profile_ids: editForm.doh_profile_ids ?? [],
doh_resolver_policy: editForm.doh_resolver_policy ?? 'primary_only'
};
await apiMutate<ModuleRow>(`/v1/modules/${moduleId}`, 'PATCH', payload);
notify.success('Модуль обновлён');
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
editSaving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Редактировать модуль</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="e-name">Название</Label>
<Input id="e-name" bind:value={editForm.name} />
</div>
<div class="grid grid-cols-2 gap-4">
<div class="space-y-1.5">
<Label for="e-priority">Приоритет</Label>
<Input id="e-priority" type="number" bind:value={editForm.priority} />
</div>
<div class="space-y-1.5">
<Label for="e-interval">Интервал (сек)</Label>
<Input id="e-interval" type="number" bind:value={editForm.refresh_interval_sec} />
</div>
</div>
<div class="space-y-1.5">
<Label for="e-cron">Cron-выражение</Label>
<Input id="e-cron" placeholder="0 */6 * * *" bind:value={editForm.cron_expr} />
</div>
<div class="space-y-1.5">
<Label for="e-comm">Community по умолчанию</Label>
<Select
type="single"
value={nullableSelectValue(editForm.default_community_id)}
onValueChange={(v) => {
editForm.default_community_id = fromNullableSelect(v);
}}
>
<SelectTrigger id="e-comm" class="w-full">
{editForm.default_community_id
? communityLabel(editForm.default_community_id, communities)
: 'Не выбрано'}
</SelectTrigger>
<SelectContent>
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => {
editForm.default_community_id = null;
}}
>
Сбросить
</Button>
</div>
{#if mod.type === 'DOMAINS'}
<div class="space-y-1.5">
<Label for="e-doh-policy">Политика DoH</Label>
<Select
type="single"
value={editForm.doh_resolver_policy ?? 'primary_only'}
onValueChange={(v) => {
if (v) editForm.doh_resolver_policy = v as DohResolverPolicy;
}}
>
<SelectTrigger id="e-doh-policy" class="w-full">
{dohPolicyRu(editForm.doh_resolver_policy)}
</SelectTrigger>
<SelectContent>
{#each dohPolicyOptions as opt (opt.value)}
<SelectItem value={opt.value}>{opt.label}</SelectItem>
{/each}
</SelectContent>
</Select>
<p class="text-xs text-muted-foreground">
{dohPolicyOptions.find(
(o) => o.value === (editForm.doh_resolver_policy ?? 'primary_only')
)?.hint}
</p>
</div>
<div class="space-y-2">
<Label>DoH-профили</Label>
<p class="text-xs text-muted-foreground">
Порядок выбора = порядок в списке (сверху вниз).
</p>
<div class="max-h-40 space-y-2 overflow-y-auto rounded-md border p-3">
{#each dohProfiles as d (d.id)}
<label class="flex items-start gap-2 text-sm">
<Checkbox
checked={isEditDohProfileSelected(d.id)}
onCheckedChange={(v) => toggleEditDohProfile(d.id, v === true)}
/>
<span class="min-w-0 break-all">
<span class="font-medium">{d.name?.trim() ? d.name : d.url}</span>
{#if d.name?.trim()}
<span class="block font-mono text-xs text-muted-foreground">{d.url}</span>
{/if}
</span>
</label>
{:else}
<p class="text-xs text-muted-foreground">Нет профилей — создайте в справочниках.</p>
{/each}
</div>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 px-2"
onclick={() => {
editForm = { ...editForm, doh_profile_ids: [] };
}}
>
Сбросить профили
</Button>
</div>
{/if}
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="e-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Отключённые модули не участвуют в обновлении конфигурации.
</p>
</div>
<Switch
id="e-enabled"
class="shrink-0"
checked={editForm.enabled !== false}
onCheckedChange={(v) => {
editForm = { ...editForm, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={saveMod} disabled={editSaving}>
{editSaving ? 'Сохранение…' : 'Сохранить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,120 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '$lib/api/types.js';
import { communityLabel, communityOptionLabel } from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
type Props = {
open: boolean;
moduleId: string;
edit: IpRangeEntry | null;
communities: BgpCommunity[];
onSaved: () => void | Promise<void>;
onClose: () => void;
};
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
let saving = $state(false);
let form = $state<IpRangeEntryCreate>({ prefix: '', community_id: '' });
let initKey = $state('');
function resetForm() {
form = edit
? { prefix: edit.prefix, community_id: edit.community_id }
: { prefix: '', community_id: '' };
}
$effect(() => {
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
async function save() {
saving = true;
try {
if (edit) {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${edit.id}`, 'PATCH', form);
notify.success('Диапазон обновлён');
} else {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', form);
notify.success('Диапазон добавлен');
}
open = false;
await onSaved();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function handleOpenChange(next: boolean) {
open = next;
if (!next) onClose();
}
</script>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="ip-prefix">Префикс (CIDR)</Label>
<Input id="ip-prefix" placeholder="203.0.113.0/24" bind:value={form.prefix} />
</div>
<div class="space-y-1.5">
<Label for="ip-comm">Community (обязательно)</Label>
<Select
type="single"
value={form.community_id}
onValueChange={(v) => {
form.community_id = v;
}}
>
<SelectTrigger id="ip-comm" class="w-full">
{form.community_id
? communityLabel(form.community_id, communities)
: 'Выберите community'}
</SelectTrigger>
<SelectContent>
{#each communities as c (c.id)}
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
{/each}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,310 @@
<script lang="ts">
import { apiFetch, apiMutate } from '$lib/api/client.js';
import type { BgpCommunity, IpRangeEntry, ModuleRow } from '$lib/api/types.js';
import {
communityLabel,
sanitizeFilenamePart,
supportsCsvIO
} from '$lib/components/modules/module-helpers.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import ModuleIpRangeEntryDialog from '$lib/components/modules/ModuleIpRangeEntryDialog.svelte';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Upload from '@lucide/svelte/icons/upload';
import Download from '@lucide/svelte/icons/download';
type Props = {
moduleId: string;
mod: ModuleRow;
entries: IpRangeEntry[];
communities: BgpCommunity[];
loading?: boolean;
onChanged: () => void | Promise<void>;
};
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<IpRangeEntry | null>(null);
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
let csvImporting = $state(false);
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const activeSelected = $derived.by(() => {
const allowed = new Set(entries.map((e) => e.id));
return [...selectedIds].filter((id) => allowed.has(id));
});
const selectedCount = $derived(activeSelected.length);
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
const columns = [
{ id: 'select', label: '', class: 'w-10' },
{
id: 'prefix',
label: 'Префикс (CIDR)',
sortable: true,
sortValue: (e: IpRangeEntry) => e.prefix
},
{ id: 'community', label: 'Community' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
else next.add(id);
selectedIds = next;
}
function toggleAll(checked: boolean) {
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
}
function openCreate() {
editTarget = null;
dialogOpen = true;
}
function openEdit(entry: IpRangeEntry) {
editTarget = entry;
dialogOpen = true;
}
function requestDelete(entry: IpRangeEntry) {
void confirm({
title: 'Удалить диапазон?',
description: entry.prefix,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(
`/v1/modules/${moduleId}/ip-range-entries/${entry.id}`,
'DELETE',
undefined,
{ idempotent: false }
);
notify.success('Удалено');
await onChanged();
}
});
}
function requestBulkDelete() {
if (selectedCount === 0) return;
void confirm({
title: 'Удалить выбранные диапазоны?',
description: `Будет удалено: ${selectedCount}`,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: bulkDelete
});
}
async function bulkDelete() {
if (selectedCount === 0) return;
deletingBulk = true;
let deleted = 0;
try {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${id}`, 'DELETE', undefined, {
idempotent: false
});
deleted += 1;
} catch (e) {
notifyApiError(e);
}
}
if (deleted > 0) notify.success(`Удалено диапазонов: ${deleted}`);
await onChanged();
} finally {
deletingBulk = false;
}
}
async function readErrorText(res: Response): Promise<string> {
const body = (await res.text()).trim();
return body || `HTTP ${res.status}`;
}
async function exportCsv() {
if (!supportsCsvIO(mod.type) || csvExporting) return;
csvExporting = true;
try {
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'GET',
headers: { Accept: 'text/csv' }
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(url);
} catch (e) {
notifyApiError(e);
} finally {
csvExporting = false;
}
}
function openImportPicker() {
if (!supportsCsvIO(mod.type) || csvImporting) return;
csvFileInput?.click();
}
async function handleImportChange(event: Event) {
const input = event.currentTarget as HTMLInputElement | null;
const file = input?.files?.[0];
if (!file || csvImporting) return;
csvImporting = true;
try {
const fileText = await file.text();
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
method: 'POST',
headers: { 'Content-Type': 'text/csv' },
body: fileText
});
if (!res.ok) {
notify.error(await readErrorText(res));
return;
}
const payload = (await res.json()) as { imported?: number };
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
await onChanged();
} catch (e) {
notifyApiError(e);
} finally {
csvImporting = false;
if (input) input.value = '';
}
}
</script>
<input
class="hidden"
type="file"
accept=".csv,text/csv"
bind:this={csvFileInput}
onchange={handleImportChange}
/>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">IP-диапазоны</CardTitle>
<CardDescription>Статические CIDR для анонса.</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
<Button
variant="outline"
size="sm"
onclick={openImportPicker}
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
>
<Upload />
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
</Button>
<Button
variant="outline"
size="sm"
onclick={exportCsv}
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
>
<Download />
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
</Button>
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
{#if selectedCount > 0}
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
<Trash2 />
Удалить ({selectedCount})
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={entries}
rowKey={(e) => e.id}
{loading}
emptyTitle="Нет диапазонов"
emptyDescription="Добавьте CIDR или импортируйте CSV."
>
{#snippet toolbar()}
{#if entries.length > 0}
<div class="flex items-center gap-2">
<Checkbox
checked={allSelected}
onCheckedChange={(v) => toggleAll(v === true)}
aria-label="Выбрать все диапазоны"
/>
<span class="text-sm text-muted-foreground">Выбрать все</span>
</div>
{/if}
{/snippet}
{#snippet cell({ row: entry, column })}
{#if column.id === 'select'}
<Checkbox
checked={selectedIds.has(entry.id)}
aria-label={`Выбрать диапазон ${entry.prefix}`}
onCheckedChange={() => toggleSelection(entry.id)}
/>
{:else if column.id === 'prefix'}
<span class="font-mono">{entry.prefix}</span>
{:else if column.id === 'community'}
<span class="text-sm text-muted-foreground">
{communityLabel(entry.community_id, communities)}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(entry)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<ModuleIpRangeEntryDialog
bind:open={dialogOpen}
{moduleId}
edit={editTarget}
{communities}
onSaved={onChanged}
onClose={() => {
editTarget = null;
}}
/>
@@ -0,0 +1,155 @@
<script lang="ts">
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '$lib/api/types.js';
import { formatDateTime, moduleIntervalLabel } from '$lib/modules/display.js';
import { dohPolicyRu } from '$lib/ui-labels.js';
import {
communityLabel,
dohProfileLabel,
moduleDohProfileIds
} from '$lib/components/modules/module-helpers.js';
import { Card, CardContent, CardHeader, CardTitle } from '@evobgp/ui/components/card/index.js';
import CardSkeleton from '$lib/components/patterns/feedback/card-skeleton.svelte';
import { cn } from '$lib/utils.js';
import ArrowDownUp from '@lucide/svelte/icons/arrow-down-up';
import Timer from '@lucide/svelte/icons/timer';
import ShieldCheck from '@lucide/svelte/icons/shield-check';
import Network from '@lucide/svelte/icons/network';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
type Props = {
mod: ModuleRow | null;
communities: BgpCommunity[];
dohProfiles: DohProfile[];
asEntries: AsEntry[];
loading?: boolean;
};
let { mod, communities, dohProfiles, asEntries, loading = false }: Props = $props();
const asPrefixTotal = $derived(
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
);
const statAccents = [
{
border: 'border-l-chart-3',
bg: 'bg-chart-3/5',
iconBg: 'bg-chart-3/15',
iconText: 'text-chart-3'
},
{
border: 'border-l-chart-5',
bg: 'bg-chart-5/5',
iconBg: 'bg-chart-5/15',
iconText: 'text-chart-5'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-muted-foreground',
bg: 'bg-muted/30',
iconBg: 'bg-muted',
iconText: 'text-muted-foreground'
}
] as const;
const kpiCards = $derived.by(() => {
if (!mod) return [];
const dohIds = moduleDohProfileIds(mod);
return [
{
id: 'priority',
label: 'Приоритет',
value: String(mod.priority ?? 0),
description: 'порядок в сборке ревизии',
icon: ArrowDownUp,
accent: statAccents[0]
},
{
id: 'interval',
label: 'Интервал',
value: moduleIntervalLabel(mod),
description: 'refresh_interval_sec / cron',
icon: Timer,
accent: statAccents[1],
mono: true
},
{
id: 'doh',
label: 'DoH',
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
description:
mod.type === 'DOMAINS'
? dohIds.length
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
: 'Системный DNS'
: 'не применимо',
icon: Network,
accent: statAccents[2]
},
{
id: 'community',
label: 'Community по умолч.',
value: communityLabel(mod.default_community_id, communities),
description: 'для записей без своего community',
icon: ShieldCheck,
accent: statAccents[3]
},
{
id: 'refreshed',
label: 'Последнее обновление',
value: formatDateTime(mod.last_refreshed_at),
description:
mod.type === 'AS_PREFIXES'
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
: 'время последнего refresh',
icon: RefreshCw,
accent: statAccents[4]
}
];
});
</script>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
{#if loading}
{#each Array(5) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each kpiCards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card class={cn('overflow-hidden border-l-4 shadow-sm', a.border, a.bg)}>
<CardHeader class="pb-2">
<p class="flex items-center gap-1.5 text-xs text-muted-foreground">
<span
class={cn('flex size-7 shrink-0 items-center justify-center rounded-md', a.iconBg)}
aria-hidden="true"
>
<Icon class={cn('size-3.5', a.iconText)} />
</span>
{card.label}
</p>
<CardTitle
class={cn('text-base font-semibold break-all', card.mono ? 'font-mono text-sm' : '')}
>
{card.value}
</CardTitle>
</CardHeader>
<CardContent>
<p class="line-clamp-2 text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
@@ -0,0 +1,115 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { AsEntry, CdnSource, DomainEntry, IpRangeEntry, ModuleRow } from '$lib/api/types.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
type Props = {
mod: ModuleRow;
asEntries: AsEntry[];
cdnSources: CdnSource[];
domainEntries: DomainEntry[];
ipEntries: IpRangeEntry[];
};
let { mod, asEntries, cdnSources, domainEntries, ipEntries }: Props = $props();
const asPrefixTotal = $derived(
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
);
</script>
<Card>
<CardHeader>
<CardTitle class="text-base">Операционный отчёт модуля</CardTitle>
<CardDescription>
Читаемая сводка по данным модуля: источники, объёмы и ожидаемый результат для
refresh/агрегации.
</CardDescription>
</CardHeader>
<CardContent class="grid gap-3 md:grid-cols-2">
{#if mod.type === 'DOMAINS'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">Домены и ожидаемые IP</p>
<p class="text-xs text-muted-foreground">
После refresh домены резолвятся в IP и конвертируются в префиксы.
</p>
<div class="mt-2 space-y-1">
{#each domainEntries.slice(0, 8) as entry (entry.id)}
<p class="font-mono text-xs break-all">{entry.fqdn}</p>
{:else}
<p class="text-xs text-muted-foreground">Нет доменов</p>
{/each}
{#if domainEntries.length > 8}
<p class="text-xs text-muted-foreground">…и ещё {domainEntries.length - 8}</p>
{/if}
</div>
</div>
{:else if mod.type === 'AS_PREFIXES'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">ASN и число полученных префиксов</p>
<p class="text-xs text-muted-foreground">
Счётчик префиксов обновляется после успешного refresh (RIPEstat).
</p>
<div class="mt-2 space-y-1">
<p class="text-xs">
Всего ASN: <span class="font-semibold">{asEntries.length}</span>
</p>
<p class="text-xs">
Сумма префиксов: <span class="font-semibold">{asPrefixTotal}</span>
</p>
</div>
</div>
{:else if mod.type === 'CDN_CIDRS'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">CDN ссылки и импортируемые префиксы</p>
<p class="text-xs text-muted-foreground">
Каждый URL поставляет список CIDR для агрегации.
</p>
<div class="mt-2 space-y-1">
{#each cdnSources.slice(0, 6) as src (src.id)}
<p class="font-mono text-xs break-all">{src.url}</p>
{:else}
<p class="text-xs text-muted-foreground">Нет CDN источников</p>
{/each}
{#if cdnSources.length > 6}
<p class="text-xs text-muted-foreground">…и ещё {cdnSources.length - 6}</p>
{/if}
</div>
</div>
{:else if mod.type === 'IP_RANGES'}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">IP ranges для агрегации</p>
<p class="text-xs text-muted-foreground">
Статические CIDR, которые попадают в итоговую ревизию.
</p>
<div class="mt-2 space-y-1">
{#each ipEntries.slice(0, 8) as entry (entry.id)}
<p class="font-mono text-xs">{entry.prefix}</p>
{:else}
<p class="text-xs text-muted-foreground">Нет диапазонов</p>
{/each}
{#if ipEntries.length > 8}
<p class="text-xs text-muted-foreground">…и ещё {ipEntries.length - 8}</p>
{/if}
</div>
</div>
{/if}
<div class="rounded-lg border p-3">
<p class="text-sm font-medium">Результат операции</p>
<p class="text-xs text-muted-foreground">
Подробный результат по конкретному запуску refresh смотрите в Операции → Задачи →
module_refresh: там отображаются источники, количество префиксов и итог агрегации.
</p>
<Button variant="link" class="mt-2 h-auto p-0" href={resolve('/operations?tab=jobs')}>
Открыть задачи
</Button>
</div>
</CardContent>
</Card>
@@ -0,0 +1,60 @@
import type { BgpCommunity, DohProfile, ModuleRow } from '$lib/api/types.js';
export const NONE_OPTION = '__none__';
export function supportsCsvIO(type: ModuleRow['type'] | null | undefined): boolean {
return type === 'AS_PREFIXES' || type === 'DOMAINS' || type === 'IP_RANGES';
}
export function sanitizeFilenamePart(v: string): string {
const cleaned = v
.trim()
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, '-')
.replace(/-+/g, '-')
.replace(/^[-_.]+|[-_.]+$/g, '');
return cleaned || 'module';
}
export function communityLabel(id: string | null, communities: BgpCommunity[]): string {
if (!id) return '—';
const c = communities.find((x) => x.id === id);
if (!c) return id.slice(0, 8) + '…';
const t = c.title?.trim();
return t || c.community;
}
export function communityOptionLabel(c: BgpCommunity): string {
const t = c.title?.trim();
return t || c.community;
}
export function nullableSelectValue(value: string | null | undefined): string {
if (value === null || value === undefined || value === '') return NONE_OPTION;
return value;
}
export function fromNullableSelect(value: string): string | null {
if (value === NONE_OPTION || value === '') return null;
return value;
}
export function moduleDohProfileIds(modRow: ModuleRow | null): string[] {
if (!modRow) return [];
if (modRow.doh_profile_ids?.length) return modRow.doh_profile_ids;
return modRow.doh_profile_id ? [modRow.doh_profile_id] : [];
}
export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
const p = dohProfiles.find((d) => d.id === id);
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : id.slice(0, 8) + '…';
}
export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext';
}
export function syncSelection(selected: Set<string>, existingIds: string[]): Set<string> {
const validIds = new Set(existingIds);
return new Set([...selected].filter((id) => validIds.has(id)));
}
@@ -0,0 +1,687 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { AuthSession } from '$lib/api/types.js';
import type { PostgresTableRow } from '$lib/monitoring/postgres.js';
import {
createMaintenancePolicy,
deleteMaintenancePolicy,
fetchPolicyHints,
listMaintenancePolicies,
runMaintenancePolicy,
updateMaintenancePolicy,
type MaintenancePolicy,
type MaintenancePolicyHints
} from '$lib/maintenance/policy-api.js';
import {
emptyMaintenancePolicyForm,
formToPayload,
vacuumStrategies,
type MaintenancePolicyForm
} from '$lib/maintenance/policy.schema.js';
import {
filterAvailablePresets,
isPresetAlreadyApplied,
maintenancePolicyPresets,
presetForm,
type MaintenancePolicyPreset
} from '$lib/maintenance/policy-presets.js';
import {
applyScheduleEditor,
cronToEditor,
defaultScheduleEditor,
describeCron,
scheduleModeOptions,
weekdayOptions,
type ScheduleEditor,
type ScheduleMode
} from '$lib/maintenance/policy-schedule.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '@evobgp/ui/components/dialog/index.js';
import { Switch } from '@evobgp/ui/components/switch/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import AppInput from '$lib/components/patterns/form/app-input.svelte';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import type { DataTableColumn } from '$lib/components/patterns/data-table/types.js';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Play from '@lucide/svelte/icons/play';
import FlaskConical from '@lucide/svelte/icons/flask-conical';
import Info from '@lucide/svelte/icons/info';
import Layers from '@lucide/svelte/icons/layers';
type Props = {
session: AuthSession | null;
tables: PostgresTableRow[];
onJobQueued?: () => void | Promise<void>;
};
let { session, tables = [], onJobQueued }: Props = $props();
let policies = $state<MaintenancePolicy[]>([]);
let loading = $state(true);
let dialogOpen = $state(false);
let editTarget = $state<MaintenancePolicy | null>(null);
let form = $state<MaintenancePolicyForm>(emptyMaintenancePolicyForm());
let saving = $state(false);
let hints = $state<MaintenancePolicyHints | null>(null);
let hintsLoading = $state(false);
let selectedPresetIds = $state<string[]>([]);
let applyingPresets = $state(false);
let activePresetId = $state<string | null>(null);
let scheduleEditor = $state<ScheduleEditor>(defaultScheduleEditor());
const schedulePreview = $derived(describeCron(form.schedule));
function loadScheduleEditor(cron: string) {
scheduleEditor = cronToEditor(cron);
}
function patchSchedule(patch: Partial<ScheduleEditor>) {
scheduleEditor = { ...scheduleEditor, ...patch };
syncScheduleToForm();
}
function setScheduleMode(mode: ScheduleMode) {
patchSchedule({ mode });
}
function syncScheduleToForm() {
const { editor, cron } = applyScheduleEditor(scheduleEditor);
scheduleEditor = editor;
form.schedule = cron;
}
function onCustomCronInput(value: string) {
scheduleEditor = { ...scheduleEditor, customCron: value };
form.schedule = value.trim() || '0 3 * * *';
}
const creatablePresets = $derived(filterAvailablePresets(policies, selectedPresetIds));
function togglePresetSelection(id: string, checked: boolean) {
if (checked) {
if (!selectedPresetIds.includes(id)) {
selectedPresetIds = [...selectedPresetIds, id];
}
} else {
selectedPresetIds = selectedPresetIds.filter((x) => x !== id);
}
}
function applyPresetToForm(preset: MaintenancePolicyPreset) {
form = presetForm(preset);
activePresetId = preset.id;
loadScheduleEditor(form.schedule);
}
async function createSelectedPresets() {
const toCreate = creatablePresets;
if (toCreate.length === 0) {
notify.error('Выберите пресеты, которые ещё не созданы');
return;
}
applyingPresets = true;
let created = 0;
try {
for (const preset of toCreate) {
await createMaintenancePolicy(formToPayload(preset.form));
created++;
}
selectedPresetIds = selectedPresetIds.filter((id) => !toCreate.some((p) => p.id === id));
notify.success(`Создано политик: ${created}`);
await loadPolicies();
} catch (e) {
notifyApiError(e, created > 0 ? `Создано ${created} из ${toCreate.length}` : undefined);
if (created > 0) await loadPolicies();
} finally {
applyingPresets = false;
}
}
const isOperator = $derived(session?.role === 'operator');
const tableOptions = $derived.by(() => {
const names = new Set(tables.map((t) => t.relname));
if (form.table_name.trim()) names.add(form.table_name.trim());
return [...names].sort();
});
const columns: DataTableColumn<MaintenancePolicy>[] = [
{ id: 'name', label: 'Название', sortable: true, sortValue: (p) => p.name },
{ id: 'table_name', label: 'Таблица', sortable: true, sortValue: (p) => p.table_name },
{ id: 'schedule', label: 'Cron (UTC)' },
{ id: 'status', label: 'Статус' },
{ id: 'actions', label: '', class: 'w-40' }
];
async function loadPolicies() {
loading = true;
try {
policies = await listMaintenancePolicies();
} catch (e) {
notifyApiError(e, 'Не удалось загрузить политики');
} finally {
loading = false;
}
}
function openCreate() {
editTarget = null;
form = emptyMaintenancePolicyForm();
hints = null;
activePresetId = null;
loadScheduleEditor(form.schedule);
dialogOpen = true;
}
function openEdit(p: MaintenancePolicy) {
editTarget = p;
form = {
name: p.name,
table_name: p.table_name,
condition: p.condition || 'true',
retention_period_sec: p.retention_period_sec ? String(p.retention_period_sec) : '',
max_rows: p.max_rows ? String(p.max_rows) : '',
vacuum_strategy: (vacuumStrategies.includes(
p.vacuum_strategy as (typeof vacuumStrategies)[number]
)
? p.vacuum_strategy
: 'none') as MaintenancePolicyForm['vacuum_strategy'],
schedule: p.schedule,
enabled: p.enabled,
dry_run_enabled: p.dry_run_enabled
};
loadScheduleEditor(form.schedule);
hints = null;
dialogOpen = true;
void loadHints(p.id);
}
async function loadHints(id: string) {
hintsLoading = true;
try {
hints = await fetchPolicyHints(id);
} catch {
hints = null;
} finally {
hintsLoading = false;
}
}
function requestDelete(p: MaintenancePolicy) {
void confirm({
title: `Удалить политику «${p.name}»?`,
description: 'Расписание и очистка по этой политике прекратятся.',
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await deleteMaintenancePolicy(p.id);
notify.success('Политика удалена');
await loadPolicies();
}
});
}
async function save() {
if (!form.name.trim() || !form.table_name.trim() || !form.schedule.trim()) {
notify.error('Заполните обязательные поля');
return;
}
saving = true;
try {
const payload = formToPayload(form);
if (editTarget) {
await updateMaintenancePolicy(editTarget.id, payload);
notify.success('Политика обновлена');
} else {
await createMaintenancePolicy(payload);
notify.success('Политика создана');
}
dialogOpen = false;
await loadPolicies();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
function queueRun(p: MaintenancePolicy, dryRun: boolean) {
void confirm({
title: dryRun ? `Dry-run: ${p.name}` : `Запуск: ${p.name}`,
description: dryRun
? 'Изменения в БД не применяются — только оценка.'
: 'Задача будет поставлена в очередь jobs.',
confirmLabel: dryRun ? 'Dry-run' : 'Запустить',
destructive: !dryRun,
onConfirm: async () => {
const res = await runMaintenancePolicy(p.id, dryRun);
notify.success(`Задача ${res.job_id}`);
await onJobQueued?.();
}
});
}
function statusBadge(p: MaintenancePolicy) {
if (!p.enabled) return 'выкл';
if (p.dry_run_enabled) return 'dry-run sched';
return p.last_status || '—';
}
onMount(() => {
void loadPolicies();
});
</script>
{#if !isOperator}
<Alert>
<AlertTitle>Только operator</AlertTitle>
<AlertDescription>Политики обслуживания БД настраиваются с ролью operator.</AlertDescription>
</Alert>
{/if}
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<CardTitle>Политики обслуживания</CardTitle>
<CardDescription>
Единственный источник конфигурации retention, vacuum и расписания (UTC cron).
</CardDescription>
</div>
{#if isOperator}
<Button size="sm" onclick={openCreate}><Plus class="size-4" /> Новая политика</Button>
{/if}
</CardHeader>
<CardContent class="flex flex-col gap-4 pt-4">
{#if isOperator}
<div class="rounded-lg border border-border/80 bg-muted/20 p-4">
<div class="mb-3 flex flex-wrap items-start justify-between gap-2">
<div>
<p class="flex items-center gap-2 text-sm font-medium">
<Layers class="size-4 text-muted-foreground" />
Пресеты стратегий
</p>
<p class="mt-1 text-xs text-muted-foreground">
Выберите шаблоны и создайте политики одним действием или примените шаблон в форме.
</p>
</div>
<Button
size="sm"
disabled={creatablePresets.length === 0 || applyingPresets}
onclick={createSelectedPresets}
>
{applyingPresets ? 'Создание…' : `Создать выбранные (${creatablePresets.length})`}
</Button>
</div>
<div class="grid gap-2 sm:grid-cols-2">
{#each maintenancePolicyPresets as preset (preset.id)}
{@const applied = isPresetAlreadyApplied(preset, policies)}
{@const checked = selectedPresetIds.includes(preset.id)}
<label
class="flex cursor-pointer gap-3 rounded-md border border-border/60 bg-background p-3 transition-colors hover:bg-muted/30 has-[:disabled]:cursor-not-allowed has-[:disabled]:opacity-60"
>
<Checkbox
id="preset-{preset.id}"
{checked}
disabled={applied}
onCheckedChange={(v) => togglePresetSelection(preset.id, v === true)}
/>
<span class="min-w-0 flex-1 space-y-1">
<span class="flex flex-wrap items-center gap-2">
<span class="text-sm font-medium">{preset.label}</span>
{#if applied}
<Badge variant="outline" class="text-xs">уже есть</Badge>
{/if}
</span>
<span class="block text-xs text-muted-foreground">{preset.description}</span>
<span class="block font-mono text-[11px] text-muted-foreground">
{preset.form.table_name} · {describeCron(preset.form.schedule)}
</span>
</span>
<Button
type="button"
variant="ghost"
size="sm"
class="shrink-0 self-start"
disabled={!isOperator}
onclick={(e) => {
e.preventDefault();
e.stopPropagation();
editTarget = null;
applyPresetToForm(preset);
dialogOpen = true;
}}
>
В форму
</Button>
</label>
{/each}
</div>
</div>
{/if}
<AppDataTable
{columns}
rows={policies}
rowKey={(p) => p.id}
{loading}
emptyTitle="Политики не созданы"
emptyDescription="Добавьте первую политику через UI — это единственный способ настройки."
>
{#snippet cell({ row, column })}
{#if column.id === 'status'}
<Badge variant={row.enabled ? 'secondary' : 'outline'}>{statusBadge(row)}</Badge>
{#if row.last_run_at}
<p class="mt-1 text-xs text-muted-foreground">{row.last_run_at}</p>
{/if}
{:else if column.id === 'actions' && isOperator}
<div class="flex flex-wrap gap-1">
<Button
variant="ghost"
size="icon-sm"
onclick={() => openEdit(row)}
aria-label="Изменить"
>
<Pencil class="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => queueRun(row, true)}
aria-label="Dry-run"
>
<FlaskConical class="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => queueRun(row, false)}
aria-label="Run"
>
<Play class="size-4" />
</Button>
<Button
variant="ghost"
size="icon-sm"
onclick={() => requestDelete(row)}
aria-label="Удалить"
>
<Trash2 class="size-4" />
</Button>
</div>
{:else if column.id === 'name'}
{row.name}
{:else if column.id === 'table_name'}
{row.table_name}
{:else if column.id === 'schedule'}
<div class="space-y-0.5">
<span class="text-sm">{describeCron(row.schedule)}</span>
<span class="block font-mono text-[11px] text-muted-foreground">{row.schedule}</span>
</div>
{:else if column.id !== 'actions'}
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="max-h-[90vh] overflow-y-auto sm:max-w-lg">
<DialogHeader>
<DialogTitle>{editTarget ? 'Изменить политику' : 'Новая политика'}</DialogTitle>
</DialogHeader>
{#if hints?.recommend_vacuum}
<Alert class="border-warning/30 bg-warning/5">
<Info class="text-warning" />
<AlertTitle>Рекомендация</AlertTitle>
<AlertDescription>{hints.detail ?? 'Рекомендуется VACUUM.'}</AlertDescription>
</Alert>
{:else if hintsLoading}
<p class="text-sm text-muted-foreground">Загрузка подсказок pg_stat…</p>
{/if}
{#if !editTarget}
<div class="space-y-2">
<p class="text-sm font-medium">Шаблон (опционально)</p>
<div class="flex flex-wrap gap-2">
{#each maintenancePolicyPresets as preset (preset.id)}
<Button
type="button"
variant={activePresetId === preset.id ? 'secondary' : 'outline'}
size="sm"
class="h-auto max-w-full py-1.5 text-left whitespace-normal"
disabled={!isOperator}
onclick={() => applyPresetToForm(preset)}
>
{preset.label}
</Button>
{/each}
</div>
<p class="text-xs text-muted-foreground">
Поля формы заполняются из шаблона; перед сохранением можно изменить любое значение.
</p>
</div>
{/if}
<div class="grid gap-4 py-2">
<FormField label="Название" id="mp-name" required>
<AppInput bind:value={form.name} disabled={!isOperator} />
</FormField>
<FormField label="Таблица" id="mp-table" required>
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
bind:value={form.table_name}
disabled={!isOperator}
>
<option value="">— выберите —</option>
{#each tableOptions as name (name)}
<option value={name}>{name}</option>
{/each}
</select>
</FormField>
<FormField label="Condition (SQL WHERE)" id="mp-condition" required>
<textarea
class="min-h-20 w-full rounded-md border border-input bg-background px-3 py-2 font-mono text-xs"
bind:value={form.condition}
disabled={!isOperator}
></textarea>
</FormField>
<div class="grid gap-4 sm:grid-cols-2">
<FormField label="Retention (сек)" id="mp-retention">
<AppInput bind:value={form.retention_period_sec} type="number" disabled={!isOperator} />
</FormField>
<FormField label="Max rows (batch)" id="mp-max-rows">
<AppInput bind:value={form.max_rows} type="number" disabled={!isOperator} />
</FormField>
</div>
<FormField label="Vacuum strategy" id="mp-vacuum">
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
bind:value={form.vacuum_strategy}
disabled={!isOperator}
>
{#each vacuumStrategies as s (s)}
<option value={s}>{s}</option>
{/each}
</select>
</FormField>
<FormField label="Расписание (UTC)" id="mp-schedule" required>
<div class="grid gap-3">
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={scheduleEditor.mode}
disabled={!isOperator}
onchange={(e) => setScheduleMode(e.currentTarget.value as ScheduleMode)}
>
{#each scheduleModeOptions as opt (opt.value)}
<option value={opt.value}>{opt.label}</option>
{/each}
</select>
{#if scheduleEditor.mode === 'minutes'}
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">Каждые</span>
<AppInput
class="w-24"
type="number"
min="1"
max="59"
value={scheduleEditor.intervalMinutes}
disabled={!isOperator}
oninput={(e) => patchSchedule({ intervalMinutes: e.currentTarget.value })}
/>
<span class="text-muted-foreground">минут</span>
</div>
{:else if scheduleEditor.mode === 'hours'}
<div class="grid gap-3 sm:grid-cols-2">
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">Каждые</span>
<AppInput
class="w-24"
type="number"
min="1"
max="23"
value={scheduleEditor.intervalHours}
disabled={!isOperator}
oninput={(e) => patchSchedule({ intervalHours: e.currentTarget.value })}
/>
<span class="text-muted-foreground">часов</span>
</div>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">В минуту</span>
<AppInput
class="w-24"
type="number"
min="0"
max="59"
value={scheduleEditor.minute}
disabled={!isOperator}
oninput={(e) => patchSchedule({ minute: e.currentTarget.value })}
/>
<span class="text-muted-foreground">часа (059)</span>
</div>
</div>
{:else if scheduleEditor.mode === 'daily'}
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">В</span>
<AppInput
class="w-20"
type="number"
min="0"
max="23"
value={scheduleEditor.hour}
disabled={!isOperator}
oninput={(e) => patchSchedule({ hour: e.currentTarget.value })}
/>
<span class="text-muted-foreground">:</span>
<AppInput
class="w-20"
type="number"
min="0"
max="59"
value={scheduleEditor.minute}
disabled={!isOperator}
oninput={(e) => patchSchedule({ minute: e.currentTarget.value })}
/>
<span class="text-muted-foreground">UTC</span>
</div>
{:else if scheduleEditor.mode === 'weekly'}
<div class="grid gap-3">
<select
class="flex h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={scheduleEditor.weekday}
disabled={!isOperator}
onchange={(e) => patchSchedule({ weekday: e.currentTarget.value })}
>
{#each weekdayOptions as wd (wd.value)}
<option value={wd.value}>{wd.label}</option>
{/each}
</select>
<div class="flex flex-wrap items-center gap-2 text-sm">
<span class="text-muted-foreground">В</span>
<AppInput
class="w-20"
type="number"
min="0"
max="23"
value={scheduleEditor.hour}
disabled={!isOperator}
oninput={(e) => patchSchedule({ hour: e.currentTarget.value })}
/>
<span class="text-muted-foreground">:</span>
<AppInput
class="w-20"
type="number"
min="0"
max="59"
value={scheduleEditor.minute}
disabled={!isOperator}
oninput={(e) => patchSchedule({ minute: e.currentTarget.value })}
/>
<span class="text-muted-foreground">UTC</span>
</div>
</div>
{:else}
<AppInput
class="font-mono"
value={scheduleEditor.customCron}
disabled={!isOperator}
placeholder="0 3 * * *"
oninput={(e) => onCustomCronInput(e.currentTarget.value)}
/>
<p class="text-xs text-muted-foreground">
5 полей: минута час день месяц день_недели (UTC). Пример: <code>0 6 * * 0</code> — воскресенье
06:00.
</p>
{/if}
<p class="rounded-md bg-muted/40 px-3 py-2 text-xs text-muted-foreground">
<span class="font-medium text-foreground">{schedulePreview}</span>
<span class="mt-1 block font-mono">{form.schedule}</span>
</p>
</div>
</FormField>
<div class="flex flex-wrap gap-6">
<div class="flex items-center gap-2">
<Switch id="mp-enabled" bind:checked={form.enabled} disabled={!isOperator} />
<Label for="mp-enabled">Включена</Label>
</div>
<div class="flex items-center gap-2">
<Switch id="mp-dry" bind:checked={form.dry_run_enabled} disabled={!isOperator} />
<Label for="mp-dry">Scheduler только dry-run</Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
{#if isOperator}
<Button onclick={save} disabled={saving}>{saving ? 'Сохранение…' : 'Сохранить'}</Button>
{/if}
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,436 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON } from '$lib/api/client.js';
import type { AuthSession } from '$lib/api/types.js';
import { notifyApiError } from '$lib/ui/app/toast.js';
import MaintenancePoliciesTab from '$lib/components/monitoring/MaintenancePoliciesTab.svelte';
import {
POSTGRES_POLL_MS,
POSTGRES_SLOW_POLL_MS,
formatBytes,
connUsagePct,
type PostgresOverview,
type PostgresQueriesResponse,
type PostgresLockRow,
type PostgresTableRow,
type PostgresRecommendationsResponse,
type PostgresMaintLog,
type CorrelationResponse
} from '$lib/monitoring/postgres.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@evobgp/ui/components/table/index.js';
import { Switch } from '@evobgp/ui/components/switch/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import Database from '@lucide/svelte/icons/database';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
let pgTab = $state('overview');
let autoRefresh = $state(true);
let session = $state<AuthSession | null>(null);
let unavailable = $state(false);
let overview = $state<PostgresOverview | null>(null);
let queries = $state<PostgresQueriesResponse | null>(null);
let locks = $state<PostgresLockRow[]>([]);
let tables = $state<PostgresTableRow[]>([]);
let recommendations = $state<PostgresRecommendationsResponse | null>(null);
let maintLogs = $state<PostgresMaintLog[]>([]);
let correlation = $state<CorrelationResponse | null>(null);
let loading = $state(true);
async function loadCore() {
try {
overview = await apiJSON<PostgresOverview>('/v1/monitoring/postgres/overview');
locks = (await apiJSON<{ items: PostgresLockRow[] }>('/v1/monitoring/postgres/locks')).items;
unavailable = false;
} catch (e) {
unavailable = true;
overview = null;
throw e;
}
}
async function loadSlow() {
queries = await apiJSON<PostgresQueriesResponse>('/v1/monitoring/postgres/queries?limit=20');
tables = (
await apiJSON<{ items: PostgresTableRow[] }>('/v1/monitoring/postgres/tables?limit=30')
).items;
recommendations = await apiJSON<PostgresRecommendationsResponse>(
'/v1/monitoring/postgres/recommendations'
);
correlation = await apiJSON<CorrelationResponse>('/v1/monitoring/correlation?window=60');
maintLogs = (
await apiJSON<{ items: PostgresMaintLog[] }>('/v1/postgres/maintenance/logs?limit=20')
).items;
}
async function loadAll() {
loading = true;
try {
await loadCore();
await loadSlow();
} catch (e) {
notifyApiError(e, 'PostgreSQL monitoring');
} finally {
loading = false;
}
}
onMount(() => {
void (async () => {
try {
session = await apiJSON<AuthSession>('/v1/auth/session');
} catch {
session = null;
}
await loadAll();
})();
});
$effect(() => {
if (!autoRefresh || unavailable) return;
const fast = setInterval(() => {
void loadCore().catch(() => {});
}, POSTGRES_POLL_MS);
const slow = setInterval(() => {
void loadSlow().catch(() => {});
}, POSTGRES_SLOW_POLL_MS);
return () => {
clearInterval(fast);
clearInterval(slow);
};
});
</script>
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Database class="size-4" />
<span>Instance-level PostgreSQL (control plane)</span>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2">
<Switch id="pg-auto" bind:checked={autoRefresh} />
<Label for="pg-auto">Автообновление</Label>
</div>
<Button variant="outline" size="sm" onclick={() => loadAll()} disabled={loading}>
<RefreshCw class="mr-1 size-4 {loading ? 'animate-spin' : ''}" />
Обновить
</Button>
</div>
</div>
{#if unavailable}
<Alert variant="destructive" class="mt-4">
<AlertTitle>PostgreSQL недоступен</AlertTitle>
<AlertDescription>
Мониторинг требует <code class="text-xs">EVOBGP_DATABASE_URL</code> (не memory backend).
</AlertDescription>
</Alert>
{:else}
<Tabs bind:value={pgTab} class="mt-4">
<TabsList>
<TabsTrigger value="overview">Обзор</TabsTrigger>
<TabsTrigger value="queries">Запросы</TabsTrigger>
<TabsTrigger value="locks">Блокировки</TabsTrigger>
<TabsTrigger value="tables">Таблицы</TabsTrigger>
<TabsTrigger value="maintenance">Обслуживание</TabsTrigger>
<TabsTrigger value="correlation">Корреляция</TabsTrigger>
</TabsList>
<TabsContent value="overview" class="mt-4 space-y-4">
{#if overview}
<div class="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">Подключения</CardTitle>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold tabular-nums">
{overview.connections.active} / {overview.connections.max_connections}
</p>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-chart-1 transition-all"
style="width: {connUsagePct(overview)}%"
></div>
</div>
<p class="mt-1 text-xs text-muted-foreground">
idle {overview.connections.idle}, total {overview.connections.total}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">Cache hit</CardTitle>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold tabular-nums">
{overview.database.cache_hit_pct ?? '—'}%
</p>
<div class="mt-2 h-2 overflow-hidden rounded-full bg-muted">
<div
class="h-full bg-chart-2 transition-all"
style="width: {overview.database.cache_hit_pct ?? 0}%"
></div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">TPS (commits)</CardTitle>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold tabular-nums">
{overview.database.xact_commit.toLocaleString()}
</p>
<p class="text-xs text-muted-foreground">
rollback {overview.database.xact_rollback.toLocaleString()}, deadlocks {overview
.database.deadlocks}
</p>
</CardContent>
</Card>
<Card>
<CardHeader class="pb-2">
<CardTitle class="text-sm font-medium">Размер БД</CardTitle>
</CardHeader>
<CardContent>
<p class="text-2xl font-semibold">{formatBytes(overview.database_size_bytes)}</p>
<p class="text-xs text-muted-foreground">
shared_buffers {overview.memory_settings.shared_buffers}
</p>
</CardContent>
</Card>
</div>
{#if overview.replication?.length}
<Card>
<CardHeader>
<CardTitle>Репликация</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Адрес</TableHead>
<TableHead>Состояние</TableHead>
<TableHead>Lag ms</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each overview.replication as r (r.client_addr ?? r.state)}
<TableRow>
<TableCell>{r.client_addr ?? '—'}</TableCell>
<TableCell>{r.state}</TableCell>
<TableCell>{r.lag_ms ?? '—'}</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
{/if}
{/if}
</TabsContent>
<TabsContent value="queries" class="mt-4">
<Card>
<CardHeader>
<CardTitle>Медленные запросы</CardTitle>
<CardDescription>
Источник: {queries?.source ?? '—'}
{#if queries?.statements_available === false || (overview && !overview.pg_stat_statements_enabled)}
· pg_stat_statements недоступен
{/if}
</CardDescription>
</CardHeader>
<CardContent>
{#if queries?.statements_hint}
<Alert class="mb-4">
<AlertTitle>Нет статистики запросов</AlertTitle>
<AlertDescription>{queries.statements_hint}</AlertDescription>
</Alert>
{/if}
<Table>
<TableHeader>
<TableRow>
<TableHead>mean ms</TableHead>
<TableHead>calls</TableHead>
<TableHead>query</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each queries?.items ?? [] as q (q.queryid ?? q.query)}
<TableRow>
<TableCell class="tabular-nums">{q.mean_exec_ms.toFixed(1)}</TableCell>
<TableCell>{q.calls}</TableCell>
<TableCell class="max-w-md truncate font-mono text-xs">{q.query}</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={3} class="text-muted-foreground">Нет данных</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="locks" class="mt-4">
<Card>
<CardHeader>
<CardTitle>Блокировки</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>pid</TableHead>
<TableHead>mode</TableHead>
<TableHead>granted</TableHead>
<TableHead>query</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each locks as l (l.pid)}
<TableRow>
<TableCell>{l.pid}</TableCell>
<TableCell>
<Badge variant={l.blocked ? 'destructive' : 'secondary'}>{l.mode}</Badge>
</TableCell>
<TableCell>{l.granted ? 'да' : 'нет'}</TableCell>
<TableCell class="max-w-lg truncate font-mono text-xs">{l.query ?? '—'}</TableCell
>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground"
>Нет активных блокировок</TableCell
>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tables" class="mt-4 space-y-4">
<Card>
<CardHeader>
<CardTitle>Таблицы и хранилище</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>table</TableHead>
<TableHead>size</TableHead>
<TableHead>seq_scan</TableHead>
<TableHead>idx_scan</TableHead>
<TableHead>bloat</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each tables as t (t.relname)}
<TableRow>
<TableCell class="font-mono text-xs">{t.relname}</TableCell>
<TableCell>{formatBytes(t.total_bytes)}</TableCell>
<TableCell>{t.seq_scan}</TableCell>
<TableCell>{t.idx_scan}</TableCell>
<TableCell>{(t.bloat_ratio ?? 0).toFixed(2)}</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
{#if recommendations?.items?.length}
<Card>
<CardHeader>
<CardTitle>Рекомендации</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
{#each recommendations.items as item (item.code + item.title)}
<Alert>
<AlertTitle>{item.title}</AlertTitle>
<AlertDescription>{item.detail}</AlertDescription>
</Alert>
{/each}
</CardContent>
</Card>
{/if}
</TabsContent>
<TabsContent value="maintenance" class="mt-4 space-y-4">
<MaintenancePoliciesTab {session} {tables} onJobQueued={loadSlow} />
<Card>
<CardHeader>
<CardTitle>Журнал обслуживания</CardTitle>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>время</TableHead>
<TableHead>kind</TableHead>
<TableHead>status</TableHead>
<TableHead>dry_run</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each maintLogs as log (log.id)}
<TableRow>
<TableCell class="text-xs">{log.created_at}</TableCell>
<TableCell>{log.kind}</TableCell>
<TableCell>{log.status}</TableCell>
<TableCell>{log.dry_run ? 'да' : 'нет'}</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground">Пусто</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="correlation" class="mt-4">
<Card>
<CardHeader>
<CardTitle>Корреляция (1ч)</CardTitle>
<CardDescription>Pipeline refresh p99 vs cache hit по минутам</CardDescription>
</CardHeader>
<CardContent class="space-y-3">
{#each correlation?.points ?? [] as p (p.timestamp)}
<div class="grid gap-2 rounded-md border p-2 text-xs md:grid-cols-3">
<span>{p.timestamp}</span>
<span>p99 refresh: {p.pipeline_refresh_p99_ms?.toFixed(0) ?? '—'} ms</span>
<span>cache hit: {p.cache_hit_pct?.toFixed(1) ?? '—'}%</span>
</div>
{:else}
<p class="text-muted-foreground">Нет точек за окно</p>
{/each}
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/if}
@@ -0,0 +1,451 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON } from '$lib/api/client.js';
import type { AuthSession } from '$lib/api/types.js';
import {
cleanupRuntimeLogFile,
getRuntimeLogTail,
isRuntimeLogsUnavailable,
listRuntimeLogCleanupAudit,
listRuntimeLogFiles,
type RuntimeLogCleanupAudit,
type RuntimeLogCleanupMode,
type RuntimeLogFile
} from '$lib/runtime-logs/runtime-logs-api.js';
import { formatBytes } from '$lib/monitoring/postgres.js';
import { formatDateTime } from '$lib/modules/display.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle
} from '@evobgp/ui/components/dialog/index.js';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@evobgp/ui/components/dropdown-menu/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import type { DataTableColumn } from '$lib/components/patterns/data-table/types.js';
import EmptyState from '$lib/components/patterns/empty-state/empty-state.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import {
dialogBodyDocument,
dialogContentDocument,
dialogHeaderDocument
} from '$lib/dialog-layout.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Eye from '@lucide/svelte/icons/eye';
import Eraser from '@lucide/svelte/icons/eraser';
import Trash2 from '@lucide/svelte/icons/trash-2';
import HardDrive from '@lucide/svelte/icons/hard-drive';
import FileText from '@lucide/svelte/icons/file-text';
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
type SubTab = 'files' | 'audit';
let subTab = $state<SubTab>('files');
let session = $state<AuthSession | null>(null);
let filesUnavailable = $state(false);
let filesLoading = $state(true);
let files = $state<RuntimeLogFile[]>([]);
let auditLoading = $state(false);
let auditError = $state<string | null>(null);
let auditItems = $state<RuntimeLogCleanupAudit[]>([]);
let auditCursor = $state<string | undefined>(undefined);
let auditHasMore = $state(false);
let previewOpen = $state(false);
let previewFilename = $state('');
let previewLoading = $state(false);
let previewContent = $state('');
let previewTruncated = $state(false);
let previewLines = $state(0);
let cleaningFilename = $state<string | null>(null);
const isOperator = $derived(session?.role === 'operator');
const totalBytes = $derived(files.reduce((sum, f) => sum + (f.size_bytes ?? 0), 0));
const fileColumns: DataTableColumn<RuntimeLogFile>[] = [
{ id: 'name', label: 'Файл', sortable: true, sortValue: (f) => f.name },
{
id: 'size',
label: 'Размер',
sortable: true,
sortValue: (f) => f.size_bytes
},
{
id: 'modified',
label: 'Изменён',
sortable: true,
sortValue: (f) => f.modified_at
},
{ id: 'actions', label: '', class: 'w-36' }
];
const auditColumns: DataTableColumn<RuntimeLogCleanupAudit>[] = [
{
id: 'created',
label: 'Время',
sortable: true,
sortValue: (r) => r.created_at
},
{ id: 'source', label: 'Источник' },
{ id: 'actor', label: 'Actor' },
{ id: 'filename', label: 'Файл', sortable: true, sortValue: (r) => r.filename },
{ id: 'action', label: 'Действие' },
{ id: 'sizes', label: 'Размер' }
];
async function loadSession() {
try {
session = await apiJSON<AuthSession>('/v1/auth/session');
} catch {
session = null;
}
}
async function loadFiles() {
filesLoading = true;
try {
files = await listRuntimeLogFiles();
filesUnavailable = false;
} catch (e) {
if (isRuntimeLogsUnavailable(e)) {
filesUnavailable = true;
files = [];
return;
}
notifyApiError(e, 'Файловые логи');
} finally {
filesLoading = false;
}
}
async function loadAudit(reset = true) {
auditLoading = true;
if (reset) auditError = null;
try {
const page = await listRuntimeLogCleanupAudit({
limit: 20,
cursor: reset ? undefined : auditCursor
});
auditItems = reset ? (page.items ?? []) : [...auditItems, ...(page.items ?? [])];
auditCursor = page.next_cursor;
auditHasMore = Boolean(page.has_more && page.next_cursor);
auditError = null;
} catch (e) {
auditError = e instanceof Error ? e.message : 'Не удалось загрузить audit';
if (reset) auditItems = [];
notifyApiError(e, 'Audit очистки логов');
} finally {
auditLoading = false;
}
}
function auditSourceLabel(actor: string): string {
if (actor.startsWith('auto:')) return 'Авто';
if (actor.startsWith('op:')) return 'Оператор';
return '—';
}
async function refreshAll() {
await Promise.all([loadFiles(), loadAudit(true)]);
}
async function openPreview(file: RuntimeLogFile) {
previewFilename = file.name;
previewOpen = true;
previewLoading = true;
previewContent = '';
try {
const tail = await getRuntimeLogTail(file.name, { lines: 200 });
previewContent = tail.content;
previewTruncated = tail.truncated;
previewLines = tail.lines_returned;
} catch (e) {
notifyApiError(e);
previewOpen = false;
} finally {
previewLoading = false;
}
}
function requestCleanup(file: RuntimeLogFile, mode: RuntimeLogCleanupMode) {
const actionLabel = mode === 'delete' ? 'удалить файл' : 'обнулить (truncate)';
void confirm({
title: mode === 'delete' ? 'Удалить log-файл?' : 'Очистить log-файл?',
description: `${file.name} · ${formatBytes(file.size_bytes)}. Действие: ${actionLabel}. Операция синхронная.`,
confirmLabel: mode === 'delete' ? 'Удалить' : 'Очистить',
destructive: true,
onConfirm: async () => {
cleaningFilename = file.name;
try {
await cleanupRuntimeLogFile(file.name, mode);
notify.success(mode === 'delete' ? 'Файл удалён' : 'Файл обнулён');
await refreshAll();
} catch (e) {
notifyApiError(e);
} finally {
cleaningFilename = null;
}
}
});
}
function actionBadgeVariant(action: RuntimeLogCleanupMode): 'secondary' | 'destructive' {
return action === 'delete' ? 'destructive' : 'secondary';
}
onMount(() => {
void (async () => {
await loadSession();
await loadFiles();
await loadAudit(true);
})();
});
</script>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<HardDrive class="size-4" />
<span>Файловые логи Docker-сервисов (sidecar stack-runtime-logs)</span>
</div>
<Button
variant="outline"
size="sm"
onclick={() => refreshAll()}
disabled={filesLoading || auditLoading}
>
<RefreshCw class={filesLoading || auditLoading ? 'animate-spin' : ''} />
Обновить
</Button>
</div>
{#if filesUnavailable}
<EmptyState
icon={HardDrive}
title="Файловые логи недоступны"
description="Список и очистка *.log доступны только на evobgp-all с примонтированным каталогом runtime-logs (EVOBGP_RUNTIME_LOGS_DIR и EVOBGP_SERVICE=evobgp-all). См. docs/access.md в репозитории. Вкладка audit очистки ниже работает без volume."
/>
{/if}
{#if !filesUnavailable}
<div class="grid gap-3 sm:grid-cols-2">
<Card>
<CardHeader class="pb-2">
<CardDescription>Файлов</CardDescription>
<CardTitle class="text-2xl tabular-nums">{files.length}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader class="pb-2">
<CardDescription>Суммарный размер</CardDescription>
<CardTitle class="text-2xl tabular-nums">{formatBytes(totalBytes)}</CardTitle>
</CardHeader>
</Card>
</div>
{/if}
<Tabs bind:value={subTab}>
<TabsList>
<TabsTrigger value="files">Файлы</TabsTrigger>
<TabsTrigger value="audit">Audit очистки</TabsTrigger>
</TabsList>
<TabsContent value="files" class="mt-4">
{#if filesUnavailable}
<EmptyState
icon={FileText}
title="FS API отключён"
description="Примонтируйте runtime-logs к evobgp-all и задайте EVOBGP_RUNTIME_LOGS_DIR."
/>
{:else}
<Card>
<CardHeader>
<CardTitle class="text-base">*.log на хосте</CardTitle>
<CardDescription>
Просмотр хвоста и синхронная очистка (truncate по умолчанию). Очистка — роль operator.
</CardDescription>
</CardHeader>
<CardContent class="min-w-0 p-4 pt-0">
<AppDataTable
columns={fileColumns}
rows={files}
rowKey={(f) => f.name}
loading={filesLoading}
emptyTitle="Нет log-файлов"
emptyDescription="Sidecar ещё не создал файлы или каталог пуст."
>
{#snippet cell({ row, column })}
{#if column.id === 'name'}
<span class="font-mono text-xs">{row.name}</span>
{:else if column.id === 'size'}
{formatBytes(row.size_bytes)}
{:else if column.id === 'modified'}
<span class="text-sm">{formatDateTime(row.modified_at)}</span>
{:else if column.id === 'actions'}
<div class="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon-sm"
aria-label="Просмотр"
onclick={() => openPreview(row)}
>
<Eye class="size-3.5" />
</Button>
{#if isOperator}
<Button
variant="ghost"
size="icon-sm"
aria-label="Очистить (truncate)"
disabled={cleaningFilename === row.name}
onclick={() => requestCleanup(row, 'truncate')}
>
{#if cleaningFilename === row.name}
<LoaderCircle class="size-3.5 animate-spin" />
{:else}
<Eraser class="size-3.5" />
{/if}
</Button>
<DropdownMenu>
<DropdownMenuTrigger>
{#snippet child({ props })}
<Button {...props} variant="ghost" size="icon-sm" aria-label="Ещё">
<MoreHorizontal class="size-3.5" />
</Button>
{/snippet}
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
variant="destructive"
disabled={cleaningFilename === row.name}
onclick={() => requestCleanup(row, 'delete')}
>
<Trash2 class="size-4" />
Удалить файл
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/if}
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
{/if}
</TabsContent>
<TabsContent value="audit" class="mt-4">
<Card>
<CardHeader>
<CardTitle class="text-base">История очистки</CardTitle>
<CardDescription>
Записи из <code class="text-xs">runtime_log_cleanup_audit</code> (viewer+).
</CardDescription>
</CardHeader>
<CardContent class="min-w-0 space-y-3 p-4 pt-0">
{#if auditError && auditItems.length === 0}
<EmptyState icon={FileText} title="Не удалось загрузить audit" description={auditError}>
{#snippet action()}
<Button variant="outline" size="sm" onclick={() => loadAudit(true)}>
Повторить
</Button>
{/snippet}
</EmptyState>
{:else}
<AppDataTable
columns={auditColumns}
rows={auditItems}
rowKey={(r) => r.id}
loading={auditLoading && auditItems.length === 0}
emptyTitle="Записей пока нет"
emptyDescription="Очистка появится после ручного DELETE или автоочистки. Настройки — Параметры → Файловые логи."
>
{#snippet cell({ row, column })}
{#if column.id === 'created'}
<span class="text-sm">{formatDateTime(row.created_at)}</span>
{:else if column.id === 'source'}
<Badge variant={row.actor_prefix.startsWith('auto:') ? 'secondary' : 'outline'}>
{auditSourceLabel(row.actor_prefix)}
</Badge>
{:else if column.id === 'actor'}
<span class="font-mono text-xs">{row.actor_prefix}</span>
{:else if column.id === 'filename'}
<span class="font-mono text-xs">{row.filename}</span>
{:else if column.id === 'action'}
<Badge variant={actionBadgeVariant(row.action)}>{row.action}</Badge>
{:else if column.id === 'sizes'}
<span class="text-xs tabular-nums">
{formatBytes(row.size_before)}
{#if row.size_after != null}
{formatBytes(row.size_after)}
{/if}
</span>
{/if}
{/snippet}
</AppDataTable>
{/if}
{#if auditHasMore}
<Button
variant="outline"
size="sm"
disabled={auditLoading}
onclick={() => loadAudit(false)}
>
{#if auditLoading}
<LoaderCircle class="size-4 animate-spin" />
{/if}
Загрузить ещё
</Button>
{/if}
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
<Dialog bind:open={previewOpen}>
<DialogContent class={dialogContentDocument}>
<DialogHeader class={dialogHeaderDocument}>
<DialogTitle class="flex items-center gap-2">
<FileText class="size-4" />
{previewFilename}
</DialogTitle>
<DialogDescription>
{#if previewTruncated}
Показан усечённый хвост ({previewLines} строк).
{:else}
Хвост файла ({previewLines} строк).
{/if}
</DialogDescription>
</DialogHeader>
<div class={dialogBodyDocument}>
{#if previewLoading}
<p class="text-sm text-muted-foreground">Загрузка…</p>
{:else}
<ScrollPreBlock variant="preserve" text={previewContent || '—'} class="max-h-[70vh]" />
{/if}
</div>
</DialogContent>
</Dialog>
@@ -0,0 +1,30 @@
<script lang="ts">
import { Label } from '@evobgp/ui/components/label/index.js';
import { Switch } from '@evobgp/ui/components/switch/index.js';
import { readNetworkAutoRefresh, writeNetworkAutoRefresh } from '$lib/network/network-metrics.js';
type Props = {
enabled?: boolean;
onchange?: (enabled: boolean) => void;
disabled?: boolean;
};
let {
enabled = $bindable(readNetworkAutoRefresh()),
onchange,
disabled = false
}: Props = $props();
function onToggle(checked: boolean) {
enabled = checked;
writeNetworkAutoRefresh(checked);
onchange?.(checked);
}
</script>
<div class="flex items-center gap-2">
<Switch id="network-auto-refresh" bind:checked={enabled} onCheckedChange={onToggle} {disabled} />
<Label for="network-auto-refresh" class="cursor-pointer text-sm text-muted-foreground">
Авто (~15 с)
</Label>
</div>
@@ -0,0 +1,80 @@
<script lang="ts">
import { onMount } from 'svelte';
import { resolve } from '$app/paths';
import { loadSettings, partitionSettings } from '$lib/settings/settings-api.js';
import { BIRD_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import { notifyApiError } from '$lib/ui/app/toast.js';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
const labels: Record<string, string> = {
bird_router_id: 'Router ID',
bird_local_ipv4: 'Local IPv4',
bird_local_ipv6: 'Local IPv6',
bird_local_asn: 'Local ASN',
bird_bgp_source_ipv4: 'BGP source IPv4',
bird_bgp_source_ipv6: 'BGP source IPv6'
};
let loading = $state(true);
let values = $state<Record<string, string>>({});
onMount(() => {
void (async () => {
loading = true;
try {
const settings = await loadSettings();
const { partitioned } = partitionSettings(settings);
const out: Record<string, string> = {};
for (const key of BIRD_SETTING_KEYS) {
const v = String(partitioned.bird[key] ?? '').trim();
if (v) out[key] = v;
}
values = out;
} catch (e) {
notifyApiError(e);
} finally {
loading = false;
}
})();
});
</script>
<Card>
<CardHeader>
<CardTitle>BIRD (кратко)</CardTitle>
<CardDescription>
Глобальные параметры BIRD из tenant settings. Полная форма — в разделе «Параметры».
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
{#if loading}
<p class="text-sm text-muted-foreground">Загрузка…</p>
{:else if Object.keys(values).length === 0}
<p class="text-sm text-muted-foreground">Параметры BIRD ещё не заданы.</p>
{:else}
<dl class="grid gap-2 text-sm sm:grid-cols-2">
{#each Object.entries(values) as [key, value] (key)}
<div class="rounded-md border bg-muted/30 px-3 py-2">
<dt class="text-muted-foreground">{labels[key] ?? key}</dt>
<dd class="font-mono text-xs break-all">{value}</dd>
</div>
{/each}
</dl>
{/if}
<Button variant="outline" href={resolve('/tenant-settings?tab=bird')}>
<SlidersHorizontal class="size-4" />
Изменить параметры
<ArrowRight class="size-4" />
</Button>
</CardContent>
</Card>
@@ -0,0 +1,240 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { BirdStatus, PeerRow, SpeakerRow } from '$lib/api/types.js';
import {
aggregateNetworkMetrics,
collectNetworkIssues,
deriveNetworkOverallStatus,
networkOverallStatusHint,
networkOverallStatusLabel
} from '$lib/network/network-metrics.js';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import KpiMetricsGrid from '$lib/components/patterns/kpi/kpi-metrics-grid.svelte';
import NetworkSpeakerStatusCard from '$lib/components/network/NetworkSpeakerStatusCard.svelte';
import CheckCircle from '@lucide/svelte/icons/check-circle';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
import XCircle from '@lucide/svelte/icons/x-circle';
import Share2 from '@lucide/svelte/icons/share-2';
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
import Server from '@lucide/svelte/icons/server';
import GitBranch from '@lucide/svelte/icons/git-branch';
import Activity from '@lucide/svelte/icons/activity';
import Bird from '@lucide/svelte/icons/bird';
import Gauge from '@lucide/svelte/icons/gauge';
type Props = {
peers: PeerRow[];
speakers: SpeakerRow[];
bird: BirdStatus | null;
loading?: boolean;
initialLoading?: boolean;
onSpeakerSelect?: (speaker: SpeakerRow) => void;
};
let {
peers,
speakers,
bird,
loading = false,
initialLoading = false,
onSpeakerSelect
}: Props = $props();
const statAccents = [
{
border: 'border-l-chart-3',
bg: 'bg-chart-3/5',
iconBg: 'bg-chart-3/15',
iconText: 'text-chart-3'
},
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-chart-4',
bg: 'bg-chart-4/5',
iconBg: 'bg-chart-4/15',
iconText: 'text-chart-4'
},
{
border: 'border-l-warning',
bg: 'bg-warning/5',
iconBg: 'bg-warning/15',
iconText: 'text-warning'
},
{
border: 'border-l-destructive',
bg: 'bg-destructive/5',
iconBg: 'bg-destructive/15',
iconText: 'text-destructive'
},
{
border: 'border-l-info',
bg: 'bg-info/10',
iconBg: 'bg-info/15',
iconText: 'text-info'
}
] as const;
const metrics = $derived(aggregateNetworkMetrics(peers, speakers, bird));
const overallStatus = $derived(deriveNetworkOverallStatus(metrics));
const overallHint = $derived(networkOverallStatusHint(overallStatus, metrics));
const issues = $derived(collectNetworkIssues(peers, speakers, 5));
const birdText = $derived.by(() => {
if (!bird?.birdc_configured) return '—';
if (bird.error) return '—';
return `${bird.bgp_established}/${bird.bgp_sessions_total}`;
});
const kpiCards = $derived.by(() => [
{
id: 'peers',
label: 'BGP-пиры',
value: initialLoading ? '—' : String(metrics.peersTotal),
description: initialLoading
? ''
: `${metrics.peersEstablished} Established из ${metrics.peersEnabled} вкл.`,
icon: Share2,
accent: statAccents[0],
badge: metrics.peersMismatch > 0 ? `mismatch ${metrics.peersMismatch}` : 'peers',
badgeClass:
metrics.peersMismatch > 0 ? 'border-warning/30 bg-warning/15 text-warning' : undefined
},
{
id: 'established',
label: 'Активные сессии',
value: initialLoading ? '—' : String(metrics.peersEstablished),
description: 'Established среди включённых пиров',
icon: CheckCircle2,
accent: statAccents[1],
badge: metrics.peersEstablished > 0 ? 'Established' : 'нет сессий',
badgeClass:
metrics.peersEstablished > 0 ? 'border-success/30 bg-success/15 text-success' : undefined
},
{
id: 'speakers',
label: 'Спикеры online',
value: initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`,
description: 'agent + BGP poll',
icon: Server,
accent: statAccents[2],
badge: metrics.speakersOnline === metrics.speakersTotal ? 'все online' : 'есть offline',
badgeClass:
metrics.speakersOnline === metrics.speakersTotal
? 'border-success/30 bg-success/15 text-success'
: 'border-warning/30 bg-warning/15 text-warning'
},
{
id: 'drift',
label: 'Drift',
value: initialLoading ? '—' : String(metrics.speakersDrift),
description: 'applied ≠ published',
icon: GitBranch,
accent: statAccents[3],
badge: metrics.speakersDrift > 0 ? 'требует apply' : 'синхронно',
badgeVariant: metrics.speakersDrift > 0 ? ('secondary' as const) : ('outline' as const)
},
{
id: 'poll-errors',
label: 'Ошибки опроса',
value: initialLoading ? '—' : String(metrics.pollErrors),
description: 'agent или BGP poll',
icon: Activity,
accent: statAccents[4],
badge: metrics.pollErrors > 0 ? 'ошибки' : 'ok',
badgeClass:
metrics.pollErrors === 0 ? 'border-success/30 bg-success/15 text-success' : undefined
},
{
id: 'cp-bird',
label: 'BGP на CP',
value: initialLoading ? '—' : birdText,
description: bird?.birdc_configured
? 'Established / total на API-хосте'
: (bird?.message ?? 'birdc не настроен'),
icon: Bird,
accent: statAccents[5],
badge: !bird?.birdc_configured ? 'N/A' : bird?.healthy ? 'В норме' : 'Деградация',
href: '/monitoring' as const
}
]);
</script>
<div class="flex min-w-0 flex-col gap-6">
{#if !initialLoading && !loading}
{#if overallStatus === 'ok'}
<Alert class="border-success/30 bg-success/5">
<CheckCircle class="text-success" />
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription>{overallHint}</AlertDescription>
</Alert>
{:else if overallStatus === 'warn'}
<Alert class="border-warning/30 bg-warning/5">
<AlertTriangle class="text-warning" />
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription>
{overallHint}
{#if issues.length > 0}
<ul class="mt-2 list-inside list-disc text-sm">
{#each issues as issue (issue.id)}
<li>{issue.message}</li>
{/each}
</ul>
{/if}
</AlertDescription>
</Alert>
{:else}
<Alert variant="destructive">
<XCircle />
<AlertTitle>{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription>
{overallHint}
{#if issues.length > 0}
<ul class="mt-2 list-inside list-disc text-sm">
{#each issues as issue (issue.id)}
<li>{issue.message}</li>
{/each}
</ul>
{/if}
</AlertDescription>
</Alert>
{/if}
{/if}
<KpiMetricsGrid
cards={kpiCards}
loading={initialLoading || loading}
skeletonCount={6}
class="sm:grid-cols-2 xl:grid-cols-3"
/>
<section class="flex min-w-0 flex-col gap-4">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-base font-semibold">Ноды</h2>
<Button variant="outline" size="sm" href={resolve('/monitoring')}>
<Gauge class="size-3.5" />
Мониторинг API
</Button>
</div>
{#if speakers.length === 0 && !initialLoading && !loading}
<p class="text-sm text-muted-foreground">Спикеры не зарегистрированы.</p>
{:else}
<div class="grid auto-rows-fr gap-4 sm:grid-cols-2 xl:grid-cols-3">
{#each speakers as speaker (speaker.id)}
<NetworkSpeakerStatusCard
{speaker}
{peers}
class="h-full"
onclick={onSpeakerSelect ? () => onSpeakerSelect(speaker) : undefined}
/>
{/each}
</div>
{/if}
</section>
</div>
@@ -0,0 +1,347 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { PeerRow, BgpPeerCreate, SpeakerRow, PeerSessionOnSpeaker } from '$lib/api/types.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '@evobgp/ui/components/dialog/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { Switch } from '@evobgp/ui/components/switch/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import AppInput from '$lib/components/patterns/form/app-input.svelte';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
type Props = {
items: PeerRow[];
speakers: SpeakerRow[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
};
let {
items,
speakers,
loading = false,
initialLoading = false,
error = null,
onRefresh
}: Props = $props();
let dialogOpen = $state(false);
let editTarget = $state<PeerRow | null>(null);
let form = $state<BgpPeerCreate & { bgp_speaker_id?: string | null }>({
name: '',
neighbor: '',
remote_asn: 0,
bgp_speaker_id: null,
enabled: true
});
let saving = $state(false);
let toggleId = $state<string | null>(null);
const speakerById = $derived.by(() => new Map(speakers.map((s) => [s.id, s])));
const columns = [
{ id: 'name', label: 'Имя', sortable: true, sortValue: (p: PeerRow) => p.name ?? '' },
{ id: 'neighbor', label: 'Адрес', sortable: true, sortValue: (p: PeerRow) => p.neighbor },
{
id: 'remote_asn',
label: 'Remote ASN',
sortable: true,
sortValue: (p: PeerRow) => p.remote_asn ?? 0
},
{ id: 'enabled', label: 'Вкл.', class: 'w-[4.5rem] text-center' },
{ id: 'session_state', label: 'Состояние сессии' },
{ id: 'speaker', label: 'Спикер' },
{ id: 'actions', label: '', class: 'w-20' }
] as const;
function peerNodeLine(s: PeerSessionOnSpeaker): string {
if (s.poll_error) return `${s.label}: опрос недоступен`;
if (s.state === 'Established') return `${s.label}: Established`;
if (s.state === 'absent') return `${s.label}: нет сессии`;
return `${s.label}: ${s.state || '—'}`;
}
function peerConnectedLabel(p: PeerRow): string {
const nodes = p.session_on_speakers ?? [];
if (nodes.length > 0) {
return nodes.map(peerNodeLine).join(' · ');
}
const established = p.established_on_speakers ?? [];
if (established.length > 0) {
return established.map((s) => `${s.label}: Established`).join(' · ');
}
return 'Не найден на опрошенных нодах';
}
function peerSessionHint(p: PeerRow): string | null {
if (!p.session_mismatch || !p.bgp_speaker_id) return null;
const expected = speakerLabelById(p.bgp_speaker_id);
const actual =
p.established_on_speakers?.map((s) => s.label).join(', ') ||
p.connected_speaker_label?.trim() ||
'другие ноды';
return `В конфиге: ${expected}; Established на: ${actual}`;
}
function speakerLabelById(id: string | null | undefined) {
if (!id) return 'Все спикеры';
const s = speakerById.get(id);
if (!s) return id.slice(0, 8) + '…';
if (s.role === 'master') {
const host = s.agent_domain ?? s.endpoint;
return host ? `CP · ${host}` : 'CP (master)';
}
return s.agent_domain ?? s.endpoint ?? id.slice(0, 8) + '…';
}
function sessionBadge(
state: string,
p: PeerRow
): 'default' | 'secondary' | 'destructive' | 'outline' {
if (p.session_mismatch) return 'destructive';
if (state === 'Established') return 'default';
if (state === 'Active' || state === 'Connect') return 'secondary';
return 'outline';
}
function openCreate() {
editTarget = null;
form = { name: '', neighbor: '', remote_asn: 0, bgp_speaker_id: null, enabled: true };
dialogOpen = true;
}
function openEdit(p: PeerRow) {
editTarget = p;
form = {
name: p.name ?? '',
neighbor: p.neighbor,
remote_asn: p.remote_asn ?? 0,
bgp_speaker_id: p.bgp_speaker_id,
enabled: p.enabled !== false
};
dialogOpen = true;
}
function requestDelete(p: PeerRow) {
void confirm({
title: 'Удалить пира?',
description: p.neighbor,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/peers/${p.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Пир удалён');
await onRefresh();
}
});
}
async function setEnabled(p: PeerRow, enabled: boolean) {
toggleId = p.id;
try {
await apiMutate(`/v1/peers/${p.id}`, 'PATCH', { enabled });
notify.success(enabled ? 'Пир включён' : 'Пир отключён');
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
toggleId = null;
}
}
async function save() {
if (!form.neighbor.trim()) {
notify.error('Укажите адрес соседа');
return;
}
if (!form.remote_asn || form.remote_asn <= 0) {
notify.error('Remote ASN должен быть больше 0');
return;
}
saving = true;
try {
if (editTarget) {
await apiMutate(`/v1/peers/${editTarget.id}`, 'PATCH', form);
notify.success('Пир обновлён');
} else {
await apiMutate('/v1/peers', 'POST', {
...form,
enabled: form.enabled !== false
});
notify.success('Пир создан');
}
dialogOpen = false;
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">BGP-пиры</CardTitle>
<CardDescription>Настройка BGP-соседей и привязка к спикерам</CardDescription>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(p) => p.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет BGP-пиров"
emptyDescription="Добавьте первого BGP-соседа для установки сессии."
>
{#snippet cell({ row: p, column })}
{#if column.id === 'name'}
<span>{p.name?.trim() || '—'}</span>
{:else if column.id === 'neighbor'}
<span class="font-mono text-sm">{p.neighbor}</span>
{:else if column.id === 'remote_asn'}
<span class="font-mono text-sm">{p.remote_asn ?? '—'}</span>
{:else if column.id === 'enabled'}
<div class="flex justify-center">
<Switch
size="sm"
checked={p.enabled !== false}
disabled={loading || toggleId === p.id}
onCheckedChange={(v) => setEnabled(p, v)}
/>
</div>
{:else if column.id === 'session_state'}
<div class="flex min-w-0 flex-col gap-0.5">
<Badge variant={sessionBadge(p.session_state, p)}>{p.session_state || '—'}</Badge>
<span
class="truncate text-xs text-muted-foreground"
title={peerSessionHint(p) ?? peerConnectedLabel(p)}
>
{peerConnectedLabel(p)}
</span>
{#if peerSessionHint(p)}
<span class="truncate text-xs text-destructive">{peerSessionHint(p)}</span>
{/if}
</div>
{:else if column.id === 'speaker'}
<span class="text-xs text-muted-foreground" title="Привязка в конфиге CP">
{p.bgp_speaker_id ? speakerLabelById(p.bgp_speaker_id) : 'Все спикеры'}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(p)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => requestDelete(p)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать пира' : 'Новый пир'}</DialogTitle>
<DialogDescription>BGP-сосед для установки сессии</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<FormField label="Имя пира (опционально)" id="p-name">
<AppInput id="p-name" placeholder="Core-RTR-1" bind:value={form.name} />
</FormField>
<FormField label="Адрес соседа" id="p-neighbor" required>
<AppInput id="p-neighbor" placeholder="192.0.2.1" bind:value={form.neighbor} />
</FormField>
<FormField label="Remote ASN" id="p-asn" required>
<AppInput id="p-asn" type="number" placeholder="65000" bind:value={form.remote_asn} />
</FormField>
<FormField label="Спикер (опционально)" id="p-speaker">
<Select
type="single"
value={form.bgp_speaker_id ?? ''}
onValueChange={(v) => {
form = { ...form, bgp_speaker_id: v || null };
}}
>
<SelectTrigger id="p-speaker" class="w-full">
{form.bgp_speaker_id ? speakerLabelById(form.bgp_speaker_id) : 'Все спикеры'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Не выбрано</SelectItem>
{#each speakers as s (s.id)}
<SelectItem value={s.id}>{s.endpoint} ({s.id.slice(0, 8)}…)</SelectItem>
{/each}
</SelectContent>
</Select>
</FormField>
<div
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
>
<div class="grid min-w-0 flex-1 gap-1 pr-2">
<Label for="p-enabled" class="leading-snug text-foreground">Включён</Label>
<p class="text-xs leading-snug text-muted-foreground">
Выключенный пир не попадает в конфиг BIRD до следующей ревизии.
</p>
</div>
<Switch
id="p-enabled"
class="shrink-0"
checked={form.enabled !== false}
onCheckedChange={(v) => {
form = { ...form, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,203 @@
<script lang="ts">
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
import {
peersForSpeaker,
speakerDisplayStatus,
speakerDispatchError,
speakerHasDrift,
speakerLabel,
speakerLiveAgentError,
speakerLiveBgpError
} from '$lib/network/network-metrics.js';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Separator } from '@evobgp/ui/components/separator/index.js';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle
} from '@evobgp/ui/components/sheet/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@evobgp/ui/components/table/index.js';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
type Props = {
speaker: SpeakerRow | null;
peers: PeerRow[];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onApply?: (speaker: SpeakerRow) => void;
};
let { speaker, peers, open = $bindable(false), onOpenChange, onApply }: Props = $props();
const status = $derived(speaker ? speakerDisplayStatus(speaker) : null);
const label = $derived(speaker ? speakerLabel(speaker) : '');
const relatedPeers = $derived(speaker ? peersForSpeaker(peers, speaker.id) : []);
const sessions = $derived(speaker?.live?.sessions ?? []);
const dispatchError = $derived(speaker ? speakerDispatchError(speaker) : null);
const agentError = $derived(speaker ? speakerLiveAgentError(speaker) : null);
const bgpError = $derived(speaker ? speakerLiveBgpError(speaker) : null);
function driftLabel(s: SpeakerRow): string {
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
const app = s.last_applied_revision_id?.slice(0, 8) ?? '—';
return `${app} / ${pub}`;
}
function formatSyncAt(iso: string | undefined): string {
if (!iso) return '—';
const d = new Date(iso);
return Number.isNaN(d.getTime()) ? iso : d.toLocaleString('ru-RU');
}
$effect(() => {
onOpenChange?.(open);
});
</script>
<Sheet bind:open>
<SheetContent class="flex w-full flex-col gap-0 overflow-y-auto p-0 sm:max-w-md">
{#if speaker}
<div class="flex min-w-0 flex-col gap-4 px-4 pt-4 pb-6">
<SheetHeader class="space-y-1 pr-8 text-left">
<SheetTitle class="truncate">{label}</SheetTitle>
<SheetDescription class="truncate">
{speaker.role} · {speaker.agent_domain ?? speaker.endpoint}
</SheetDescription>
</SheetHeader>
<div class="flex flex-wrap items-center gap-2">
{#if status}
<Badge variant={status.variant}>{status.label}</Badge>
{/if}
{#if speakerHasDrift(speaker)}
<Badge variant="secondary">Drift</Badge>
{/if}
</div>
<dl class="grid grid-cols-[minmax(0,9rem)_1fr] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted-foreground">BGP Established</dt>
<dd class="text-right font-medium tabular-nums">
{speaker.live?.bgp_established ?? '—'} / {speaker.live?.bgp_sessions_total ?? '—'}
</dd>
{#if speaker.live?.agent_last_sync_at}
<dt class="text-muted-foreground">Последний sync</dt>
<dd class="text-right text-xs tabular-nums">
{formatSyncAt(speaker.live.agent_last_sync_at)}
</dd>
{/if}
<dt class="text-muted-foreground">Drift (app / pub)</dt>
<dd class="truncate text-right font-mono text-xs">{driftLabel(speaker)}</dd>
{#if speaker.last_dispatch_at}
<dt class="text-muted-foreground">Dispatch</dt>
<dd class="text-right text-xs tabular-nums">
{formatSyncAt(speaker.last_dispatch_at)}
</dd>
{/if}
</dl>
{#if dispatchError}
<Alert class="border-warning/30 bg-warning/5">
<AlertTriangle class="text-warning" />
<AlertTitle class="text-sm">{dispatchError.title}</AlertTitle>
<AlertDescription class="text-xs leading-relaxed"
>{dispatchError.detail}</AlertDescription
>
</Alert>
{/if}
{#if agentError}
<Alert variant="destructive">
<AlertTitle class="text-sm">{agentError.title}</AlertTitle>
<AlertDescription class="text-xs">{agentError.detail}</AlertDescription>
</Alert>
{/if}
{#if bgpError}
<Alert variant="destructive">
<AlertTitle class="text-sm">{bgpError.title}</AlertTitle>
<AlertDescription class="text-xs">{bgpError.detail}</AlertDescription>
</Alert>
{/if}
{#if onApply && speaker.published_revision_id}
<Button variant="outline" size="sm" class="w-fit" onclick={() => onApply(speaker)}>
Apply revision
</Button>
{/if}
<Separator />
<section class="min-w-0 space-y-2">
<h3 class="text-sm font-medium">BGP-сессии (live)</h3>
{#if sessions.length === 0}
<p class="text-sm text-muted-foreground">Нет данных или сессий нет.</p>
{:else}
<div class="rounded-md border">
<Table class="table-fixed">
<TableHeader>
<TableRow>
<TableHead class="w-[65%]">Имя</TableHead>
<TableHead class="w-[35%] text-right">Состояние</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each sessions as sess, i (sess.name + i)}
<TableRow>
<TableCell class="align-top">
<p class="truncate font-mono text-xs" title={sess.name}>{sess.name}</p>
{#if sess.neighbor}
<p class="truncate text-xs text-muted-foreground" title={sess.neighbor}>
{sess.neighbor}
</p>
{/if}
</TableCell>
<TableCell class="text-right align-top">
<Badge variant="outline" class="shrink-0">{sess.state}</Badge>
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</div>
{/if}
</section>
<Separator />
<section class="min-w-0 space-y-2">
<h3 class="text-sm font-medium">Пиры на ноде</h3>
{#if relatedPeers.length === 0}
<p class="text-sm text-muted-foreground">Нет привязанных пиров.</p>
{:else}
<ul class="divide-y rounded-md border">
{#each relatedPeers as p (p.id)}
<li class="flex min-w-0 items-start justify-between gap-3 px-3 py-2.5 text-sm">
<div class="min-w-0 flex-1">
<p class="truncate font-medium" title={p.name?.trim() || p.neighbor}>
{p.name?.trim() || p.neighbor}
</p>
{#if p.session_mismatch}
<p class="mt-0.5 text-xs text-warning">
Mismatch: сессия не на назначенной ноде
</p>
{/if}
</div>
<Badge variant="outline" class="shrink-0">{p.session_state || '—'}</Badge>
</li>
{/each}
</ul>
{/if}
</section>
</div>
{/if}
</SheetContent>
</Sheet>
@@ -0,0 +1,85 @@
<script lang="ts">
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
import {
speakerBgpText,
speakerDisplayStatus,
speakerHasDrift,
speakerLabel
} from '$lib/network/network-metrics.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import { cn } from '$lib/utils.js';
import Server from '@lucide/svelte/icons/server';
type Props = {
speaker: SpeakerRow;
peers?: PeerRow[];
onclick?: () => void;
class?: string;
};
let { speaker, peers = [], onclick, class: className }: Props = $props();
const status = $derived(speakerDisplayStatus(speaker));
const label = $derived(speakerLabel(speaker));
const drift = $derived(speakerHasDrift(speaker));
const peerCount = $derived(
peers.filter(
(p) =>
p.bgp_speaker_id === speaker.id ||
p.bgp_speaker_id === null ||
p.bgp_speaker_id === undefined
).length
);
</script>
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
<Card
class={cn(
'flex h-full flex-col transition-colors',
onclick ? 'cursor-pointer hover:border-primary/35' : '',
className
)}
role={onclick ? 'button' : undefined}
tabindex={onclick ? 0 : undefined}
{onclick}
onkeydown={(e) => {
if (onclick && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
onclick();
}
}}
>
<CardHeader class="pb-2">
<div class="flex items-start gap-2">
<div class="min-w-0 flex-1">
<CardTitle class="flex items-center gap-2 text-sm">
<Server class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="truncate" title={label}>{label}</span>
</CardTitle>
<CardDescription class="truncate font-mono text-xs">{speaker.role}</CardDescription>
</div>
<Badge variant={status.variant} class="shrink-0">{status.label}</Badge>
</div>
</CardHeader>
<CardContent class="mt-auto pt-0">
<dl class="grid grid-cols-[1fr_auto] gap-x-3 gap-y-2 text-sm">
<dt class="text-muted-foreground">BGP</dt>
<dd class="font-medium tabular-nums">{speakerBgpText(speaker)}</dd>
<dt class="text-muted-foreground">Пиры</dt>
<dd class="tabular-nums">{peerCount}</dd>
<dt class="text-muted-foreground">Drift</dt>
<dd>
<Badge variant={drift ? 'secondary' : 'outline'} class="text-xs">
{drift ? 'есть' : 'нет'}
</Badge>
</dd>
</dl>
</CardContent>
</Card>
@@ -0,0 +1,526 @@
<script lang="ts">
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type { SpeakerRow, BgpSpeakerCreate, BundleSigningPublicKey } from '$lib/api/types.js';
import {
speakerBgpText,
speakerDisplayStatus,
speakerHasDrift
} from '$lib/network/network-metrics.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '@evobgp/ui/components/dialog/index.js';
import { Checkbox } from '@evobgp/ui/components/checkbox/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import AppInput from '$lib/components/patterns/form/app-input.svelte';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Play from '@lucide/svelte/icons/play';
import Copy from '@lucide/svelte/icons/copy';
import Trash2 from '@lucide/svelte/icons/trash-2';
import Eye from '@lucide/svelte/icons/eye';
type Props = {
items: SpeakerRow[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
onRefresh: () => void | Promise<void>;
onSpeakerSelect?: (speaker: SpeakerRow) => void;
};
let {
items,
loading = false,
initialLoading = false,
error = null,
onRefresh,
onSpeakerSelect
}: Props = $props();
type SpeakerForm = {
endpoint: string;
role: string;
agent_domain: string;
node_ipv4: string;
bird_bgp_source_ipv4: string;
bgpSourceManual: boolean;
};
let dialogOpen = $state(false);
let wizardOpen = $state(false);
let applyDialogOpen = $state(false);
let composeDialogOpen = $state(false);
let editTarget = $state<SpeakerRow | null>(null);
let applyTarget = $state<SpeakerRow | null>(null);
let composeTarget = $state<SpeakerRow | null>(null);
let applyRevisionId = $state('');
let composeText = $state('');
let createdSpeaker = $state<SpeakerRow | null>(null);
let form = $state<SpeakerForm>({
endpoint: '',
role: 'replica',
agent_domain: '',
node_ipv4: '',
bird_bgp_source_ipv4: '',
bgpSourceManual: false
});
let saving = $state(false);
let applyingId = $state<string | null>(null);
const columns = [
{ id: 'status', label: 'Статус' },
{ id: 'live_agent', label: 'Agent' },
{ id: 'bgp', label: 'BGP' },
{
id: 'agent_domain',
label: 'Agent domain',
sortable: true,
sortValue: (s: SpeakerRow) => s.agent_domain ?? s.endpoint
},
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
{ id: 'drift', label: 'Drift' },
{ id: 'actions', label: '', class: 'w-44' }
] as const;
function parseIpv4FromEndpoint(ep: string): string {
try {
const u = ep.includes('://') ? new URL(ep) : new URL(`https://${ep}`);
const host = u.hostname;
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return host;
} catch {
/* ignore */
}
return '';
}
function onNodeIPv4Change(ip: string) {
form.node_ipv4 = ip;
if (!form.bgpSourceManual) {
form.bird_bgp_source_ipv4 = ip;
}
}
function onEndpointChange(ep: string) {
form.endpoint = ep;
const ip = parseIpv4FromEndpoint(ep);
if (ip && !form.node_ipv4) {
onNodeIPv4Change(ip);
}
}
function emptyForm(): SpeakerForm {
return {
endpoint: '',
role: 'replica',
agent_domain: '',
node_ipv4: '',
bird_bgp_source_ipv4: '',
bgpSourceManual: false
};
}
function formFromSpeaker(s: SpeakerRow): SpeakerForm {
return {
endpoint: s.endpoint,
role: s.role,
agent_domain: s.agent_domain ?? '',
node_ipv4: s.node_ipv4 ?? '',
bird_bgp_source_ipv4: s.bird_bgp_source_ipv4 ?? s.node_ipv4 ?? '',
bgpSourceManual: Boolean(
s.bird_bgp_source_ipv4 && s.node_ipv4 && s.bird_bgp_source_ipv4 !== s.node_ipv4
)
};
}
function buildMetaJson(f: SpeakerForm): string {
const meta: Record<string, string> = {};
if (f.agent_domain.trim()) meta.agent_domain = f.agent_domain.trim();
if (f.node_ipv4.trim()) meta.node_ipv4 = f.node_ipv4.trim();
if (f.bird_bgp_source_ipv4.trim()) meta.bird_bgp_source_ipv4 = f.bird_bgp_source_ipv4.trim();
return JSON.stringify(meta);
}
function buildApiBody(f: SpeakerForm): BgpSpeakerCreate {
const ep =
f.endpoint.trim() || (f.agent_domain.trim() ? `https://${f.agent_domain.trim()}` : '');
return {
endpoint: ep,
role: f.role.trim() || 'replica',
meta_json: buildMetaJson(f)
};
}
function statusVariant(s: SpeakerRow): 'default' | 'secondary' | 'destructive' | 'outline' {
return speakerDisplayStatus(s).variant;
}
function statusLabel(s: SpeakerRow): string {
return speakerDisplayStatus(s).label;
}
function liveAgentLabel(s: SpeakerRow): string {
if (!s.live) return '—';
if (s.live.agent_ok === true) return 'OK';
return s.live.agent_error ? 'Error' : 'Offline';
}
function driftLabel(s: SpeakerRow): string {
const pub = s.published_revision_id?.slice(0, 8) ?? '—';
const app = s.last_applied_revision_id?.slice(0, 8) ?? '—';
return `${app} / ${pub}`;
}
function openCreate() {
editTarget = null;
form = emptyForm();
dialogOpen = true;
}
function openEdit(s: SpeakerRow) {
editTarget = s;
form = formFromSpeaker(s);
dialogOpen = true;
}
function requestDelete(s: SpeakerRow) {
const label = s.agent_domain ?? s.endpoint ?? s.id;
void confirm({
title: 'Удалить спикера?',
description: label,
confirmLabel: 'Удалить',
destructive: true,
onConfirm: async () => {
await apiMutate(`/v1/speakers/${s.id}`, 'DELETE', undefined, { idempotent: false });
notify.success('Спикер удалён');
await onRefresh();
}
});
}
function openApply(s: SpeakerRow) {
applyTarget = s;
applyRevisionId = s.published_revision_id ?? '';
applyDialogOpen = true;
}
async function buildComposeSnippet(s: SpeakerRow): Promise<string> {
let pubkey = '';
try {
const pk = await apiJSON<BundleSigningPublicKey>('/v1/bundle/signing-public-key');
pubkey = pk.public_key_base64;
} catch {
pubkey = '<GET /v1/bundle/signing-public-key>';
}
const domain = s.agent_domain ?? 'bgp-dc.example.com';
return `# deploy/compose/docker-compose.remote-speaker.yaml
# cp .env.remote-speaker.example .env.remote-speaker
# cp .env.remote-speaker-tls.example .env.remote-speaker-tls
EVOBGP_SPEAKER_ID=${s.id}
EVOBGP_AGENT_SECRET=<from UI wizard>
EVOBGP_NODE_TOKEN=<node API key from /access>
EVOBGP_BUNDLE_PUBKEY_BASE64=${pubkey}
EVOBGP_CONTROL_PLANE_URL=https://<your-cp-host>:8080
AGENT_DOMAIN=${domain}
PANEL_IP_WHITELIST=<CP public IP>/32
[email protected]
CF_DNS_API_TOKEN=<cloudflare token>
# docker compose -f docker-compose.remote-speaker.yaml \\
# --env-file .env.remote-speaker --env-file .env.remote-speaker-tls \\
# --profile production up -d`;
}
async function openCompose(s: SpeakerRow) {
composeTarget = s;
composeText = await buildComposeSnippet(s);
composeDialogOpen = true;
}
async function copyCompose() {
try {
await navigator.clipboard.writeText(composeText);
notify.success('Скопировано');
} catch {
notify.error('Не удалось скопировать');
}
}
async function applySpeaker() {
if (!applyTarget || !applyRevisionId.trim()) {
notify.error('Укажите revision_id');
return;
}
applyingId = applyTarget.id;
try {
await apiMutate(`/v1/speakers/${applyTarget.id}/apply`, 'POST', {
revision_id: applyRevisionId.trim()
});
notify.success('Apply запущен');
applyDialogOpen = false;
} catch (e) {
notifyApiError(e);
} finally {
applyingId = null;
}
}
async function save() {
const body = buildApiBody(form);
if (!body.endpoint.trim()) {
notify.error('Укажите endpoint или agent domain');
return;
}
saving = true;
try {
if (editTarget) {
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', body);
notify.success('Спикер обновлён');
dialogOpen = false;
} else {
const created = await apiMutate<SpeakerRow>('/v1/speakers', 'POST', body);
notify.success('Спикер создан');
dialogOpen = false;
createdSpeaker = created;
composeText = await buildComposeSnippet(created);
wizardOpen = true;
}
await onRefresh();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
async function copyAgentSecret() {
const secret = createdSpeaker?.agent_secret;
if (!secret) return;
try {
await navigator.clipboard.writeText(secret);
notify.success('agent_secret скопирован');
} catch {
notify.error('Не удалось скопировать');
}
}
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Спикеры</CardTitle>
<CardDescription
>Удалённые BIRD-ноды (Remnawave-style Panel→Node + signed bundle)</CardDescription
>
</div>
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2">
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(s) => s.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет спикеров"
emptyDescription="Добавьте реплику для применения signed bundle."
>
{#snippet cell({ row: s, column })}
{#if column.id === 'status'}
<Badge variant={statusVariant(s)}>{statusLabel(s)}</Badge>
{:else if column.id === 'live_agent'}
<Badge variant={s.live?.agent_ok ? 'outline' : 'destructive'}>{liveAgentLabel(s)}</Badge>
{:else if column.id === 'bgp'}
<span class="font-mono text-xs tabular-nums">{speakerBgpText(s)}</span>
{:else if column.id === 'agent_domain'}
<span class="font-mono text-sm">{s.agent_domain ?? s.endpoint}</span>
{:else if column.id === 'role'}
<Badge variant="outline">{s.role}</Badge>
{:else if column.id === 'drift'}
<span
class="font-mono text-xs text-muted-foreground"
title="applied / published"
class:text-warning={speakerHasDrift(s)}
>
{driftLabel(s)}
</span>
{:else if column.id === 'actions'}
<div class="flex flex-wrap gap-1">
{#if onSpeakerSelect}
<Button variant="outline" size="xs" title="Детали" onclick={() => onSpeakerSelect(s)}>
<Eye class="size-3" />
</Button>
{/if}
<Button variant="outline" size="xs" title="Copy compose" onclick={() => openCompose(s)}>
<Copy class="size-3" />
</Button>
<Button
variant="outline"
size="xs"
title="Apply revision (canary)"
onclick={() => openApply(s)}
disabled={applyingId === s.id}
>
<Play class="size-3" />
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
<Pencil class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
title="Удалить спикера"
onclick={() => requestDelete(s)}
>
<Trash2 class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
<Dialog bind:open={dialogOpen}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>{editTarget ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<FormField label="Agent domain (FQDN)" id="s-domain">
<AppInput id="s-domain" placeholder="bgp-dc2.example.com" bind:value={form.agent_domain} />
</FormField>
<FormField label="Endpoint" id="s-endpoint">
<AppInput
id="s-endpoint"
placeholder="https://bgp-dc2.example.com"
value={form.endpoint}
oninput={(e) => onEndpointChange((e.currentTarget as HTMLInputElement).value)}
/>
</FormField>
<FormField label="IP ноды (IPv4)" id="s-node-ip">
<AppInput
id="s-node-ip"
placeholder="203.0.113.10"
value={form.node_ipv4}
oninput={(e) => onNodeIPv4Change((e.currentTarget as HTMLInputElement).value)}
/>
</FormField>
<FormField label="BGP source IPv4" id="s-bgp-src">
<AppInput
id="s-bgp-src"
placeholder="= IP ноды"
bind:value={form.bird_bgp_source_ipv4}
disabled={!form.bgpSourceManual}
/>
</FormField>
<label class="flex items-center gap-2 text-sm">
<Checkbox bind:checked={form.bgpSourceManual} />
Задать BGP source вручную
</label>
<FormField label="Роль" id="s-role">
<AppInput id="s-role" placeholder="replica" bind:value={form.role} />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
<Button onclick={save} disabled={saving}>
{saving ? 'Сохранение…' : editTarget ? 'Сохранить' : 'Создать'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={wizardOpen}>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Спикер создан</DialogTitle>
<DialogDescription>
Сохраните agent_secret — он больше не отображается. Скопируйте compose на VPS реплики.
</DialogDescription>
</DialogHeader>
{#if createdSpeaker?.agent_secret}
<FormField label="agent_secret (один раз)" id="w-secret">
<div class="flex gap-2">
<AppInput
id="w-secret"
readonly
value={createdSpeaker.agent_secret}
class="font-mono text-xs"
/>
<Button variant="outline" size="icon-sm" onclick={copyAgentSecret}><Copy /></Button>
</div>
</FormField>
{/if}
<FormField label="docker-compose env" id="w-compose">
<textarea
id="w-compose"
class="min-h-[200px] w-full rounded-md border bg-muted/30 p-2 font-mono text-xs"
readonly
value={composeText}
></textarea>
</FormField>
<DialogFooter>
<Button variant="outline" onclick={copyCompose}><Copy />Copy compose</Button>
<Button onclick={() => (wizardOpen = false)}>Готово</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={applyDialogOpen}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>Apply на спикер</DialogTitle>
</DialogHeader>
<FormField label="revision_id" id="a-rev" required>
<AppInput id="a-rev" bind:value={applyRevisionId} class="font-mono text-xs" />
</FormField>
<DialogFooter>
<Button variant="outline" onclick={() => (applyDialogOpen = false)}>Отмена</Button>
<Button onclick={applySpeaker} disabled={applyingId != null}>Apply</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog bind:open={composeDialogOpen}>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Copy docker-compose</DialogTitle>
<DialogDescription
>Спикер {composeTarget?.agent_domain ?? composeTarget?.id}</DialogDescription
>
</DialogHeader>
<textarea
class="min-h-[240px] w-full rounded-md border bg-muted/30 p-2 font-mono text-xs"
readonly
value={composeText}
></textarea>
<DialogFooter>
<Button variant="outline" onclick={copyCompose}><Copy />Копировать</Button>
<Button onclick={() => (composeDialogOpen = false)}>Закрыть</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -0,0 +1,218 @@
<script lang="ts">
import type { RevisionDiff, RevisionPrefix, RevisionRow } from '$lib/api/types.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import { formatDateTime } from '$lib/modules/display.js';
import { sortRevisionDiffItems } from '$lib/sort-prefixes.js';
type Props = {
revisions: RevisionRow[];
diffRevA: string;
diffRevB: string;
diffData: RevisionDiff | null;
diffLoading: boolean;
onDiffRevAChange: (value: string) => void;
onDiffRevBChange: (value: string) => void;
onLoadDiff: () => void;
};
let {
revisions,
diffRevA,
diffRevB,
diffData,
diffLoading,
onDiffRevAChange,
onDiffRevBChange,
onLoadDiff
}: Props = $props();
function revisionLabel(rev: RevisionRow): string {
return `${rev.id.slice(0, 8)}… (${formatDateTime(rev.created_at)})`;
}
function diffAddedRaw(d: RevisionDiff | null): (string | RevisionPrefix)[] {
if (!d) return [];
if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added;
if (Array.isArray(d.added)) return d.added;
return [];
}
function diffRemovedRaw(d: RevisionDiff | null): (string | RevisionPrefix)[] {
if (!d) return [];
if (d.prefixes && Array.isArray(d.prefixes.removed)) return d.prefixes.removed;
if (Array.isArray(d.removed)) return d.removed;
return [];
}
let addedSorted = $derived(diffData ? sortRevisionDiffItems(diffAddedRaw(diffData)) : []);
let removedSorted = $derived(diffData ? sortRevisionDiffItems(diffRemovedRaw(diffData)) : []);
let scrollAddedEl: HTMLDivElement | null = $state(null);
let scrollRemovedEl: HTMLDivElement | null = $state(null);
let syncScrollLock = false;
function syncFromAdded() {
if (syncScrollLock || !scrollAddedEl || !scrollRemovedEl) return;
syncScrollLock = true;
scrollRemovedEl.scrollTop = scrollAddedEl.scrollTop;
queueMicrotask(() => {
syncScrollLock = false;
});
}
function syncFromRemoved() {
if (syncScrollLock || !scrollAddedEl || !scrollRemovedEl) return;
syncScrollLock = true;
scrollAddedEl.scrollTop = scrollRemovedEl.scrollTop;
queueMicrotask(() => {
syncScrollLock = false;
});
}
</script>
<Card class="min-h-0">
<CardHeader>
<CardTitle class="text-base">Сравнение ревизий</CardTitle>
<CardDescription>Выберите ID двух ревизий для сравнения</CardDescription>
</CardHeader>
<CardContent class="min-h-0 space-y-4">
<div class="flex flex-col gap-2 sm:flex-row">
<Select type="single" value={diffRevA} onValueChange={(v) => onDiffRevAChange(v ?? '')}>
<SelectTrigger class="min-w-0 flex-1">
{diffRevA
? revisions.find((r) => r.id === diffRevA)
? revisionLabel(revisions.find((r) => r.id === diffRevA)!)
: diffRevA
: 'Ревизия A'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ревизия A</SelectItem>
{#each revisions as rev (rev.id)}
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
{/each}
</SelectContent>
</Select>
<Select type="single" value={diffRevB} onValueChange={(v) => onDiffRevBChange(v ?? '')}>
<SelectTrigger class="min-w-0 flex-1">
{diffRevB
? revisions.find((r) => r.id === diffRevB)
? revisionLabel(revisions.find((r) => r.id === diffRevB)!)
: diffRevB
: 'Ревизия B'}
</SelectTrigger>
<SelectContent>
<SelectItem value="">Ревизия B</SelectItem>
{#each revisions as rev (rev.id)}
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
{/each}
</SelectContent>
</Select>
<Button
size="sm"
class="shrink-0 self-start sm:self-auto"
onclick={onLoadDiff}
disabled={diffLoading}
>
{diffLoading ? 'Загрузка…' : 'Сравнить'}
</Button>
</div>
{#if diffData}
<div
class="grid min-h-0 grid-cols-1 overflow-hidden rounded-md border border-border sm:grid-cols-2"
style="scrollbar-gutter: stable;"
>
<div class="flex min-h-0 min-w-0 flex-col border-b border-border sm:border-r sm:border-b-0">
<div
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-success"
>
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">+</span>
<span>Добавлено ({addedSorted.length})</span>
</div>
<div
bind:this={scrollAddedEl}
class="max-h-[min(28rem,70vh)] min-h-0 overflow-x-auto overflow-y-scroll overscroll-contain bg-background/50"
onscroll={syncFromAdded}
>
{#if addedSorted.length === 0}
<p class="p-3 font-mono text-xs text-muted-foreground">Нет изменений</p>
{:else}
<table class="w-full border-collapse font-mono text-xs">
<tbody>
{#each addedSorted as line, i (`a-${i}-${line}`)}
<tr
class="border-b border-l-2 border-success/30 bg-success/5 hover:bg-muted/30"
>
<td
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
>
{i + 1}
</td>
<td
class="max-w-0 py-0.5 pr-3 pl-1 break-all whitespace-pre-wrap text-foreground"
>
{line}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
<div class="flex min-h-0 min-w-0 flex-col">
<div
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-destructive"
>
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none"></span>
<span>Удалено ({removedSorted.length})</span>
</div>
<div
bind:this={scrollRemovedEl}
class="max-h-[min(28rem,70vh)] min-h-0 overflow-x-auto overflow-y-scroll overscroll-contain bg-background/50"
onscroll={syncFromRemoved}
>
{#if removedSorted.length === 0}
<p class="p-3 font-mono text-xs text-muted-foreground">Нет изменений</p>
{:else}
<table class="w-full border-collapse font-mono text-xs">
<tbody>
{#each removedSorted as line, i (`r-${i}-${line}`)}
<tr
class="border-b border-l-2 border-destructive/30 bg-destructive/5 hover:bg-muted/30"
>
<td
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
>
{i + 1}
</td>
<td
class="max-w-0 py-0.5 pr-3 pl-1 break-all whitespace-pre-wrap text-foreground"
>
{line}
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</div>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,163 @@
<script lang="ts">
import { Button } from '@evobgp/ui/components/button/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Label } from '@evobgp/ui/components/label/index.js';
import Filter from '@lucide/svelte/icons/filter';
import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x';
import { jobKindFilterRu, jobStatusRu } from '$lib/ui-labels.js';
type Props = {
searchQ: string;
onSearchQChange: (v: string) => void;
filterStatus: string;
onFilterStatusChange: (v: string) => void;
filterKind: string;
onFilterKindChange: (v: string) => void;
filterModuleId: string;
onFilterModuleIdChange: (v: string) => void;
activeOnly: boolean;
onErrorsChip: () => void;
onActiveChip: () => void;
onResetFilters: () => void;
moduleOptions: { id: string; name: string }[];
disabled?: boolean;
};
let {
searchQ,
onSearchQChange,
filterStatus,
onFilterStatusChange,
filterKind,
onFilterKindChange,
filterModuleId,
onFilterModuleIdChange,
activeOnly,
onErrorsChip,
onActiveChip,
onResetFilters,
moduleOptions,
disabled = false
}: Props = $props();
const statusOptions: { value: string; label: string }[] = [
{ value: '', label: 'Все статусы' },
{ value: 'queued', label: jobStatusRu('queued') },
{ value: 'running', label: jobStatusRu('running') },
{ value: 'succeeded', label: jobStatusRu('succeeded') },
{ value: 'failed', label: jobStatusRu('failed') },
{ value: 'cancelled', label: jobStatusRu('cancelled') }
];
const kindOptions: { value: string; label: string }[] = [
{ value: '', label: 'Все типы' },
{ value: 'module_refresh', label: jobKindFilterRu('module_refresh') },
{ value: 'deploy_apply', label: jobKindFilterRu('deploy_apply') },
{ value: 'revision_rollback', label: jobKindFilterRu('revision_rollback') },
{ value: 'bird_reload', label: jobKindFilterRu('bird_reload') }
];
</script>
<div class="space-y-3 rounded-lg border border-border/80 bg-muted/15 p-3">
<div class="flex flex-wrap items-center gap-2">
<Filter class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
<span class="text-sm font-medium">Фильтры задач</span>
<div class="ml-auto flex flex-wrap gap-1.5">
<Button
type="button"
variant={filterStatus === 'failed' && !activeOnly ? 'default' : 'outline'}
size="sm"
class="h-7 text-xs"
{disabled}
onclick={onErrorsChip}
>
Только ошибки
</Button>
<Button
type="button"
variant={activeOnly ? 'default' : 'outline'}
size="sm"
class="h-7 text-xs"
{disabled}
onclick={onActiveChip}
>
В работе
</Button>
<Button
type="button"
variant="ghost"
size="sm"
class="h-7 text-xs"
{disabled}
onclick={onResetFilters}
>
<X class="size-3.5" aria-hidden="true" />
Сброс
</Button>
</div>
</div>
<div class="relative">
<Search
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
aria-hidden="true"
/>
<Input
type="search"
placeholder="Поиск по ID, типу, статусу, ошибке, meta…"
class="h-9 pl-9"
value={searchQ}
oninput={(e) => onSearchQChange((e.currentTarget as HTMLInputElement).value)}
{disabled}
autocomplete="off"
aria-label="Текстовый поиск по задачам"
/>
</div>
<div class="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
<div class="space-y-1.5">
<Label for="job-filter-status" class="text-xs">Статус</Label>
<select
id="job-filter-status"
class="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
value={filterStatus}
onchange={(e) => onFilterStatusChange((e.currentTarget as HTMLSelectElement).value)}
{disabled}
>
{#each statusOptions as o (o.value)}
<option value={o.value}>{o.label}</option>
{/each}
</select>
</div>
<div class="space-y-1.5">
<Label for="job-filter-kind" class="text-xs">Тип задачи</Label>
<select
id="job-filter-kind"
class="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
value={filterKind}
onchange={(e) => onFilterKindChange((e.currentTarget as HTMLSelectElement).value)}
{disabled}
>
{#each kindOptions as o (o.value)}
<option value={o.value}>{o.label}</option>
{/each}
</select>
</div>
<div class="space-y-1.5">
<Label for="job-filter-module" class="text-xs">Модуль (локально)</Label>
<select
id="job-filter-module"
class="h-9 w-full rounded-md border border-border bg-background px-2 text-sm"
value={filterModuleId}
onchange={(e) => onFilterModuleIdChange((e.currentTarget as HTMLSelectElement).value)}
{disabled}
>
<option value="">Все модули</option>
{#each moduleOptions as m (m.id)}
<option value={m.id}>{m.name}</option>
{/each}
</select>
</div>
</div>
</div>
@@ -0,0 +1,463 @@
<script lang="ts">
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { JobRow } from '$lib/api/types.js';
import type { JobDetailedReport, JobLogEntry } from './types.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import EmptyState from '$lib/components/patterns/empty-state/empty-state.svelte';
import { formatDateTime } from '$lib/modules/display.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Eye from '@lucide/svelte/icons/eye';
import X from '@lucide/svelte/icons/x';
import ChevronDown from '@lucide/svelte/icons/chevron-down';
import CircleDot from '@lucide/svelte/icons/circle-dot';
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
import PlayCircle from '@lucide/svelte/icons/play-circle';
import Flag from '@lucide/svelte/icons/flag';
import Layers from '@lucide/svelte/icons/layers';
import Globe from '@lucide/svelte/icons/globe';
import Binary from '@lucide/svelte/icons/binary';
import Link2 from '@lucide/svelte/icons/link-2';
import { cn } from '$lib/utils.js';
import { jobKindSubtitle, jobKindTitle } from '$lib/operations/job-kind-label.js';
import { jobStatusRu, logKindRu, moduleTypeRu } from '$lib/ui-labels.js';
import JobReportTableBlock from './job-report-table-block.svelte';
import { asnReportColumns, reportRowColumns } from './job-report-columns.js';
import type { RowData } from '@tanstack/table-core';
type Props = {
jobs: JobRow[];
/** Сколько задач вернул API до клиентских фильтров */
jobsFetchedTotal?: number;
jobsLoading: boolean;
moduleNameById: ReadonlyMap<string, string>;
expandedJobIds: SvelteSet<string>;
jobDetailsById: SvelteMap<string, JobRow>;
jobDetailsLoading: SvelteSet<string>;
jobReportsById: SvelteMap<string, JobDetailedReport>;
jobReportsLoading: SvelteSet<string>;
onReloadJobs: () => void;
onOpenJobDetail: (job: JobRow) => void;
onRequestCancelJob: (job: JobRow) => void;
onToggleJobExpanded: (job: JobRow) => void | Promise<void>;
isJobExpanded: (jobId: string) => boolean;
getJobLogEntries: (job: JobRow) => JobLogEntry[];
getJobLogTotal: (job: JobRow, entries?: JobLogEntry[]) => number;
jobStatusVariant: (status: string) => 'default' | 'secondary' | 'outline' | 'destructive';
};
let {
jobs,
jobsFetchedTotal,
jobsLoading,
moduleNameById,
expandedJobIds,
jobDetailsById,
jobDetailsLoading,
jobReportsById,
jobReportsLoading,
onReloadJobs,
onOpenJobDetail,
onRequestCancelJob,
onToggleJobExpanded,
isJobExpanded,
getJobLogEntries,
getJobLogTotal,
jobStatusVariant
}: Props = $props();
const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef<
RowData,
unknown
>[];
const asnCols = asnReportColumns as import('@tanstack/table-core').ColumnDef<RowData, unknown>[];
</script>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Задачи</CardTitle>
<CardDescription class="flex flex-wrap items-center gap-x-2 gap-y-1">
<span>Фоновые задачи (ingest, применение, обновление)</span>
{#if jobsFetchedTotal !== undefined}
<span class="font-normal text-muted-foreground tabular-nums">
· Показано {jobs.length} из {jobsFetchedTotal}
</span>
{/if}
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
class="shrink-0 self-start sm:self-auto"
onclick={onReloadJobs}
disabled={jobsLoading}
>
<RefreshCw class={jobsLoading ? 'animate-spin' : ''} />
</Button>
</CardHeader>
<CardContent class="space-y-3 p-3 sm:p-4">
{#each jobs as job (job.job_id)}
{@const kindSub = jobKindSubtitle(job, moduleNameById)}
<div class="min-w-0 overflow-hidden rounded-lg border bg-card">
<div class="flex min-w-0 flex-col gap-3 p-3 sm:p-4">
<div class="flex min-w-0 flex-wrap items-start justify-between gap-2">
<div class="flex min-w-0 items-center gap-2">
<Button
variant="ghost"
size="icon-sm"
class="mt-0.5 shrink-0 text-muted-foreground hover:text-foreground"
aria-label={isJobExpanded(job.job_id)
? 'Свернуть детали задачи'
: 'Развернуть детали задачи'}
aria-expanded={isJobExpanded(job.job_id)}
onclick={() => void onToggleJobExpanded(job)}
>
<ChevronDown
class={cn(
'size-4 transition-transform',
isJobExpanded(job.job_id) && 'rotate-180'
)}
/>
</Button>
<div class="min-w-0">
<p class="font-medium break-words">{jobKindTitle(job, moduleNameById)}</p>
{#if kindSub}
<p class="font-mono text-xs break-all text-muted-foreground">{kindSub}</p>
{/if}
<p class="font-mono text-xs break-all text-muted-foreground">{job.job_id}</p>
</div>
</div>
<div class="flex items-center gap-1">
<Button
variant="ghost"
size="icon-sm"
onclick={() => onOpenJobDetail(job)}
aria-label="Открыть задачу в модальном окне"
>
<Eye class="size-3.5" />
</Button>
{#if job.status === 'running' || job.status === 'queued'}
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => onRequestCancelJob(job)}
aria-label="Отменить задачу"
>
<X class="size-3.5" />
</Button>
{/if}
</div>
</div>
<div class="grid gap-2 text-sm sm:grid-cols-2 xl:grid-cols-4">
<div
class="rounded-md border border-chart-1/25 bg-chart-1/5 px-2.5 py-2 dark:bg-chart-1/10"
>
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-1 uppercase">
<CircleDot class="size-3.5 shrink-0" aria-hidden="true" />
Статус
</p>
<Badge class="mt-1" variant={jobStatusVariant(job.status)}
>{jobStatusRu(job.status)}</Badge
>
</div>
<div
class="rounded-md border border-chart-2/25 bg-chart-2/5 px-2.5 py-2 dark:bg-chart-2/10"
>
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-2 uppercase">
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
Создана
</p>
<p class="mt-1">{formatDateTime(job.created_at)}</p>
</div>
<div
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
>
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-3 uppercase">
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
Запущена
</p>
<p class="mt-1">{formatDateTime(job.started_at)}</p>
</div>
<div
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
>
<p class="flex items-center gap-1 text-[11px] font-medium text-chart-4 uppercase">
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
Завершена
</p>
<p class="mt-1">{formatDateTime(job.finished_at)}</p>
</div>
</div>
</div>
{#if expandedJobIds.has(job.job_id)}
{@const detailedJob = jobDetailsById.get(job.job_id) ?? job}
{@const isDetailsLoading = jobDetailsLoading.has(job.job_id)}
{@const isReportLoading = jobReportsLoading.has(job.job_id)}
{@const jobReport = jobReportsById.get(job.job_id)}
{@const logEntries = getJobLogEntries(detailedJob)}
{@const logTotal = getJobLogTotal(detailedJob, logEntries)}
<div class="border-t bg-muted/10 p-3 sm:p-4">
<div class="space-y-3 pr-1">
{#if isDetailsLoading}
<p class="text-xs text-muted-foreground">Догружаем свежие детали задачи…</p>
{/if}
{#if detailedJob.error}
<div class="rounded-md border border-destructive/30 bg-destructive/5 p-3">
<p class="mb-1 text-xs text-muted-foreground">Ошибка</p>
<p class="text-sm break-words text-destructive">{detailedJob.error}</p>
</div>
{/if}
{#if logEntries.length > 0}
<div class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium">Журнал обработки</p>
<p
class="rounded-md border bg-muted/50 px-2 py-0.5 text-xs text-muted-foreground"
>
{logEntries.length} записей, всего {logTotal} префиксов
</p>
</div>
<div class="rounded-md border bg-muted/10 p-2">
<div class="space-y-2">
{#each logEntries as entry, idx (`${job.job_id}-${idx}`)}
<div
class="space-y-3 rounded-lg border border-border/70 bg-card p-3 shadow-sm"
>
<p class="text-sm leading-relaxed font-medium break-words">
{entry.message}
</p>
<div class="grid gap-2 md:grid-cols-2">
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span
class="text-[11px] tracking-wide text-muted-foreground uppercase"
>Источник</span
>
<p
class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs break-all"
>
{entry.source}
</p>
</div>
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span
class="text-[11px] tracking-wide text-muted-foreground uppercase"
>Тип</span
>
<p
class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs break-all"
>
{logKindRu(entry.kind)}
</p>
</div>
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span
class="text-[11px] tracking-wide text-muted-foreground uppercase"
>Сообщество BGP</span
>
<p
class="mt-1 rounded bg-background px-1.5 py-0.5 text-xs break-words"
title={entry.community !== 'none' ? entry.community : undefined}
>
{entry.community_label?.trim() || entry.community}
</p>
</div>
<div class="min-w-0 rounded-md border bg-muted/60 px-2.5 py-2">
<span
class="text-[11px] tracking-wide text-muted-foreground uppercase"
>Префиксы</span
>
<p class="mt-1 rounded bg-background px-1.5 py-0.5 font-mono text-xs">
{entry.prefix_count}
</p>
</div>
</div>
{#if entry.sample && entry.sample.length > 0}
<div class="space-y-1.5">
<p class="text-[11px] tracking-wide text-muted-foreground uppercase">
Примеры
</p>
<div class="rounded-md border bg-muted/35 p-2.5">
<div class="space-y-1.5">
{#each entry.sample as sampleValue, sampleIdx (`${job.job_id}-${idx}-sample-${sampleIdx}`)}
<p
class="rounded bg-background px-2 py-1 font-mono text-xs break-all"
>
{sampleValue}
</p>
{/each}
</div>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
</div>
{/if}
{#if isReportLoading}
<p class="text-xs text-muted-foreground">
Собираем подробный отчёт по источникам и агрегации…
</p>
{/if}
{#if jobReport}
<div class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm font-medium">Операции по модулю</p>
<div class="flex flex-wrap gap-1.5">
{#if jobReport.module}
<Badge variant="outline">{moduleTypeRu(jobReport.module.type)}</Badge>
<Badge variant="secondary">{jobReport.module.name}</Badge>
{/if}
{#if jobReport.revisionId}
<Badge variant="outline" class="font-mono text-[10px]"
>{jobReport.revisionId.slice(0, 8)}</Badge
>
{/if}
</div>
</div>
<div class="grid min-w-0 gap-2 md:grid-cols-2 xl:grid-cols-4">
<div
class="min-w-0 overflow-hidden rounded-md border border-chart-1/30 bg-chart-1/5 p-2.5 dark:bg-chart-1/10"
>
<p
class="flex items-center gap-1 text-[11px] font-medium text-chart-1 uppercase"
>
<Layers class="size-3.5 shrink-0" aria-hidden="true" />
Агрегация
</p>
<p class="text-sm font-semibold">{jobReport.aggregationTotal} префиксов</p>
</div>
<div
class="min-w-0 overflow-hidden rounded-md border border-chart-2/30 bg-chart-2/5 p-2.5 dark:bg-chart-2/10"
>
<p
class="flex items-center gap-1 text-[11px] font-medium text-chart-2 uppercase"
>
<Globe class="size-3.5 shrink-0" aria-hidden="true" />
Домены
</p>
<p class="text-sm font-semibold">{jobReport.domains.length}</p>
</div>
<div
class="min-w-0 overflow-hidden rounded-md border border-chart-3/30 bg-chart-3/5 p-2.5 dark:bg-chart-3/10"
>
<p
class="flex items-center gap-1 text-[11px] font-medium text-chart-3 uppercase"
>
<Binary class="size-3.5 shrink-0" aria-hidden="true" />
ASN
</p>
<p class="text-sm font-semibold">{jobReport.asn.length}</p>
</div>
<div
class="min-w-0 overflow-hidden rounded-md border border-chart-4/30 bg-chart-4/5 p-2.5 dark:bg-chart-4/10"
>
<p
class="flex items-center gap-1 text-[11px] font-medium text-chart-4 uppercase"
>
<Link2 class="size-3.5 shrink-0" aria-hidden="true" />
CDN / IP-диапазоны
</p>
<p class="text-sm font-semibold">
{jobReport.cdn.length}/{jobReport.ipRanges.length}
</p>
</div>
</div>
{#if jobReport.aggregationByKind.length > 0}
<div class="space-y-1">
<p class="text-xs text-muted-foreground">Результат агрегации по типам</p>
<div class="flex flex-wrap gap-1.5">
{#each jobReport.aggregationByKind as row (`${job.job_id}-agg-${row.kind}`)}
<Badge variant="outline">{logKindRu(row.kind)}: {row.prefixCount}</Badge>
{/each}
</div>
</div>
{/if}
<div class="grid min-w-0 gap-3 xl:grid-cols-2">
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">
Домены: какой домен какие IP/префиксы вернул
</p>
<JobReportTableBlock
rows={jobReport.domains as RowData[]}
columns={reportCols}
emptyLabel="Нет данных по доменам"
/>
</div>
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">ASN: сколько префиксов получено по AS</p>
<JobReportTableBlock
rows={jobReport.asn as RowData[]}
columns={asnCols}
emptyLabel="Нет данных по ASN"
/>
</div>
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">
CDN: из каждой ссылки полученные IP/префиксы
</p>
<JobReportTableBlock
rows={jobReport.cdn as RowData[]}
columns={reportCols}
emptyLabel="Нет данных по CDN"
/>
</div>
<div class="min-w-0 space-y-1.5 rounded-lg border p-3">
<p class="text-sm font-medium">
IP-диапазоны: итог по статическим диапазонам
</p>
<JobReportTableBlock
rows={jobReport.ipRanges as RowData[]}
columns={reportCols}
emptyLabel="Нет данных по IP range"
/>
</div>
</div>
</div>
{/if}
<div class="space-y-1">
<p class="text-xs text-muted-foreground">Meta (JSON)</p>
<div class="rounded-md border bg-muted/30 p-3">
<pre
class="font-mono text-xs [overflow-wrap:anywhere] whitespace-pre-wrap">{JSON.stringify(
detailedJob.meta ?? {},
null,
2
)}</pre>
</div>
</div>
</div>
</div>
{/if}
</div>
{:else}
{#if jobsLoading}
<div class="py-8 text-center text-sm text-muted-foreground">Загрузка…</div>
{:else}
<EmptyState
title="Нет задач"
description="Задачи появятся после refresh, apply или rollback."
/>
{/if}
{/each}
</CardContent>
</Card>
@@ -0,0 +1,174 @@
<script lang="ts">
import type { BirdStatus } from '$lib/api/types.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Card } from '@evobgp/ui/components/card/index.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Play from '@lucide/svelte/icons/play';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import Bird from '@lucide/svelte/icons/bird';
import { cn } from '$lib/utils.js';
type Props = {
applying: boolean;
reloading: boolean;
birdLoading: boolean;
birdStatus: BirdStatus | null;
onApply: () => void;
onReload: () => void;
onRefreshBirdStatus: () => void;
onOpenBirdProtocols: () => void;
birdHealthyBadgeVariant: (
h: boolean | null | undefined
) => 'default' | 'secondary' | 'outline' | 'destructive';
birdHealthyShortLabel: (h: boolean | null | undefined) => string;
};
let {
applying,
reloading,
birdLoading,
birdStatus,
onApply,
onReload,
onRefreshBirdStatus,
onOpenBirdProtocols,
birdHealthyBadgeVariant,
birdHealthyShortLabel
}: Props = $props();
</script>
<div class="grid grid-cols-1 items-stretch gap-3 sm:grid-cols-2 xl:grid-cols-3">
<Card
class={cn('min-w-0 overflow-hidden border-l-4 border-l-chart-1 bg-chart-1/5 p-4 shadow-sm')}
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 flex-1 gap-3">
<div
class="flex size-11 shrink-0 items-center justify-center rounded-xl bg-chart-1/20"
aria-hidden="true"
>
<Play class="size-5 text-chart-1" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<p class="font-semibold">Применить ко всем спикерам</p>
<p class="max-w-[42ch] text-sm text-muted-foreground">
Применить текущую конфигурацию на всех BIRD-спикерах
</p>
</div>
</div>
<Button
class="w-full shrink-0 self-start sm:w-auto sm:self-auto"
onclick={onApply}
disabled={applying}
>
<Play class="size-4" aria-hidden="true" />
Применить
</Button>
</div>
</Card>
<Card
class={cn('min-w-0 overflow-hidden border-l-4 border-l-chart-4 bg-chart-4/5 p-4 shadow-sm')}
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div class="flex min-w-0 flex-1 gap-3">
<div
class="flex size-11 shrink-0 items-center justify-center rounded-xl bg-chart-4/20"
aria-hidden="true"
>
<RotateCcw class="size-5 text-chart-4" />
</div>
<div class="min-w-0 flex-1 space-y-1">
<p class="font-semibold">Перезагрузка BIRD</p>
<p class="max-w-[42ch] text-sm text-muted-foreground">
Перезагрузить конфигурацию BIRD на всех спикерах
</p>
</div>
</div>
<Button
variant="outline"
class="w-full shrink-0 self-start sm:w-auto sm:self-auto"
onclick={onReload}
disabled={reloading}
>
<RotateCcw class="size-4" aria-hidden="true" />
Перезагрузить
</Button>
</div>
</Card>
<Card
class={cn(
'min-w-0 overflow-hidden border-l-4 border-l-info bg-info/10 p-4 shadow-sm sm:col-span-2 xl:col-span-1'
)}
>
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div class="min-w-0 flex-1 space-y-1">
<div class="flex flex-wrap items-center gap-2">
<div
class="mr-0.5 flex size-9 items-center justify-center rounded-lg bg-info/15"
aria-hidden="true"
>
<Bird class="size-5 shrink-0 text-info" />
</div>
<p class="font-semibold">Состояние BIRD</p>
{#if birdStatus}
<Badge
variant={birdHealthyBadgeVariant(birdStatus.healthy)}
class={birdStatus.healthy === true
? 'border-success/30 bg-success/15 text-success'
: undefined}
>
{birdHealthyShortLabel(birdStatus.healthy)}
</Badge>
{/if}
</div>
<p class="max-w-[56ch] text-sm text-foreground/80">
Локально на хосте API: <code class="rounded bg-muted px-1 text-xs"
>birdc show protocols</code
>. Не заменяет мониторинг спикеров.
</p>
{#if birdLoading}
<p class="text-sm text-foreground/80">Загрузка…</p>
{:else if birdStatus}
{#if !birdStatus.birdc_configured}
<p class="text-sm text-foreground/80">
{birdStatus.message ?? 'birdc не настроен на API.'}
</p>
{:else if birdStatus.error}
<p class="text-sm text-destructive">{birdStatus.error}</p>
{:else}
<p class="text-sm">
<span class="text-muted-foreground">BGP сессий:</span>
<span class="font-medium">{birdStatus.bgp_established}</span>
<span class="text-muted-foreground">/</span>
<span class="font-medium">{birdStatus.bgp_sessions_total}</span>
<span class="text-muted-foreground"> установлено / всего</span>
</p>
{/if}
{:else}
<p class="text-sm text-foreground/80">Статус не загружен</p>
{/if}
</div>
<div class="flex w-full shrink-0 flex-wrap gap-2 sm:w-auto sm:flex-col sm:items-stretch">
<Button
variant="outline"
size="sm"
class="w-full sm:w-auto"
onclick={onRefreshBirdStatus}
disabled={birdLoading}
>
<RefreshCw class={birdLoading ? 'size-3.5 animate-spin' : 'size-3.5'} />
Обновить
</Button>
{#if birdStatus?.birdc_configured && birdStatus.protocols_excerpt}
<Button variant="ghost" size="sm" class="w-full sm:w-auto" onclick={onOpenBirdProtocols}>
Вывод birdc
</Button>
{/if}
</div>
</div>
</Card>
</div>
@@ -0,0 +1,120 @@
<script lang="ts">
import type { RevisionRow } from '$lib/api/types.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import { formatDateTime } from '$lib/modules/display.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Undo from '@lucide/svelte/icons/undo';
import Eye from '@lucide/svelte/icons/eye';
import Download from '@lucide/svelte/icons/download';
type Props = {
revisions: RevisionRow[];
revLoading: boolean;
onReload: () => void;
onOpenPreview: (rev: RevisionRow) => void;
onRollbackRequest: (rev: RevisionRow) => void;
onDownloadDiagnosticLog: (rev: RevisionRow) => void;
};
let {
revisions,
revLoading,
onReload,
onOpenPreview,
onRollbackRequest,
onDownloadDiagnosticLog
}: Props = $props();
const columns = [
{
id: 'id',
label: 'ID',
sortable: true,
sortValue: (rev: RevisionRow) => rev.id
},
{
id: 'created',
label: 'Создана',
sortable: true,
sortValue: (rev: RevisionRow) => rev.created_at ?? ''
},
{
id: 'prefixes',
label: 'Префиксов',
sortable: true,
sortValue: (rev: RevisionRow) => rev.materialized_prefix_count ?? 0
},
{ id: 'hash', label: 'Хэш' },
{ id: 'actions', label: '', class: 'w-32' }
] as const;
</script>
<Card>
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0 flex-1">
<CardTitle class="text-base">История ревизий</CardTitle>
<CardDescription class="max-w-[75ch]">
Создаются задачей module_refresh (CDN / IP / AS и т.д.); в превью есть полный текст BIRD с
инклюдами
</CardDescription>
</div>
<Button
variant="outline"
size="sm"
class="shrink-0 self-start sm:self-auto"
onclick={onReload}
disabled={revLoading}
>
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
</Button>
</CardHeader>
<CardContent class="min-w-0 p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={revisions}
rowKey={(rev) => rev.id}
loading={revLoading}
emptyTitle="Нет ревизий"
emptyDescription="Ревизии появятся после обновления модулей."
>
{#snippet cell({ row: rev, column })}
{#if column.id === 'id'}
<span class="font-mono text-xs">{rev.id.slice(0, 8)}</span>
{:else if column.id === 'created'}
<span class="text-sm">{formatDateTime(rev.created_at)}</span>
{:else if column.id === 'prefixes'}
{rev.materialized_prefix_count}
{:else if column.id === 'hash'}
<span class="font-mono text-xs text-muted-foreground"
>{rev.content_hash.slice(0, 12)}</span
>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
<Eye class="size-3.5" />
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
<Undo class="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-sm"
title="Скачать диагностический лог"
onclick={() => onDownloadDiagnosticLog(rev)}
>
<Download class="size-3.5" />
</Button>
</div>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
@@ -0,0 +1,42 @@
import type { ColumnDef } from '@tanstack/table-core';
import type { ReportRow } from './types.js';
export const reportRowColumns: ColumnDef<ReportRow>[] = [
{
accessorKey: 'label',
header: 'Метка',
cell: ({ row }) => {
const v = row.original.label;
return typeof v === 'string' ? v : '—';
}
},
{
accessorKey: 'prefixCount',
header: 'Префиксов',
cell: ({ row }) => String(row.original.prefixCount)
},
{
id: 'prefixes',
header: 'Префиксы',
cell: ({ row }) => {
const p = row.original.prefixes;
if (!p?.length) return '—';
return p.join(', ');
}
}
];
export type AsnReportRow = { asn: string; prefixCount: number };
export const asnReportColumns: ColumnDef<AsnReportRow>[] = [
{
accessorKey: 'asn',
header: 'ASN',
cell: ({ row }) => `AS${row.original.asn}`
},
{
accessorKey: 'prefixCount',
header: 'Префиксов',
cell: ({ row }) => String(row.original.prefixCount)
}
];
@@ -0,0 +1,107 @@
<script lang="ts">
import {
type ColumnDef,
type PaginationState,
type RowData,
getCoreRowModel,
getPaginationRowModel
} from '@tanstack/table-core';
import { createSvelteTable, FlexRender } from '@evobgp/ui/components/data-table/index.js';
import * as Table from '@evobgp/ui/components/table/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
type Props = {
rows: RowData[];
columns: ColumnDef<RowData, unknown>[];
emptyLabel: string;
};
let { rows, columns, emptyLabel }: Props = $props();
let pagination = $state<PaginationState>({ pageIndex: 0, pageSize: 10 });
const table = createSvelteTable({
get data() {
return rows;
},
get columns() {
return columns;
},
state: {
get pagination() {
return pagination;
}
},
onPaginationChange: (updater) => {
pagination = typeof updater === 'function' ? updater(pagination) : updater;
},
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel()
});
</script>
<div class="rounded-md border">
<Table.Root>
<Table.Header>
{#each table.getHeaderGroups() as headerGroup (headerGroup.id)}
<Table.Row>
{#each headerGroup.headers as header (header.id)}
<Table.Head class="max-w-[min(28rem,40vw)]">
{#if !header.isPlaceholder}
<FlexRender
content={header.column.columnDef.header}
context={header.getContext()}
/>
{/if}
</Table.Head>
{/each}
</Table.Row>
{/each}
</Table.Header>
<Table.Body>
{#each table.getRowModel().rows as row (row.id)}
<Table.Row>
{#each row.getVisibleCells() as cell (cell.id)}
<Table.Cell
class="max-w-[min(28rem,40vw)] align-top font-mono text-xs [overflow-wrap:anywhere] break-all whitespace-pre-wrap"
>
<FlexRender content={cell.column.columnDef.cell} context={cell.getContext()} />
</Table.Cell>
{/each}
</Table.Row>
{:else}
<Table.Row>
<Table.Cell
colspan={columns.length}
class="text-muted-foreground h-16 text-center text-sm"
>
{emptyLabel}
</Table.Cell>
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
{#if table.getPageCount() > 1}
<div class="flex items-center justify-end gap-2 pt-2">
<p class="mr-auto text-xs text-muted-foreground">
Стр. {table.getState().pagination.pageIndex + 1} из {table.getPageCount()} ({rows.length} строк)
</p>
<Button
variant="outline"
size="sm"
onclick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
>
Назад
</Button>
<Button
variant="outline"
size="sm"
onclick={() => table.nextPage()}
disabled={!table.getCanNextPage()}
>
Далее
</Button>
</div>
{/if}
@@ -0,0 +1,30 @@
import type { ModuleRow } from '$lib/api/types.js';
export type JobLogEntry = {
kind: string;
source: string;
community: string;
/** Человекочитаемое имя из справочника (title или BGP community). */
community_label?: string;
prefix_count: number;
sample?: string[];
message: string;
};
export type ReportRow = {
label: string;
source: string;
prefixes: string[];
prefixCount: number;
};
export type JobDetailedReport = {
revisionId: string | null;
module: ModuleRow | null;
aggregationTotal: number;
aggregationByKind: Array<{ kind: string; prefixCount: number }>;
domains: ReportRow[];
asn: Array<{ asn: string; prefixCount: number }>;
cdn: ReportRow[];
ipRanges: ReportRow[];
};
@@ -0,0 +1,120 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { PeerRow, SpeakerRow } from '$lib/api/types.js';
import {
aggregateNetworkMetrics,
collectNetworkIssues,
deriveNetworkOverallStatus,
networkOverallStatusHint,
networkOverallStatusLabel
} from '$lib/network/network-metrics.js';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import CheckCircle from '@lucide/svelte/icons/check-circle';
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
import XCircle from '@lucide/svelte/icons/x-circle';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import NetworkIcon from '@lucide/svelte/icons/network';
type Props = {
peers: PeerRow[];
speakers: SpeakerRow[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
};
let { peers, speakers, loading = false, initialLoading = false, error = null }: Props = $props();
const metrics = $derived(aggregateNetworkMetrics(peers, speakers));
const overallStatus = $derived(deriveNetworkOverallStatus(metrics));
const overallHint = $derived(networkOverallStatusHint(overallStatus, metrics));
const issues = $derived(collectNetworkIssues(peers, speakers, 3));
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="flex items-center gap-2 text-base">
<NetworkIcon class="size-4" />
Сеть (BGP)
</CardTitle>
<CardDescription>Live-статус пиров и спикеров</CardDescription>
</div>
<Button variant="outline" size="sm" href={resolve('/network?tab=overview')}>
Подробнее
<ArrowRight class="size-3.5" />
</Button>
</CardHeader>
<CardContent class="space-y-3 p-4 pt-4">
{#if error}
<p class="text-sm text-destructive">{error}</p>
{:else if initialLoading || loading}
<p class="text-sm text-muted-foreground">Загрузка live-метрик…</p>
{:else if overallStatus === 'ok'}
<Alert class="border-success/30 bg-success/5 py-3">
<CheckCircle class="text-success" />
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription class="text-xs">{overallHint}</AlertDescription>
</Alert>
{:else if overallStatus === 'warn'}
<Alert class="border-warning/30 bg-warning/5 py-3">
<AlertTriangle class="text-warning" />
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription class="text-xs">
{overallHint}
{#if issues.length > 0}
<ul class="mt-2 list-inside list-disc">
{#each issues as issue (issue.id)}
<li>{issue.message}</li>
{/each}
</ul>
{/if}
</AlertDescription>
</Alert>
{:else}
<Alert variant="destructive" class="py-3">
<XCircle />
<AlertTitle class="text-sm">{networkOverallStatusLabel(overallStatus)}</AlertTitle>
<AlertDescription class="text-xs">
{overallHint}
{#if issues.length > 0}
<ul class="mt-2 list-inside list-disc">
{#each issues as issue (issue.id)}
<li>{issue.message}</li>
{/each}
</ul>
{/if}
</AlertDescription>
</Alert>
{/if}
<div class="flex flex-wrap gap-4 text-sm">
<div>
<p class="text-muted-foreground">Пиры Established</p>
<p class="text-xl font-bold tabular-nums">
{initialLoading ? '—' : `${metrics.peersEstablished}/${metrics.peersEnabled}`}
</p>
</div>
<div>
<p class="text-muted-foreground">Спикеры online</p>
<p class="text-xl font-bold tabular-nums">
{initialLoading ? '—' : `${metrics.speakersOnline}/${metrics.speakersTotal}`}
</p>
</div>
<div>
<p class="text-muted-foreground">Drift</p>
<p class="text-xl font-bold tabular-nums">{initialLoading ? '—' : metrics.speakersDrift}</p>
</div>
</div>
</CardContent>
</Card>
@@ -0,0 +1,94 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { JobRow } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
import { jobStatusRu, jobStatusBadgeVariant } from '$lib/ui-labels.js';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import ExternalLink from '@lucide/svelte/icons/external-link';
type Props = {
items: JobRow[];
moduleNameById: ReadonlyMap<string, string>;
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
};
let {
items,
moduleNameById,
loading = false,
initialLoading = false,
error = null
}: Props = $props();
const columns = [
{ id: 'kind', label: 'Вид', sortable: true, sortValue: (j: JobRow) => j.kind },
{
id: 'status',
label: 'Статус',
sortable: true,
sortValue: (j: JobRow) => j.status
},
{
id: 'created',
label: 'Создана',
sortable: true,
sortValue: (j: JobRow) => j.created_at ?? ''
},
{ id: 'actions', label: '', class: 'w-10' }
] as const;
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Последние задачи</CardTitle>
<CardDescription>Фоновые задачи ingest, refresh и apply</CardDescription>
</div>
<Button variant="outline" size="sm" href={resolve('/operations?tab=jobs')}>
Все
<ArrowRight class="size-3.5" />
</Button>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(j) => j.job_id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет задач"
emptyDescription="Задачи появятся после refresh или деплоя."
>
{#snippet cell({ row: j, column })}
{#if column.id === 'kind'}
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
{:else if column.id === 'status'}
<Badge variant={jobStatusBadgeVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
{:else if column.id === 'created'}
<span class="text-xs whitespace-nowrap text-muted-foreground">
{formatDateTime(j.created_at)}
</span>
{:else if column.id === 'actions'}
<Button variant="ghost" size="icon-sm" href={resolve('/operations?tab=jobs')}>
<ExternalLink class="size-3.5" />
</Button>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
@@ -0,0 +1,89 @@
<script lang="ts">
import { resolve } from '$app/paths';
import type { RevisionRow } from '$lib/api/types.js';
import { formatDateTime } from '$lib/modules/display.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
import ExternalLink from '@lucide/svelte/icons/external-link';
type Props = {
items: RevisionRow[];
loading?: boolean;
initialLoading?: boolean;
error?: string | null;
};
let { items, loading = false, initialLoading = false, error = null }: Props = $props();
const columns = [
{
id: 'id',
label: 'ID',
sortable: true,
sortValue: (rev: RevisionRow) => rev.id
},
{
id: 'created',
label: 'Создана',
sortable: true,
sortValue: (rev: RevisionRow) => rev.created_at ?? ''
},
{
id: 'prefixes',
label: 'Префиксов',
sortable: true,
sortValue: (rev: RevisionRow) => rev.materialized_prefix_count ?? 0
},
{ id: 'actions', label: '', class: 'w-10' }
] as const;
</script>
<Card>
<CardHeader
class="flex flex-col gap-3 border-b py-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="min-w-0 flex-1">
<CardTitle class="text-base">Последние ревизии</CardTitle>
<CardDescription>Снимки конфигурации BIRD после обновления модулей</CardDescription>
</div>
<Button variant="outline" size="sm" href={resolve('/operations')}>
Все
<ArrowRight class="size-3.5" />
</Button>
</CardHeader>
<CardContent class="p-4 pt-0">
<AppDataTable
columns={[...columns]}
rows={items}
rowKey={(rev) => rev.id}
loading={initialLoading || loading}
{error}
emptyTitle="Нет ревизий"
emptyDescription="Ревизии появятся после обновления модулей."
>
{#snippet cell({ row: rev, column })}
{#if column.id === 'id'}
<span class="font-mono text-xs">{rev.id.slice(0, 8)}</span>
{:else if column.id === 'created'}
<span class="text-sm whitespace-nowrap text-muted-foreground">
{formatDateTime(rev.created_at)}
</span>
{:else if column.id === 'prefixes'}
<span class="tabular-nums">{rev.materialized_prefix_count}</span>
{:else if column.id === 'actions'}
<Button variant="ghost" size="icon-sm" href={resolve('/operations')}>
<ExternalLink class="size-3.5" />
</Button>
{/if}
{/snippet}
</AppDataTable>
</CardContent>
</Card>
@@ -0,0 +1,15 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
type Props = {
children: Snippet;
class?: string;
};
let { children, class: className }: Props = $props();
</script>
<div class={cn('flex flex-col gap-4 md:gap-6', className)}>
{@render children()}
</div>
@@ -0,0 +1,55 @@
<script lang="ts">
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle
} from '@evobgp/ui/components/alert-dialog/index.js';
import { closeConfirm, confirmState } from './confirm-state.svelte.js';
const state = $derived(confirmState.current);
const open = $derived(!!state?.open);
function onOpenChange(v: boolean) {
if (!v && state && !state.loading) closeConfirm();
}
async function handleConfirm() {
if (!state || state.loading) return;
await state.onConfirm();
}
</script>
<AlertDialog {open} {onOpenChange}>
{#if state}
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{state.title}</AlertDialogTitle>
{#if state.description}
<AlertDialogDescription>{state.description}</AlertDialogDescription>
{/if}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={state.loading} onclick={() => closeConfirm()}>
{state.cancelLabel ?? 'Отмена'}
</AlertDialogCancel>
<AlertDialogAction
class={state.destructive
? 'text-destructive-foreground bg-destructive hover:bg-destructive/90'
: ''}
disabled={state.loading}
onclick={(e) => {
e.preventDefault();
void handleConfirm();
}}
>
{state.loading ? '…' : (state.confirmLabel ?? 'Подтвердить')}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
{/if}
</AlertDialog>
@@ -0,0 +1,42 @@
export type ConfirmOptions = {
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
destructive?: boolean;
onConfirm: () => void | Promise<void>;
};
export type ConfirmState = ConfirmOptions & {
open: boolean;
loading: boolean;
};
export const confirmState = $state<{ current: ConfirmState | null }>({ current: null });
export function confirm(options: ConfirmOptions) {
return new Promise<boolean>((resolve) => {
confirmState.current = {
...options,
open: true,
loading: false,
onConfirm: async () => {
const current = confirmState.current;
if (!current || current.loading) return;
confirmState.current = { ...current, loading: true };
try {
await options.onConfirm();
resolve(true);
} catch {
resolve(false);
} finally {
confirmState.current = null;
}
}
};
});
}
export function closeConfirm() {
confirmState.current = null;
}
@@ -0,0 +1,189 @@
<script lang="ts" generics="T extends Record<string, unknown>">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
import * as Table from '@evobgp/ui/components/table/index.js';
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import EmptyState from '$lib/components/patterns/empty-state/empty-state.svelte';
import ArrowUpDown from '@lucide/svelte/icons/arrow-up-down';
import ArrowUp from '@lucide/svelte/icons/arrow-up';
import ArrowDown from '@lucide/svelte/icons/arrow-down';
import { Button } from '@evobgp/ui/components/button/index.js';
import type { DataTableColumn } from './types.js';
type Props = {
columns: DataTableColumn<T>[];
rows: T[];
rowKey: (row: T) => string;
loading?: boolean;
error?: string | null;
emptyTitle?: string;
emptyDescription?: string;
/** Client-side page size; 0 = all rows */
pageSize?: number;
toolbar?: Snippet;
cell: Snippet<[{ row: T; column: DataTableColumn<T> }]>;
class?: string;
};
let {
columns,
rows,
rowKey,
loading = false,
error = null,
emptyTitle = 'Нет записей',
emptyDescription,
pageSize = 0,
toolbar,
cell,
class: className
}: Props = $props();
let sortColumnId = $state<string | null>(null);
let sortDir = $state<'asc' | 'desc'>('asc');
let pageIndex = $state(0);
const sortedRows = $derived.by(() => {
if (!sortColumnId) return rows;
const col = columns.find((c) => c.id === sortColumnId);
if (!col?.sortValue) return rows;
const getter = col.sortValue;
const copy = [...rows];
copy.sort((a, b) => {
const av = getter(a);
const bv = getter(b);
const as = av == null ? '' : String(av);
const bs = bv == null ? '' : String(bv);
const cmp = as.localeCompare(bs, 'ru', { numeric: true });
return sortDir === 'asc' ? cmp : -cmp;
});
return copy;
});
const paginatedRows = $derived.by(() => {
if (!pageSize || pageSize <= 0) return sortedRows;
const start = pageIndex * pageSize;
return sortedRows.slice(start, start + pageSize);
});
const pageCount = $derived(
pageSize > 0 ? Math.max(1, Math.ceil(sortedRows.length / pageSize)) : 1
);
$effect(() => {
rows;
pageIndex = 0;
});
function toggleSort(col: DataTableColumn<T>) {
if (!col.sortable) return;
if (sortColumnId === col.id) {
sortDir = sortDir === 'asc' ? 'desc' : 'asc';
} else {
sortColumnId = col.id;
sortDir = 'asc';
}
}
const skeletonRows = 5;
</script>
<div class={cn('flex flex-col gap-3', className)}>
{#if toolbar}
<div class="flex flex-wrap items-center justify-between gap-2">{@render toolbar()}</div>
{/if}
{#if error}
<Alert variant="destructive">
<AlertTitle>Ошибка загрузки</AlertTitle>
<AlertDescription>{error}</AlertDescription>
</Alert>
{/if}
<div class="rounded-md border">
<Table.Root>
<Table.Header>
<Table.Row>
{#each columns as col (col.id)}
<Table.Head class={col.headerClass ?? col.class}>
{#if col.sortable}
<Button
variant="ghost"
size="sm"
class="-ms-2 h-8 gap-1 px-2 font-medium"
onclick={() => toggleSort(col)}
>
{col.label}
{#if sortColumnId === col.id}
{#if sortDir === 'asc'}
<ArrowUp class="size-3.5" />
{:else}
<ArrowDown class="size-3.5" />
{/if}
{:else}
<ArrowUpDown class="size-3.5 text-muted-foreground" />
{/if}
</Button>
{:else}
{col.label}
{/if}
</Table.Head>
{/each}
</Table.Row>
</Table.Header>
<Table.Body>
{#if loading}
{#each Array(skeletonRows) as _, i (i)}
<Table.Row>
{#each columns as col (col.id)}
<Table.Cell class={col.class}>
<Skeleton class="h-5 w-full max-w-[12rem]" />
</Table.Cell>
{/each}
</Table.Row>
{/each}
{:else if sortedRows.length === 0}
<Table.Row>
<Table.Cell colspan={columns.length} class="p-0">
<EmptyState title={emptyTitle} description={emptyDescription} />
</Table.Cell>
</Table.Row>
{:else}
{#each paginatedRows as row (rowKey(row))}
<Table.Row>
{#each columns as col (col.id)}
<Table.Cell class={col.class}>
{@render cell({ row, column: col })}
</Table.Cell>
{/each}
</Table.Row>
{/each}
{/if}
</Table.Body>
</Table.Root>
</div>
{#if pageSize > 0 && sortedRows.length > pageSize}
<div class="flex items-center justify-between gap-2 text-sm text-muted-foreground">
<span>
{pageIndex * pageSize + 1}{Math.min((pageIndex + 1) * pageSize, sortedRows.length)} из
{sortedRows.length}
</span>
<div class="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={pageIndex === 0}
onclick={() => (pageIndex -= 1)}>Назад</Button
>
<Button
variant="outline"
size="sm"
disabled={pageIndex >= pageCount - 1}
onclick={() => (pageIndex += 1)}>Вперёд</Button
>
</div>
</div>
{/if}
</div>
@@ -0,0 +1,8 @@
export type DataTableColumn<T> = {
id: string;
label: string;
sortable?: boolean;
sortValue?: (row: T) => string | number | null | undefined;
class?: string;
headerClass?: string;
};
@@ -0,0 +1,31 @@
<script lang="ts">
import type { Component, Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
type Props = {
title?: string;
description?: string;
icon?: Component;
action?: Snippet;
class?: string;
};
let { title = 'Нет данных', description, icon: Icon, action, class: className }: Props = $props();
</script>
<div
class={cn('flex flex-col items-center justify-center gap-2 px-4 py-12 text-center', className)}
>
{#if Icon}
<div class="mb-1 text-muted-foreground/60" aria-hidden="true">
<Icon class="size-10" />
</div>
{/if}
<p class="text-sm font-medium">{title}</p>
{#if description}
<p class="max-w-sm text-sm text-muted-foreground">{description}</p>
{/if}
{#if action}
<div class="mt-2">{@render action()}</div>
{/if}
</div>
@@ -0,0 +1,15 @@
<script lang="ts">
import { Card, CardContent, CardHeader } from '@evobgp/ui/components/card/index.js';
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
</script>
<Card>
<CardHeader class="gap-2">
<Skeleton class="h-5 w-40" />
<Skeleton class="h-4 w-64" />
</CardHeader>
<CardContent class="flex flex-col gap-3">
<Skeleton class="h-24 w-full" />
<Skeleton class="h-4 w-3/4" />
</CardContent>
</Card>
@@ -0,0 +1,27 @@
<script lang="ts">
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
import * as Table from '@evobgp/ui/components/table/index.js';
type Props = {
columns?: number;
rows?: number;
};
let { columns = 4, rows = 5 }: Props = $props();
</script>
<div class="rounded-md border">
<Table.Root>
<Table.Body>
{#each Array(rows) as _, ri (ri)}
<Table.Row>
{#each Array(columns) as _, ci (ci)}
<Table.Cell>
<Skeleton class="h-5 w-full max-w-[10rem]" />
</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
@@ -0,0 +1,19 @@
<script lang="ts">
import { Input } from '@evobgp/ui/components/input/index.js';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
type Props = ComponentProps<typeof Input> & {
value?: string | number;
error?: boolean;
};
let { class: className, value = $bindable(''), error = false, ...rest }: Props = $props();
</script>
<Input
class={cn(error && 'border-destructive aria-invalid:border-destructive', className)}
aria-invalid={error || undefined}
bind:value
{...rest}
/>
@@ -0,0 +1,17 @@
<script lang="ts">
import { Textarea } from '@evobgp/ui/components/textarea/index.js';
import { cn } from '$lib/utils.js';
import type { ComponentProps } from 'svelte';
type Props = ComponentProps<typeof Textarea> & {
error?: boolean;
};
let { class: className, error = false, ...rest }: Props = $props();
</script>
<Textarea
class={cn(error && 'border-destructive aria-invalid:border-destructive', className)}
aria-invalid={error || undefined}
{...rest}
/>
@@ -0,0 +1,38 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { cn } from '$lib/utils.js';
import { Label } from '@evobgp/ui/components/label/index.js';
type Props = {
label: string;
id: string;
description?: string;
error?: string | null;
required?: boolean;
class?: string;
children: Snippet;
};
let {
label,
id,
description,
error,
required = false,
class: className,
children
}: Props = $props();
</script>
<div class={cn('flex flex-col gap-1.5', className)}>
<Label for={id}>
{label}
{#if required}<span class="text-destructive" aria-hidden="true"> *</span>{/if}
</Label>
{@render children()}
{#if error}
<p class="text-xs text-destructive" role="alert">{error}</p>
{:else if description}
<p class="text-xs text-muted-foreground">{description}</p>
{/if}
</div>
@@ -0,0 +1,112 @@
<script lang="ts">
import type { Component } from 'svelte';
import { Badge } from '@evobgp/ui/components/badge/index.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '@evobgp/ui/components/card/index.js';
import CardSkeleton from '$lib/components/patterns/feedback/card-skeleton.svelte';
import { cn } from '$lib/utils.js';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
export type KpiAccent = {
border: string;
bg: string;
iconBg: string;
iconText: string;
};
export type KpiCardItem = {
id: string;
label: string;
value: string;
description: string;
icon: Component;
accent: KpiAccent;
badge: string;
badgeClass?: string;
badgeVariant?: 'default' | 'secondary' | 'destructive' | 'outline';
valueClass?: string;
error?: string | null;
href?: string;
};
type Props = {
cards: KpiCardItem[];
loading?: boolean;
skeletonCount?: number;
class?: string;
};
let { cards, loading = false, skeletonCount = 3, class: className }: Props = $props();
</script>
<div class={cn('grid auto-rows-fr gap-4', className)}>
{#if loading}
{#each Array(skeletonCount) as _, i (i)}
<CardSkeleton />
{/each}
{:else}
{#each cards as card (card.id)}
{@const Icon = card.icon}
{@const a = card.accent}
<Card
class={cn(
'flex h-full flex-col overflow-hidden border-l-4 shadow-sm',
card.href ? 'transition-colors hover:border-primary/35' : '',
a.border,
a.bg
)}
>
<CardHeader class="pb-2">
{#if card.href}
<div class="flex items-center justify-between gap-2">
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn(
'flex size-9 shrink-0 items-center justify-center rounded-lg',
a.iconBg
)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
<Button variant="ghost" size="icon-sm" href={card.href}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
</div>
{:else}
<CardDescription class="flex min-w-0 items-center gap-2">
<span
class={cn('flex size-9 shrink-0 items-center justify-center rounded-lg', a.iconBg)}
aria-hidden="true"
>
<Icon class={cn('size-4', a.iconText)} />
</span>
<span class="truncate">{card.label}</span>
</CardDescription>
{/if}
<CardTitle class={cn('font-bold tabular-nums', card.valueClass ?? 'text-3xl')}
>{card.value}</CardTitle
>
</CardHeader>
<CardContent class="mt-auto space-y-2">
<Badge variant={card.badgeVariant ?? 'outline'} class={card.badgeClass}
>{card.badge}</Badge
>
{#if card.error}
<p class="text-xs text-destructive">{card.error}</p>
{:else}
<p class="text-xs text-muted-foreground">{card.description}</p>
{/if}
</CardContent>
</Card>
{/each}
{/if}
</div>
@@ -0,0 +1,38 @@
<script lang="ts">
type Props = {
items: readonly string[];
rowHeight?: number;
viewportHeight?: number;
class?: string;
};
let { items, rowHeight = 20, viewportHeight = 320, class: className = '' }: Props = $props();
let scrollTop = $state(0);
const totalHeight = $derived(items.length * rowHeight);
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / rowHeight) - 2));
const visibleCount = $derived(Math.ceil(viewportHeight / rowHeight) + 4);
const visibleItems = $derived(items.slice(startIndex, startIndex + visibleCount));
</script>
<div
class="overflow-auto rounded-md border {className}"
style="height: {viewportHeight}px"
onscroll={(e) => {
scrollTop = e.currentTarget.scrollTop;
}}
>
<div style="height: {totalHeight}px; position: relative">
{#each visibleItems as pfx, i (`${startIndex + i}-${pfx}`)}
<p
class="truncate px-2 font-mono text-xs leading-5"
style="position: absolute; top: {(startIndex + i) *
rowHeight}px; left: 0; right: 0; height: {rowHeight}px"
>
{pfx}
</p>
{:else}
<p class="text-muted-foreground p-2 text-sm">Нет префиксов</p>
{/each}
</div>
</div>
@@ -0,0 +1,69 @@
<script lang="ts" generics="T">
import type { Snippet } from 'svelte';
import AlertCircle from '@lucide/svelte/icons/alert-circle';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import { Button } from '@evobgp/ui/components/button/index.js';
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
import EmptyState from '$lib/components/empty-state.svelte';
type Props = {
data: T | undefined;
isLoading: boolean;
isError: boolean;
error?: unknown;
empty?: boolean;
emptyTitle?: string;
emptyDescription?: string;
emptyAction?: Snippet;
onRetry?: () => void;
skeleton?: Snippet;
children: Snippet<[T]>;
};
let {
data,
isLoading,
isError,
error,
empty = false,
emptyTitle = 'Нет данных',
emptyDescription,
emptyAction,
onRetry,
skeleton,
children
}: Props = $props();
const errorMessage = $derived(
error instanceof Error ? error.message : 'Не удалось загрузить данные'
);
</script>
{#if isLoading}
{#if skeleton}
{@render skeleton()}
{:else}
<div class="flex flex-col gap-3">
<Skeleton class="h-8 w-48" />
<Skeleton class="h-32 w-full" />
</div>
{/if}
{:else if isError}
<EmptyState
title="Ошибка загрузки"
description={errorMessage}
icon={AlertCircle}
action={onRetry ? retryAction : undefined}
/>
{:else if empty || data == null}
<EmptyState title={emptyTitle} description={emptyDescription} action={emptyAction} />
{:else}
{@render children(data)}
{/if}
{#snippet retryAction()}
<Button variant="outline" size="sm" onclick={onRetry}>
<RefreshCw class="size-4" />
Повторить
</Button>
{/snippet}
@@ -0,0 +1,26 @@
<script lang="ts">
import { Card, CardContent } from '@evobgp/ui/components/card/index.js';
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
import { cn } from '$lib/utils.js';
type Props = {
count?: number;
class?: string;
};
let { count = 4, class: className }: Props = $props();
</script>
<div class={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-3', className)}>
{#each Array.from({ length: count }) as _, i (i)}
<Card>
<CardContent class="flex items-start gap-2.5 px-3 py-2.5">
<Skeleton class="size-7 shrink-0 rounded-md" />
<div class="flex flex-1 flex-col gap-2">
<Skeleton class="h-3 w-20" />
<Skeleton class="h-6 w-16" />
</div>
</CardContent>
</Card>
{/each}
</div>
@@ -0,0 +1,111 @@
<script lang="ts">
import type { Component, Snippet } from 'svelte';
import { Card, CardContent } from '@evobgp/ui/components/card/index.js';
import { cn } from '$lib/utils.js';
export type SectionCardItem = {
label: string | Snippet;
value: string | number | Snippet;
hint?: string | Snippet;
icon?: Component;
badge?: Snippet;
variant?: 'default' | 'warning' | 'destructive';
active?: boolean;
onClick?: () => void;
};
type Props = {
items: SectionCardItem[];
class?: string;
};
let { items, class: className }: Props = $props();
const variantClass: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'border-warning/50',
destructive: 'border-destructive/50'
};
const valueVariantClass: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'text-warning',
destructive: 'text-destructive'
};
function sectionGridClass(count: number): string {
if (count <= 1) return 'grid-cols-1';
if (count === 2) return 'sm:grid-cols-2';
if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3';
if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4';
if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5';
if (count === 6) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6';
return 'sm:grid-cols-2 lg:grid-cols-3';
}
</script>
<div class={cn('grid gap-3', sectionGridClass(items.length), className)}>
{#each items as item, idx (typeof item.label === 'string' ? item.label : idx)}
{@const clickable = Boolean(item.onClick)}
<Card
class={cn(
'gap-0',
variantClass[item.variant ?? 'default'],
item.active && 'border-primary ring-1 ring-primary/30',
clickable && 'cursor-pointer transition-colors hover:bg-muted/40'
)}
onclick={item.onClick}
role={clickable ? 'button' : undefined}
tabindex={clickable ? 0 : undefined}
onkeydown={(e) => {
if (clickable && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
item.onClick?.();
}
}}
>
<CardContent class="flex items-start gap-2.5 px-3 py-2.5">
{#if item.icon}
<span
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground"
>
<item.icon class="size-4" />
</span>
{/if}
<div class="flex min-w-0 flex-1 flex-col gap-0.5">
<div class="flex items-center justify-between gap-2">
{#if typeof item.label === 'string'}
<span class="truncate text-xs text-muted-foreground">{item.label}</span>
{:else}
<span class="truncate text-xs text-muted-foreground">{@render item.label()}</span>
{/if}
{#if item.badge}
<span class="shrink-0">{@render item.badge()}</span>
{/if}
</div>
<div class="flex min-w-0 items-baseline gap-1.5">
<span
class={cn(
'flex items-center gap-1 text-lg font-semibold tabular-nums',
valueVariantClass[item.variant ?? 'default']
)}
>
{#if typeof item.value === 'string' || typeof item.value === 'number'}
{item.value}
{:else}
{@render item.value()}
{/if}
</span>
{#if item.hint}
{#if typeof item.hint === 'string'}
<span class="truncate text-xs text-muted-foreground">· {item.hint}</span>
{:else}
<span class="truncate text-xs text-muted-foreground">· {@render item.hint()}</span>
{/if}
{/if}
</div>
</div>
</CardContent>
</Card>
{/each}
</div>
@@ -0,0 +1,30 @@
<script lang="ts">
import { Badge } from '@evobgp/ui/components/badge/index.js';
type BadgeVariant = 'default' | 'secondary' | 'destructive' | 'outline';
const STATUS_VARIANT: Record<string, BadgeVariant> = {
active: 'default',
ok: 'default',
enabled: 'default',
paused: 'secondary',
disabled: 'secondary',
error: 'destructive',
failed: 'destructive',
running: 'outline',
warning: 'outline',
stale: 'outline'
};
type Props = {
status: string;
label?: string;
};
let { status, label }: Props = $props();
const variant = $derived(STATUS_VARIANT[status] ?? 'outline');
const text = $derived(label ?? status);
</script>
<Badge {variant}>{text}</Badge>
@@ -0,0 +1,27 @@
<script lang="ts">
import { Skeleton } from '@evobgp/ui/components/skeleton/index.js';
import * as Table from '@evobgp/ui/components/table/index.js';
type Props = {
columns?: number;
rows?: number;
};
let { columns = 4, rows = 5 }: Props = $props();
</script>
<div class="rounded-md border">
<Table.Root>
<Table.Body>
{#each Array(rows) as _, ri (ri)}
<Table.Row>
{#each Array(columns) as _, ci (ci)}
<Table.Cell>
<Skeleton class="h-5 w-full max-w-[10rem]" />
</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table.Root>
</div>
@@ -0,0 +1,149 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
loadSettings,
partitionSettings,
patchSettings,
type AdditionalSettingEntry
} from '$lib/settings/settings-api.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import EmptyState from '$lib/components/patterns/empty-state/empty-state.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Save from '@lucide/svelte/icons/save';
import Plus from '@lucide/svelte/icons/plus';
import Trash2 from '@lucide/svelte/icons/trash-2';
let loading = $state(false);
let saving = $state(false);
let loaded = $state(false);
let additionalSettings = $state<AdditionalSettingEntry[]>([]);
let additionalIdCounter = $state(1);
let canSave = $derived.by(() => {
if (loading || saving || !loaded) return false;
return additionalSettings.some((entry) => entry.key.trim() !== '');
});
function addAdditionalSetting() {
additionalSettings.push({ id: additionalIdCounter++, key: '', value: '' });
}
function requestRemoveAdditionalSetting(entry: AdditionalSettingEntry) {
const key = entry.key.trim();
void confirm({
title: key ? `Удалить параметр «${key}»?` : 'Удалить строку?',
description: key
? 'Строка исчезнет из формы. Чтобы удалить ключ из tenant, сохраните без него или очистите значение и примените PATCH.'
: 'Несохранённая пустая строка будет удалена из формы.',
confirmLabel: 'Удалить',
destructive: Boolean(key),
onConfirm: async () => {
additionalSettings = additionalSettings.filter((row) => row.id !== entry.id);
}
});
}
async function load() {
loading = true;
try {
const settings = await loadSettings();
const { partitioned, nextId } = partitionSettings(settings, additionalIdCounter);
additionalSettings = partitioned.additional;
additionalIdCounter = nextId;
loaded = true;
} catch (e) {
notifyApiError(e);
} finally {
loading = false;
}
}
async function save() {
if (!canSave) {
notify.error('Нечего сохранять');
return;
}
const payload: Record<string, string> = {};
for (const entry of additionalSettings) {
const key = entry.key.trim();
if (!key) continue;
payload[key] = entry.value;
}
saving = true;
try {
await patchSettings(payload);
notify.success('Дополнительные параметры сохранены');
await load();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
onMount(() => {
void load();
});
</script>
<Card>
<CardHeader>
<div class="flex items-center justify-between gap-4">
<div class="space-y-1">
<CardTitle>Дополнительные параметры</CardTitle>
<CardDescription>Произвольные KV-пары в global_settings (operator).</CardDescription>
</div>
{#if loaded}
<Button variant="outline" size="sm" onclick={addAdditionalSetting}>
<Plus class="size-4" />
Добавить строку
</Button>
{/if}
</div>
</CardHeader>
<CardContent class="space-y-4">
{#if loading && !loaded}
<p class="text-sm text-muted-foreground">Загрузка…</p>
{:else if !loaded}
<Button variant="outline" onclick={load}>Загрузить настройки</Button>
{:else if additionalSettings.length === 0}
<EmptyState
title="Нет дополнительных параметров"
description="Добавьте KV-пару при необходимости."
/>
{:else}
<div class="space-y-2">
{#each additionalSettings as entry (entry.id)}
<div class="grid grid-cols-1 gap-2 md:grid-cols-[1fr_1fr_auto]">
<Input bind:value={entry.key} placeholder="Ключ (например, bird_log_level)" />
<Input bind:value={entry.value} placeholder="Значение (строка)" />
<Button
variant="ghost"
size="icon"
aria-label="Удалить строку"
onclick={() => requestRemoveAdditionalSetting(entry)}
>
<Trash2 class="size-4" />
</Button>
</div>
{/each}
</div>
<Button onclick={save} disabled={!canSave}>
<Save />
{saving ? 'Сохранение…' : 'Применить дополнительные параметры'}
</Button>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,212 @@
<script lang="ts">
import { onMount } from 'svelte';
import { defaults, superForm } from 'sveltekit-superforms';
import { zod4 } from 'sveltekit-superforms/adapters';
import {
birdSettingsSchema,
emptyBirdSettingsForm,
type BirdSettingsForm
} from '$lib/settings/bird-settings.schema.js';
import {
buildPayloadFromFormFields,
loadSettings,
partitionSettings,
patchSettings
} from '$lib/settings/settings-api.js';
import { BIRD_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Save from '@lucide/svelte/icons/save';
import Info from '@lucide/svelte/icons/info';
let loading = $state(false);
let saving = $state(false);
let loaded = $state(false);
const { form, errors, reset, validateForm } = superForm(
defaults(emptyBirdSettingsForm(), zod4(birdSettingsSchema)),
{
validators: zod4(birdSettingsSchema),
SPA: true,
dataType: 'json'
}
);
let hasValidationErrors = $derived(
BIRD_SETTING_KEYS.some((key) => Boolean($errors[key as keyof BirdSettingsForm]?.length))
);
let canSave = $derived.by(() => {
if (loading || saving || hasValidationErrors || !loaded) return false;
return BIRD_SETTING_KEYS.some((key) => {
const value = String($form[key as keyof BirdSettingsForm] ?? '').trim();
return value !== '' && !$errors[key as keyof BirdSettingsForm]?.length;
});
});
async function load() {
loading = true;
try {
const settings = await loadSettings();
const { partitioned } = partitionSettings(settings);
reset({ data: partitioned.bird });
loaded = true;
} catch (e) {
notifyApiError(e);
} finally {
loading = false;
}
}
async function save() {
const validation = await validateForm({ update: true });
if (!validation.valid) {
notify.error('Исправьте ошибки в полях настроек');
return;
}
if (!canSave) {
notify.error('Нечего сохранять или есть ошибки в полях');
return;
}
const payload = buildPayloadFromFormFields(
BIRD_SETTING_KEYS,
$form as Record<string, string>,
$errors as Partial<Record<string, string[]>>
);
saving = true;
try {
await patchSettings(payload);
notify.success('Параметры BIRD сохранены');
await load();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
onMount(() => {
void load();
});
</script>
<Card>
<CardHeader>
<CardTitle>BIRD control plane</CardTitle>
<CardDescription>
Глобальные параметры BIRD для pipeline refresh/apply. Сохранение через
<code class="text-xs">PATCH /v1/settings</code> (роль operator).
</CardDescription>
</CardHeader>
<CardContent class="space-y-5">
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>Подстановка в конфиг</AlertTitle>
<AlertDescription>
Значения используются при генерации BIRD-конфигурации (router id, local AS, адреса). Пиры и
спикеры настраиваются в разделе «Сеть».
</AlertDescription>
</Alert>
{#if loading && !loaded}
<p class="text-sm text-muted-foreground">Загрузка…</p>
{:else if !loaded}
<Button variant="outline" onclick={load}>Загрузить параметры</Button>
{:else}
<div class="space-y-3">
<FormField
id="bird-router-id"
label="Router ID (bird_router_id)"
error={$errors.bird_router_id?.[0]}
>
<Input id="bird-router-id" bind:value={$form.bird_router_id} placeholder="203.0.113.1" />
</FormField>
<FormField
id="bird-local-ipv4"
label="Локальный IPv4 (bird_local_ipv4)"
error={$errors.bird_local_ipv4?.[0]}
>
<Input
id="bird-local-ipv4"
bind:value={$form.bird_local_ipv4}
placeholder="198.51.100.10"
/>
</FormField>
<FormField
id="bird-local-ipv6"
label="Локальный IPv6 (bird_local_ipv6)"
error={$errors.bird_local_ipv6?.[0]}
>
<Input
id="bird-local-ipv6"
bind:value={$form.bird_local_ipv6}
placeholder="2001:db8::10"
/>
</FormField>
<FormField
id="bird-local-asn"
label="Локальный ASN (bird_local_asn)"
error={$errors.bird_local_asn?.[0]}
>
<Input
id="bird-local-asn"
type="number"
min="1"
bind:value={$form.bird_local_asn}
placeholder="65001"
/>
</FormField>
<FormField
id="bird-bgp-source-ipv4"
label="BGP source IPv4 (bird_bgp_source_ipv4)"
error={$errors.bird_bgp_source_ipv4?.[0]}
>
<Input
id="bird-bgp-source-ipv4"
bind:value={$form.bird_bgp_source_ipv4}
placeholder="198.51.100.11"
/>
</FormField>
<FormField
id="bird-bgp-source-ipv6"
label="BGP source IPv6 (bird_bgp_source_ipv6)"
error={$errors.bird_bgp_source_ipv6?.[0]}
>
<Input
id="bird-bgp-source-ipv6"
bind:value={$form.bird_bgp_source_ipv6}
placeholder="2001:db8::11"
/>
</FormField>
</div>
{#if hasValidationErrors}
<p class="text-sm text-destructive">
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
</p>
{/if}
<Button onclick={save} disabled={!canSave}>
<Save />
{saving ? 'Сохранение…' : 'Применить параметры BIRD'}
</Button>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,277 @@
<script lang="ts">
import { onMount } from 'svelte';
import { defaults, superForm } from 'sveltekit-superforms';
import { zod4 } from 'sveltekit-superforms/adapters';
import type { AuthSession } from '$lib/api/types.js';
import { apiJSON } from '$lib/api/client.js';
import {
emptyRevisionSettingsForm,
revisionSettingsSchema
} from '$lib/settings/revision-settings.schema.js';
import {
fetchRevisionPruneEstimate,
pruneRevisionsNow,
type RevisionPruneEstimate
} from '$lib/settings/revision-prune-api.js';
import {
buildPayloadFromFormFields,
loadSettings,
partitionSettings,
patchSettings
} from '$lib/settings/settings-api.js';
import { REVISION_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
import { formatBytes } from '$lib/monitoring/postgres.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Save from '@lucide/svelte/icons/save';
import Trash2 from '@lucide/svelte/icons/trash-2';
let loading = $state(false);
let saving = $state(false);
let pruning = $state(false);
let loaded = $state(false);
let session = $state<AuthSession | null>(null);
let estimateLoading = $state(false);
let estimate = $state<RevisionPruneEstimate | null>(null);
const { form, errors, reset, validateForm } = superForm(
defaults(emptyRevisionSettingsForm(), zod4(revisionSettingsSchema)),
{
validators: zod4(revisionSettingsSchema),
SPA: true,
dataType: 'json'
}
);
const isOperator = $derived(session?.role === 'operator');
const hasValidationErrors = $derived(Boolean($errors.revision_retention_minutes?.length));
const parsedRetentionMinutes = $derived.by(() => {
const s = String($form.revision_retention_minutes ?? '').trim();
if (s === '' || !/^\d+$/.test(s)) return null;
const n = Number(s);
if (!Number.isInteger(n) || n < 15 || n > 43200) return null;
return n;
});
const canSave = $derived.by(() => {
if (loading || saving || hasValidationErrors || !loaded) return false;
return String($form.revision_retention_minutes ?? '').trim() !== '';
});
const canPruneNow = $derived.by(() => {
if (!isOperator || !loaded || pruning || saving || parsedRetentionMinutes === null)
return false;
return (estimate?.revision_count ?? 0) > 0;
});
async function loadSession() {
try {
session = await apiJSON<AuthSession>('/v1/auth/session');
} catch {
session = null;
}
}
async function refreshEstimate(minutes: number) {
estimateLoading = true;
try {
estimate = await fetchRevisionPruneEstimate(minutes);
} catch (e) {
estimate = null;
notifyApiError(e, 'Не удалось рассчитать оценку очистки');
} finally {
estimateLoading = false;
}
}
$effect(() => {
const minutes = parsedRetentionMinutes;
if (!loaded || minutes === null) {
estimate = null;
return;
}
const handle = setTimeout(() => {
void refreshEstimate(minutes);
}, 400);
return () => clearTimeout(handle);
});
async function load() {
loading = true;
try {
const settings = await loadSettings();
const { partitioned } = partitionSettings(settings);
reset({ data: partitioned.revision });
loaded = true;
} catch (e) {
notifyApiError(e);
} finally {
loading = false;
}
}
async function save() {
const validation = await validateForm({ update: true });
if (!validation.valid) {
notify.error('Исправьте ошибки в полях настроек');
return;
}
if (!canSave) {
notify.error('Нечего сохранять или есть ошибки в полях');
return;
}
const payload = buildPayloadFromFormFields(
REVISION_SETTING_KEYS,
$form as Record<string, string>,
$errors as Partial<Record<string, string[]>>
);
saving = true;
try {
await patchSettings(payload);
notify.success('Параметры хранения ревизий сохранены');
await load();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
async function requestPruneNow() {
const minutes = parsedRetentionMinutes;
if (minutes === null) return;
let est: RevisionPruneEstimate;
try {
est = await fetchRevisionPruneEstimate(minutes);
} catch (e) {
notifyApiError(e);
return;
}
estimate = est;
if (est.revision_count === 0) {
notify.info('Нет ревизий для удаления по выбранному retention');
return;
}
void confirm({
title: 'Очистить старые ревизии?',
description: `Будет удалено ${est.revision_count} ревизий. Ориентировочно освободится ~${formatBytes(est.bytes_estimate)}. Действие необратимо.`,
confirmLabel: 'Очистить',
destructive: true,
onConfirm: async () => {
pruning = true;
try {
const res = await pruneRevisionsNow(minutes);
notify.success(
`Удалено ревизий: ${res.deleted_revisions}, освобождено ~${formatBytes(res.bytes_estimate)}`
);
await refreshEstimate(minutes);
} catch (e) {
notifyApiError(e);
throw e;
} finally {
pruning = false;
}
}
});
}
onMount(() => {
void loadSession();
void load();
});
</script>
<Card>
<CardHeader>
<CardTitle>Хранение ревизий</CardTitle>
<CardDescription>
Автоматическая очистка старых ревизий. Последняя ревизия и раскатанные на спикерах не
удаляются.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
{#if loading && !loaded}
<p class="text-sm text-muted-foreground">Загрузка…</p>
{:else if !loaded}
<Button variant="outline" onclick={load}>Загрузить настройки</Button>
{:else}
<FormField
id="revision-retention-minutes"
label="Время жизни ревизий, мин (revision_retention_minutes)"
error={$errors.revision_retention_minutes?.[0]}
description="Допустимый диапазон: 15–43200 минут."
>
<Input
id="revision-retention-minutes"
type="number"
min="15"
max="43200"
bind:value={$form.revision_retention_minutes}
placeholder="43200"
/>
</FormField>
{#if parsedRetentionMinutes !== null}
<div class="space-y-1 rounded-md border bg-muted/30 p-4 text-sm">
<p class="font-medium">Оценка очистки по введённому retention</p>
{#if estimateLoading}
<p class="text-muted-foreground">Расчёт…</p>
{:else if estimate}
<p>
Будет удалено ревизий: <strong>{estimate.revision_count}</strong>
</p>
<p>
Освободится ориентировочно: <strong>~{formatBytes(estimate.bytes_estimate)}</strong>
</p>
{#if estimate.prefix_row_count > 0}
<p class="text-xs text-muted-foreground">
Строк префиксов в снимках: {estimate.prefix_row_count}
{#if estimate.orphan_snapshot_count > 0}
· снимков: {estimate.orphan_snapshot_count}
{/if}
</p>
{/if}
{:else}
<p class="text-muted-foreground">Оценка недоступна</p>
{/if}
<p class="pt-1 text-xs text-muted-foreground">
Учитываются те же правила, что при автоочистке: последняя ревизия и раскатанные на
спикерах не удаляются.
</p>
</div>
{/if}
{#if hasValidationErrors}
<p class="text-sm text-destructive">
Есть ошибки в полях. Исправьте их, чтобы сохранить изменения.
</p>
{/if}
<div class="flex flex-wrap gap-2">
<Button onclick={save} disabled={!canSave}>
<Save />
{saving ? 'Сохранение…' : 'Применить параметры ревизий'}
</Button>
{#if isOperator}
<Button variant="destructive" disabled={!canPruneNow} onclick={requestPruneNow}>
<Trash2 />
{pruning ? 'Очистка…' : 'Очистить сейчас'}
</Button>
{/if}
</div>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,345 @@
<script lang="ts">
import { onMount } from 'svelte';
import { defaults, superForm } from 'sveltekit-superforms';
import { zod4 } from 'sveltekit-superforms/adapters';
import type { AuthSession } from '$lib/api/types.js';
import { apiJSON } from '$lib/api/client.js';
import {
emptyRuntimeLogsSettingsForm,
runtimeLogsSettingsSchema
} from '$lib/settings/runtime-logs-settings.schema.js';
import {
buildPayloadFromFormFields,
loadSettings,
partitionSettings,
patchSettings
} from '$lib/settings/settings-api.js';
import { RUNTIME_LOGS_SETTING_KEYS } from '$lib/settings/settings-known-keys.js';
import {
fetchRuntimeLogAutoEstimate,
runRuntimeLogAutoCleanup,
type RuntimeLogAutoEstimate
} from '$lib/runtime-logs/runtime-logs-auto-api.js';
import { isRuntimeLogsUnavailable } from '$lib/runtime-logs/runtime-logs-api.js';
import { formatBytes } from '$lib/monitoring/postgres.js';
import { Button } from '@evobgp/ui/components/button/index.js';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle
} from '@evobgp/ui/components/card/index.js';
import { Input } from '@evobgp/ui/components/input/index.js';
import { Switch } from '@evobgp/ui/components/switch/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '@evobgp/ui/components/select/index.js';
import FormField from '$lib/components/patterns/form/form-field.svelte';
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
import Save from '@lucide/svelte/icons/save';
import Play from '@lucide/svelte/icons/play';
let loading = $state(false);
let saving = $state(false);
let running = $state(false);
let loaded = $state(false);
let session = $state<AuthSession | null>(null);
let estimateLoading = $state(false);
let estimate = $state<RuntimeLogAutoEstimate | null>(null);
let fsUnavailable = $state(false);
const { form, errors, reset, validateForm } = superForm(
defaults(emptyRuntimeLogsSettingsForm(), zod4(runtimeLogsSettingsSchema)),
{
validators: zod4(runtimeLogsSettingsSchema),
SPA: true,
dataType: 'json'
}
);
const isOperator = $derived(session?.role === 'operator');
const autoEnabled = $derived($form.runtime_logs_auto_enabled === 'true');
const hasValidationErrors = $derived(
Boolean(
$errors.runtime_logs_max_file_mb?.length ||
$errors.runtime_logs_auto_schedule?.length ||
$errors.runtime_logs_auto_mode?.length
)
);
const parsedMaxMb = $derived.by(() => {
const s = String($form.runtime_logs_max_file_mb ?? '').trim();
if (s === '' || !/^\d+$/.test(s)) return null;
const n = Number(s);
if (!Number.isInteger(n) || n < 1 || n > 512) return null;
return n;
});
const canSave = $derived.by(() => {
if (loading || saving || hasValidationErrors || !loaded || !isOperator) return false;
return true;
});
const canRunNow = $derived.by(() => {
if (!isOperator || !loaded || running || saving || parsedMaxMb === null || fsUnavailable)
return false;
return (estimate?.would_count ?? 0) > 0;
});
async function loadSession() {
try {
session = await apiJSON<AuthSession>('/v1/auth/session');
} catch {
session = null;
}
}
async function refreshEstimate() {
if (fsUnavailable || parsedMaxMb === null) {
estimate = null;
return;
}
estimateLoading = true;
try {
estimate = await fetchRuntimeLogAutoEstimate();
fsUnavailable = false;
} catch (e) {
estimate = null;
if (isRuntimeLogsUnavailable(e)) {
fsUnavailable = true;
return;
}
notifyApiError(e, 'Оценка автоочистки');
} finally {
estimateLoading = false;
}
}
$effect(() => {
if (!loaded || parsedMaxMb === null) {
estimate = null;
return;
}
const handle = setTimeout(() => {
void refreshEstimate();
}, 400);
return () => clearTimeout(handle);
});
async function load() {
loading = true;
try {
const settings = await loadSettings();
const { partitioned } = partitionSettings(settings);
reset({ data: partitioned.runtimeLogs });
loaded = true;
} catch (e) {
notifyApiError(e);
} finally {
loading = false;
}
}
async function save() {
const validation = await validateForm({ update: true });
if (!validation.valid) {
notify.error('Исправьте ошибки в полях настроек');
return;
}
const payload = buildPayloadFromFormFields(
RUNTIME_LOGS_SETTING_KEYS,
$form as Record<string, string>,
$errors as Partial<Record<string, string[]>>
);
if ($form.runtime_logs_auto_mode === 'truncate' || $form.runtime_logs_auto_mode === 'delete') {
payload.runtime_logs_auto_mode = $form.runtime_logs_auto_mode;
}
saving = true;
try {
await patchSettings(payload);
notify.success('Параметры автоочистки логов сохранены');
await load();
} catch (e) {
notifyApiError(e);
} finally {
saving = false;
}
}
async function requestRunNow() {
if (parsedMaxMb === null) return;
let est: RuntimeLogAutoEstimate;
try {
est = await fetchRuntimeLogAutoEstimate();
} catch (e) {
notifyApiError(e);
return;
}
estimate = est;
const count = est.would_count ?? 0;
if (count === 0) {
notify.info('Нет файлов выше порога для очистки');
return;
}
const mode = $form.runtime_logs_auto_mode === 'delete' ? 'удаление' : 'truncate';
void confirm({
title: 'Запустить автоочистку сейчас?',
description: `Будет затронуто файлов: ${count}. Режим: ${mode}. Записи появятся в Monitoring → Файловые логи → Audit.`,
confirmLabel: 'Запустить',
destructive: true,
onConfirm: async () => {
running = true;
try {
const res = await runRuntimeLogAutoCleanup(false);
notify.success(`Очищено файлов: ${res.cleaned_count ?? 0}`);
await refreshEstimate();
} catch (e) {
notifyApiError(e);
throw e;
} finally {
running = false;
}
}
});
}
onMount(() => {
void loadSession();
void load();
});
</script>
<Card>
<CardHeader>
<CardTitle>Файловые логи (runtime-logs)</CardTitle>
<CardDescription>
Автоочистка <code class="text-xs">*.log</code> на диске evobgp-all (sidecar
stack-runtime-logs). Требуется volume <code class="text-xs">EVOBGP_RUNTIME_LOGS_DIR</code>.
</CardDescription>
</CardHeader>
<CardContent class="space-y-4">
{#if loading && !loaded}
<p class="text-sm text-muted-foreground">Загрузка…</p>
{:else if !loaded}
<Button variant="outline" onclick={load}>Загрузить настройки</Button>
{:else}
<FormField
id="runtime-logs-auto-enabled"
label="Автоочистка по расписанию"
description="Scheduler в evobgp-all (UTC cron ниже)."
>
<Switch
id="runtime-logs-auto-enabled"
checked={autoEnabled}
disabled={!isOperator}
onCheckedChange={(v) => {
$form.runtime_logs_auto_enabled = v ? 'true' : 'false';
}}
/>
</FormField>
<FormField
id="runtime-logs-max-mb"
label="Порог размера файла, MiB"
error={$errors.runtime_logs_max_file_mb?.[0]}
description="Очищать файлы строго больше порога (1–512 MiB)."
>
<Input
id="runtime-logs-max-mb"
type="number"
min="1"
max="512"
bind:value={$form.runtime_logs_max_file_mb}
disabled={!isOperator}
/>
</FormField>
<FormField
id="runtime-logs-schedule"
label="Расписание (UTC cron)"
error={$errors.runtime_logs_auto_schedule?.[0]}
description="5 полей: минута час день месяц день_недели. По умолчанию каждые 6 часов."
>
<Input
id="runtime-logs-schedule"
bind:value={$form.runtime_logs_auto_schedule}
placeholder="0 */6 * * *"
disabled={!isOperator}
/>
</FormField>
<FormField id="runtime-logs-mode" label="Режим очистки">
<Select
type="single"
value={$form.runtime_logs_auto_mode || 'truncate'}
disabled={!isOperator}
onValueChange={(v) => {
if (v === 'truncate' || v === 'delete') $form.runtime_logs_auto_mode = v;
}}
>
<SelectTrigger id="runtime-logs-mode" class="w-full max-w-xs">
{$form.runtime_logs_auto_mode === 'delete'
? 'delete — удалить файл'
: 'truncate — обнулить'}
</SelectTrigger>
<SelectContent>
<SelectItem value="truncate">truncate — обнулить</SelectItem>
<SelectItem value="delete">delete — удалить файл</SelectItem>
</SelectContent>
</Select>
</FormField>
{#if fsUnavailable}
<p class="text-sm text-muted-foreground">
FS API недоступен (не evobgp-all или нет volume). Оценка и «Запустить сейчас» недоступны;
настройки сохраняются для будущего прогона scheduler.
</p>
{:else if parsedMaxMb !== null}
<div class="space-y-1 rounded-md border bg-muted/30 p-4 text-sm">
<p class="font-medium">Оценка по текущему порогу</p>
{#if estimateLoading}
<p class="text-muted-foreground">Расчёт…</p>
{:else if estimate}
<p>
Файлов к очистке: <strong>{estimate.would_count ?? 0}</strong>
</p>
{#if estimate.items?.length}
<ul class="mt-2 space-y-1 text-xs text-muted-foreground">
{#each estimate.items.filter((i) => i.would_cleanup) as item (item.filename)}
<li class="font-mono">
{item.filename} · {formatBytes(item.size_bytes)}
</li>
{/each}
</ul>
{/if}
{:else}
<p class="text-muted-foreground">Оценка недоступна</p>
{/if}
</div>
{/if}
{#if hasValidationErrors}
<p class="text-sm text-destructive">Исправьте ошибки в полях перед сохранением.</p>
{/if}
<div class="flex flex-wrap gap-2">
{#if isOperator}
<Button onclick={save} disabled={!canSave}>
<Save />
{saving ? 'Сохранение…' : 'Сохранить'}
</Button>
<Button variant="destructive" disabled={!canRunNow} onclick={requestRunNow}>
<Play />
{running ? 'Запуск…' : 'Запустить сейчас'}
</Button>
{/if}
</div>
{/if}
</CardContent>
</Card>
@@ -0,0 +1,91 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import PageShell from '$lib/components/page-shell.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs/index.js';
import TenantBirdSettingsCard from '$lib/components/tenant-settings/TenantBirdSettingsCard.svelte';
import TenantRevisionSettingsCard from '$lib/components/tenant-settings/TenantRevisionSettingsCard.svelte';
import TenantAdditionalSettingsCard from '$lib/components/tenant-settings/TenantAdditionalSettingsCard.svelte';
import TenantRuntimeLogsSettingsCard from '$lib/components/tenant-settings/TenantRuntimeLogsSettingsCard.svelte';
import SlidersHorizontal from '@lucide/svelte/icons/sliders-horizontal';
import Info from '@lucide/svelte/icons/info';
type TenantSettingsTab = 'bird' | 'revision' | 'runtime-logs' | 'additional';
function parseTenantSettingsTab(value: string | null): TenantSettingsTab {
if (value === 'revision' || value === 'runtime-logs' || value === 'additional') return value;
return 'bird';
}
let activeTab = $state<TenantSettingsTab>('bird');
let tabSyncReady = $state(false);
onMount(() => {
activeTab = parseTenantSettingsTab(page.url.searchParams.get('tab'));
tabSyncReady = true;
});
function syncTabToUrl(tab: TenantSettingsTab) {
if (!tabSyncReady) return;
const url = new URL(page.url);
if (tab === 'bird') url.searchParams.delete('tab');
else url.searchParams.set('tab', tab);
const next = `${url.pathname}${url.search}${url.hash}`;
if (next !== `${page.url.pathname}${page.url.search}${page.url.hash}`) {
void goto(next, { replaceState: true, keepFocus: true, noScroll: true });
}
}
$effect(() => {
if (!tabSyncReady) return;
syncTabToUrl(activeTab);
});
</script>
<PageShell>
<PageHeader
title="Параметры tenant"
description="Параметры control plane для текущего tenant (API /v1/settings). Токен и тема интерфейса — в разделе «Настройки»."
icon={SlidersHorizontal}
iconClass="bg-chart-5/15 text-chart-5"
/>
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>Operator-only</AlertTitle>
<AlertDescription>
Изменение значений через <code class="text-xs">PATCH /v1/settings</code> требует роли operator.
При отсутствии прав API вернёт 403.
</AlertDescription>
</Alert>
<Tabs bind:value={activeTab}>
<div class="[scrollbar-gutter:stable] overflow-x-auto pb-1">
<TabsList class="inline-flex min-w-max">
<TabsTrigger value="bird">BIRD</TabsTrigger>
<TabsTrigger value="revision">Ревизии</TabsTrigger>
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
<TabsTrigger value="additional">Дополнительно</TabsTrigger>
</TabsList>
</div>
<TabsContent value="bird" class="mt-4">
<TenantBirdSettingsCard />
</TabsContent>
<TabsContent value="revision" class="mt-4">
<TenantRevisionSettingsCard />
</TabsContent>
<TabsContent value="runtime-logs" class="mt-4">
<TenantRuntimeLogsSettingsCard />
</TabsContent>
<TabsContent value="additional" class="mt-4">
<TenantAdditionalSettingsCard />
</TabsContent>
</Tabs>
</PageShell>
+21
View File
@@ -0,0 +1,21 @@
/**
* Общая разметка для модалок с прокручиваемым контентом (превью BIRD, вывод birdc, логи).
* DialogContent по умолчанию grid + p-4; здесь переопределяем на flex-колонку без внешних отступов.
*/
export const dialogContentDocument =
'!flex w-[min(100vw-2rem,56rem)] max-h-[min(92vh,880px)] flex-col gap-0 overflow-hidden !p-0 sm:max-w-4xl';
export const dialogHeaderDocument =
'shrink-0 space-y-1.5 border-b border-border/70 px-6 pt-5 pb-3 pr-14 text-left';
export const dialogBodyDocument =
'flex min-h-0 min-w-0 flex-1 flex-col gap-3 overflow-hidden px-6 py-4';
/** Компактные модалки (детали задачи, формы): единая шапка и тело */
export const dialogContentPanel =
'!flex max-h-[min(90vh,40rem)] w-full max-w-lg flex-col gap-0 overflow-hidden !p-0 sm:max-w-lg';
export const dialogHeaderPanel =
'shrink-0 space-y-1.5 border-b border-border/70 px-6 pt-5 pb-3 pr-14 text-left';
export const dialogBodyPanel = 'min-h-0 flex-1 overflow-y-auto px-6 py-4';
+1
View File
@@ -0,0 +1 @@
// place files you want to import through the `$lib` alias in this folder.
@@ -0,0 +1,68 @@
import { apiJSON, apiMutate } from '$lib/api/client.js';
export type MaintenancePolicy = {
id: string;
name: string;
table_name: string;
condition: string;
retention_period_sec?: number;
max_rows?: number;
vacuum_strategy: string;
schedule: string;
enabled: boolean;
dry_run_enabled: boolean;
last_run_at?: string;
last_status?: string;
last_error?: string;
created_at?: string;
updated_at?: string;
};
export type MaintenancePolicyHints = {
table_name: string;
n_dead_tup: number;
bloat_ratio?: number;
last_autovacuum?: string;
recommend_vacuum: boolean;
detail?: string;
};
export type MaintenancePoliciesResponse = {
items: MaintenancePolicy[];
next_cursor?: string;
has_more?: boolean;
};
export async function listMaintenancePolicies(limit = 100): Promise<MaintenancePolicy[]> {
const r = await apiJSON<MaintenancePoliciesResponse>(`/v1/maintenance/policies?limit=${limit}`);
return r.items ?? [];
}
export async function createMaintenancePolicy(
body: Record<string, unknown>
): Promise<MaintenancePolicy> {
return apiMutate<MaintenancePolicy>('/v1/maintenance/policies', 'POST', body);
}
export async function updateMaintenancePolicy(
id: string,
body: Record<string, unknown>
): Promise<MaintenancePolicy> {
return apiMutate<MaintenancePolicy>(`/v1/maintenance/policies/${id}`, 'PATCH', body);
}
export async function deleteMaintenancePolicy(id: string): Promise<void> {
await apiMutate(`/v1/maintenance/policies/${id}`, 'DELETE', undefined, { idempotent: false });
}
export async function runMaintenancePolicy(
id: string,
dryRun: boolean
): Promise<{ job_id: string }> {
const path = dryRun ? '/v1/maintenance/dry-run' : '/v1/maintenance/run';
return apiMutate<{ job_id: string; status: string }>(path, 'POST', { policy_id: id });
}
export async function fetchPolicyHints(id: string): Promise<MaintenancePolicyHints> {
return apiJSON<MaintenancePolicyHints>(`/v1/maintenance/policies/${id}/hints`);
}
@@ -0,0 +1,148 @@
import type { MaintenancePolicy } from '$lib/maintenance/policy-api.js';
import type { MaintenancePolicyForm } from '$lib/maintenance/policy.schema.js';
const DAY_SEC = 86_400;
/** Рекомендуемый шаблон политики (только UI; в БД не seed'ится). */
export type MaintenancePolicyPreset = {
id: string;
label: string;
description: string;
/** Подсказка: таблица должна быть видна в pg_stat (не блокирует создание). */
tableHint?: string;
form: MaintenancePolicyForm;
};
/** Базовые пресеты EvoBGP — оператор выбирает, какие создать. */
export const maintenancePolicyPresets: MaintenancePolicyPreset[] = [
{
id: 'job_audit_retention',
label: 'Job audit — retention 90d',
description:
'Удаляет завершённые записи job_audit старше 90 дней; VACUUM ANALYZE после очистки. Расписание 03:00 UTC.',
tableHint: 'job_audit',
form: {
name: 'Job audit retention (90d)',
table_name: 'job_audit',
condition: "status IN ('succeeded', 'failed', 'cancelled')",
retention_period_sec: String(90 * DAY_SEC),
max_rows: '10000',
vacuum_strategy: 'vacuum_analyze',
schedule: '0 3 * * *',
enabled: true,
dry_run_enabled: true
}
},
{
id: 'runtime_log_cleanup_audit_retention',
label: 'Runtime log cleanup audit — 90d',
description:
'Удаляет записи runtime_log_cleanup_audit старше 90 дней (ручная и автоочистка FS).',
tableHint: 'runtime_log_cleanup_audit',
form: {
name: 'Runtime log cleanup audit (90d)',
table_name: 'runtime_log_cleanup_audit',
condition: 'true',
retention_period_sec: String(90 * DAY_SEC),
max_rows: '5000',
vacuum_strategy: 'none',
schedule: '0 4 * * *',
enabled: true,
dry_run_enabled: true
}
},
{
id: 'postgres_maintenance_audit_retention',
label: 'Maintenance audit — 30d',
description: 'Очищает postgres_maintenance_audit старше 30 дней без vacuum.',
tableHint: 'postgres_maintenance_audit',
form: {
name: 'Postgres maintenance audit (30d)',
table_name: 'postgres_maintenance_audit',
condition: 'true',
retention_period_sec: String(30 * DAY_SEC),
max_rows: '5000',
vacuum_strategy: 'none',
schedule: '0 4 * * *',
enabled: true,
dry_run_enabled: true
}
},
{
id: 'job_audit_vacuum_weekly',
label: 'Job audit — VACUUM weekly',
description: 'Только VACUUM ANALYZE job_audit по воскресеньям, без удаления строк.',
tableHint: 'job_audit',
form: {
name: 'Job audit vacuum (weekly)',
table_name: 'job_audit',
condition: 'true',
retention_period_sec: '',
max_rows: '',
vacuum_strategy: 'vacuum_analyze',
schedule: '0 2 * * 0',
enabled: true,
dry_run_enabled: false
}
},
{
id: 'postgres_monitor_snapshot',
label: 'PG monitor snapshots — 14d',
description:
'Удаляет снимки postgres_monitor_snapshot старше 14 дней (фильтр по collected_at в condition).',
tableHint: 'postgres_monitor_snapshot',
form: {
name: 'Postgres monitor snapshots (14d)',
table_name: 'postgres_monitor_snapshot',
condition: "collected_at < NOW() - INTERVAL '14 days'",
retention_period_sec: '',
max_rows: '10000',
vacuum_strategy: 'none',
schedule: '0 5 * * *',
enabled: true,
dry_run_enabled: true
}
},
{
id: 'config_revision_retention',
label: 'Config revisions — 180d',
description:
'Долгое хранение старых config_revision (180d). Перед включением проверьте revision_retention в настройках.',
tableHint: 'config_revision',
form: {
name: 'Config revision retention (180d)',
table_name: 'config_revision',
condition: 'true',
retention_period_sec: String(180 * DAY_SEC),
max_rows: '5000',
vacuum_strategy: 'vacuum',
schedule: '0 6 * * 0',
enabled: false,
dry_run_enabled: true
}
}
];
export function presetForm(preset: MaintenancePolicyPreset): MaintenancePolicyForm {
return structuredClone(preset.form);
}
/** Политика с тем же именем и таблицей считается уже созданной из пресета. */
export function isPresetAlreadyApplied(
preset: MaintenancePolicyPreset,
policies: MaintenancePolicy[]
): boolean {
return policies.some(
(p) => p.name === preset.form.name.trim() && p.table_name === preset.form.table_name.trim()
);
}
export function filterAvailablePresets(
policies: MaintenancePolicy[],
selected: Iterable<string>
): MaintenancePolicyPreset[] {
const ids = new Set(selected);
return maintenancePolicyPresets.filter(
(p) => ids.has(p.id) && !isPresetAlreadyApplied(p, policies)
);
}
@@ -0,0 +1,181 @@
/** Режимы расписания (5-field cron, UTC) — совместимы с robfig/cron в scheduler. */
export type ScheduleMode = 'minutes' | 'hours' | 'daily' | 'weekly' | 'custom';
export type ScheduleEditor = {
mode: ScheduleMode;
/** Интервал в минутах (режим minutes). */
intervalMinutes: string;
/** Интервал в часах (режим hours). */
intervalHours: string;
minute: string;
hour: string;
/** 0 = воскресенье … 6 = суббота */
weekday: string;
customCron: string;
};
export const scheduleModeOptions: { value: ScheduleMode; label: string }[] = [
{ value: 'minutes', label: 'Каждые N минут' },
{ value: 'hours', label: 'Каждые N часов' },
{ value: 'daily', label: 'Ежедневно в указанное время' },
{ value: 'weekly', label: 'Еженедельно в указанный день' },
{ value: 'custom', label: 'Cron вручную (расширенный)' }
];
export const weekdayOptions = [
{ value: '0', label: 'Воскресенье' },
{ value: '1', label: 'Понедельник' },
{ value: '2', label: 'Вторник' },
{ value: '3', label: 'Среда' },
{ value: '4', label: 'Четверг' },
{ value: '5', label: 'Пятница' },
{ value: '6', label: 'Суббота' }
] as const;
export function defaultScheduleEditor(cron = '0 3 * * *'): ScheduleEditor {
return cronToEditor(cron);
}
function splitCron(cron: string): [string, string, string, string, string] | null {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return null;
return [parts[0], parts[1], parts[2], parts[3], parts[4]];
}
function clampInt(raw: string, min: number, max: number): number {
const n = Math.floor(Number(String(raw).trim()));
if (!Number.isFinite(n)) return min;
return Math.min(max, Math.max(min, n));
}
function pad2(n: number): string {
return String(n).padStart(2, '0');
}
/** Пытается разобрать cron в редактор; неизвестные выражения → custom. */
export function cronToEditor(cron: string): ScheduleEditor {
const base: ScheduleEditor = {
mode: 'custom',
intervalMinutes: '30',
intervalHours: '2',
minute: '0',
hour: '3',
weekday: '0',
customCron: cron.trim() || '0 3 * * *'
};
const parts = splitCron(cron);
if (!parts) return base;
const [min, hour, dom, month, dow] = parts;
const minEvery = min.match(/^\*\/(\d+)$/);
if (minEvery && hour === '*' && dom === '*' && month === '*' && dow === '*') {
return { ...base, mode: 'minutes', intervalMinutes: minEvery[1], customCron: cron.trim() };
}
const hourEvery = hour.match(/^\*\/(\d+)$/);
if (hourEvery && !min.includes('*') && dom === '*' && month === '*' && dow === '*') {
return {
...base,
mode: 'hours',
intervalHours: hourEvery[1],
minute: min,
customCron: cron.trim()
};
}
if (!min.includes('*') && !hour.includes('*') && dom === '*' && month === '*' && dow === '*') {
return { ...base, mode: 'daily', minute: min, hour, customCron: cron.trim() };
}
if (!min.includes('*') && !hour.includes('*') && dom === '*' && month === '*' && dow !== '*') {
return { ...base, mode: 'weekly', minute: min, hour, weekday: dow, customCron: cron.trim() };
}
return base;
}
/** Собирает 5-field cron из редактора. */
export function editorToCron(editor: ScheduleEditor): string {
switch (editor.mode) {
case 'minutes': {
const n = clampInt(editor.intervalMinutes, 1, 59);
return `*/${n} * * * *`;
}
case 'hours': {
const n = clampInt(editor.intervalHours, 1, 23);
const m = clampInt(editor.minute, 0, 59);
return `${m} */${n} * * *`;
}
case 'daily': {
const m = clampInt(editor.minute, 0, 59);
const h = clampInt(editor.hour, 0, 23);
return `${m} ${h} * * *`;
}
case 'weekly': {
const m = clampInt(editor.minute, 0, 59);
const h = clampInt(editor.hour, 0, 23);
const d = clampInt(editor.weekday, 0, 6);
return `${m} ${h} * * ${d}`;
}
case 'custom':
return editor.customCron.trim() || '0 3 * * *';
}
}
/** Человекочитаемое описание расписания для таблицы и подсказок. */
export function describeCron(cron: string): string {
const ed = cronToEditor(cron);
switch (ed.mode) {
case 'minutes': {
const n = clampInt(ed.intervalMinutes, 1, 59);
return `Каждые ${n} ${minutesLabel(n)} (UTC)`;
}
case 'hours': {
const n = clampInt(ed.intervalHours, 1, 23);
const m = clampInt(ed.minute, 0, 59);
return `Каждые ${n} ${hoursLabel(n)}, в :${pad2(m)} (UTC)`;
}
case 'daily': {
const h = clampInt(ed.hour, 0, 23);
const m = clampInt(ed.minute, 0, 59);
return `Ежедневно в ${pad2(h)}:${pad2(m)} UTC`;
}
case 'weekly': {
const wd =
weekdayOptions.find((w) => w.value === String(clampInt(ed.weekday, 0, 6)))?.label ??
ed.weekday;
const h = clampInt(ed.hour, 0, 23);
const m = clampInt(ed.minute, 0, 59);
return `Каждую ${wd.toLowerCase()} в ${pad2(h)}:${pad2(m)} UTC`;
}
case 'custom':
return `Cron: ${cron.trim()}`;
}
}
/** Синхронизирует form.schedule из редактора. */
export function applyScheduleEditor(editor: ScheduleEditor): {
editor: ScheduleEditor;
cron: string;
} {
if (editor.mode === 'custom') {
const cron = editor.customCron.trim() || '0 3 * * *';
return { editor: { ...editor, customCron: cron }, cron };
}
const cron = editorToCron(editor);
return { editor: { ...editor, customCron: cron }, cron };
}
function minutesLabel(n: number): string {
if (n % 10 === 1 && n % 100 !== 11) return 'минуту';
if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return 'минуты';
return 'минут';
}
function hoursLabel(n: number): string {
if (n % 10 === 1 && n % 100 !== 11) return 'час';
if (n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 10 || n % 100 >= 20)) return 'часа';
return 'часов';
}
@@ -0,0 +1,58 @@
import { z } from 'zod';
export const vacuumStrategies = ['none', 'vacuum', 'analyze', 'vacuum_analyze', 'reindex'] as const;
export type VacuumStrategy = (typeof vacuumStrategies)[number];
export const maintenancePolicySchema = z.object({
name: z.string().trim().min(1, 'Укажите название'),
table_name: z.string().trim().min(1, 'Укажите таблицу'),
condition: z
.string()
.trim()
.min(1, 'Укажите условие')
.refine((v) => !/[;]|--|\/\*/.test(v), 'Недопустимые символы в condition'),
retention_period_sec: z.string().optional(),
max_rows: z.string().optional(),
vacuum_strategy: z.enum(vacuumStrategies),
schedule: z.string().trim().min(1, 'Укажите cron (UTC)'),
enabled: z.boolean(),
dry_run_enabled: z.boolean()
});
export type MaintenancePolicyForm = z.infer<typeof maintenancePolicySchema>;
export function emptyMaintenancePolicyForm(): MaintenancePolicyForm {
return {
name: '',
table_name: '',
condition: 'true',
retention_period_sec: '',
max_rows: '10000',
vacuum_strategy: 'none',
schedule: '0 3 * * *',
enabled: true,
dry_run_enabled: true
};
}
export function parseOptionalInt(raw: string | undefined): number | undefined {
const v = String(raw ?? '').trim();
if (!v) return undefined;
const n = Number(v);
return Number.isFinite(n) && n > 0 ? Math.floor(n) : undefined;
}
export function formToPayload(form: MaintenancePolicyForm) {
return {
name: form.name.trim(),
table_name: form.table_name.trim(),
condition: form.condition.trim() || 'true',
retention_period_sec: parseOptionalInt(form.retention_period_sec),
max_rows: parseOptionalInt(form.max_rows),
vacuum_strategy: form.vacuum_strategy,
schedule: form.schedule.trim(),
enabled: form.enabled,
dry_run_enabled: form.dry_run_enabled
};
}
+43
View File
@@ -0,0 +1,43 @@
import type { ModuleRow } from '$lib/api/types.js';
/** Форматирование ISO-даты для таблиц модулей и расписания. */
export function formatDateTime(value: string | null | undefined): string {
if (typeof value !== 'string' || value.trim().length === 0) return '—';
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return '—';
return parsed.toLocaleString('ru-RU');
}
/** Подпись интервала refresh: секунды, cron или комбинация. */
export function moduleIntervalLabel(moduleRow: ModuleRow): string {
const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : '';
const raw = moduleRow.refresh_interval_sec as unknown;
const interval =
typeof raw === 'number'
? raw
: typeof raw === 'string' && raw.trim().length > 0
? Number(raw)
: null;
const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : '';
if (cron && intervalLabel) return `${intervalLabel} (${cron})`;
if (cron) return cron;
if (intervalLabel) return intervalLabel;
return '—';
}
export function moduleTypeBadgeVariant(
type: string
): 'default' | 'secondary' | 'outline' | 'destructive' {
switch (type) {
case 'AS_PREFIXES':
return 'default';
case 'CDN_CIDRS':
return 'secondary';
case 'DOMAINS':
return 'outline';
case 'IP_RANGES':
return 'outline';
default:
return 'outline';
}
}
+118
View File
@@ -0,0 +1,118 @@
/** Types and helpers for PostgreSQL monitoring API. */
export const POSTGRES_POLL_MS = 20_000;
export const POSTGRES_SLOW_POLL_MS = 60_000;
export type PostgresOverview = {
collected_at: string;
connections: {
active: number;
idle: number;
total: number;
max_connections: number;
};
database: {
backends: number;
xact_commit: number;
xact_rollback: number;
deadlocks: number;
blks_hit: number;
blks_read: number;
cache_hit_pct: number;
};
database_size_bytes: number;
memory_settings: {
shared_buffers: string;
work_mem: string;
effective_cache_size: string;
};
replication: Array<{
client_addr?: string;
state: string;
sync_state?: string;
lag_ms?: number;
}>;
pg_stat_statements_enabled: boolean;
};
export type PostgresQueryRow = {
queryid?: number;
query: string;
calls: number;
total_exec_ms: number;
mean_exec_ms: number;
rows: number;
};
export type PostgresQueriesResponse = {
collected_at: string;
source: string;
items: PostgresQueryRow[];
statements_available?: boolean;
statements_hint?: string;
};
export type PostgresLockRow = {
locktype: string;
mode: string;
granted: boolean;
pid: number;
usename?: string;
state?: string;
query?: string;
blocked: boolean;
};
export type PostgresTableRow = {
relname: string;
total_bytes: number;
idx_scan: number;
seq_scan: number;
n_dead_tup: number;
bloat_ratio?: number;
last_autovacuum?: string;
};
export type PostgresRecommendation = {
severity: string;
code: string;
title: string;
detail: string;
refs?: string[];
};
export type PostgresRecommendationsResponse = {
collected_at: string;
items: PostgresRecommendation[];
};
export type PostgresMaintLog = {
id: string;
kind: string;
target_table?: string;
dry_run: boolean;
status: string;
error?: string;
created_at: string;
};
export type CorrelationResponse = {
window_minutes: number;
points: Array<{
timestamp: string;
pipeline_refresh_p99_ms?: number;
cache_hit_pct?: number;
}>;
};
export function formatBytes(n: number): string {
if (n >= 1 << 30) return `${(n / (1 << 30)).toFixed(1)} GiB`;
if (n >= 1 << 20) return `${(n / (1 << 20)).toFixed(1)} MiB`;
if (n >= 1 << 10) return `${(n / (1 << 10)).toFixed(1)} KiB`;
return `${n} B`;
}
export function connUsagePct(ov: PostgresOverview | null): number {
if (!ov?.connections.max_connections) return 0;
return Math.min(100, (ov.connections.total / ov.connections.max_connections) * 100);
}
+169
View File
@@ -0,0 +1,169 @@
/** Pure-helpers для страницы /monitoring. */
export type OverallStatus = 'ok' | 'warn' | 'error' | 'unknown';
export type JobsKpi = { running: number; failed: number; total: number };
export type ReadyStatus = { status: string; checks?: Record<string, unknown> };
export type HealthStatus = { ok: boolean; status?: string; error?: string };
export type CheckBadge = {
label: string;
variant: 'default' | 'secondary' | 'destructive' | 'outline';
class?: string;
hint?: string;
};
export function toErrorMessage(error: unknown): string {
if (error instanceof Error && error.message.trim() !== '') return error.message;
return 'Ошибка запроса';
}
export function summarizeJobsStatuses(items: Array<{ status?: string }>): JobsKpi {
let running = 0;
let failed = 0;
for (const row of items) {
const status = String(row.status ?? '').toLowerCase();
if (status === 'queued' || status === 'running' || status === 'cancel_requested') {
running += 1;
}
if (status === 'failed' || status === 'error' || status === 'canceled') {
failed += 1;
}
}
return { running, failed, total: items.length };
}
export function deriveOverallStatus(input: {
health: HealthStatus | null;
ready: ReadyStatus | null;
jobs: JobsKpi | null;
birdConfigured: boolean;
birdHealthy: boolean | null;
}): OverallStatus {
const { health, ready, jobs, birdConfigured, birdHealthy } = input;
if (health === null && ready === null && jobs === null) return 'unknown';
if (!health?.ok) return 'error';
if (ready !== null && ready.status !== 'ready') return 'error';
if (jobs !== null && jobs.failed > 0) return 'warn';
if (birdConfigured && birdHealthy === false) return 'warn';
return 'ok';
}
export function overallStatusLabel(status: OverallStatus): string {
switch (status) {
case 'ok':
return 'В норме';
case 'warn':
return 'Внимание';
case 'error':
return 'Ошибка';
default:
return 'Нет данных';
}
}
export function overallStatusHint(
status: OverallStatus,
input: {
healthOk: boolean | undefined;
jobsFailed: number;
}
): string {
if (status === 'unknown') return 'Нет данных. Запустите обновление.';
if (status === 'error') {
if (!input.healthOk) return 'Проверьте доступность API и логи сервиса.';
return 'Readiness не в норме: проверьте postgres/store/jobs.';
}
if (status === 'warn') {
if (input.jobsFailed > 0) return 'Есть ошибки в задачах: откройте операции и последние jobs.';
return 'Проверьте BGP-сессии и вывод birdc.';
}
return 'Критичных отклонений не обнаружено.';
}
export function overallBadgeVariant(status: OverallStatus): CheckBadge['variant'] {
if (status === 'ok') return 'default';
if (status === 'warn') return 'secondary';
if (status === 'error') return 'destructive';
return 'outline';
}
export function overallBadgeClass(status: OverallStatus): string | undefined {
if (status === 'ok') return 'border-success/30 bg-success/15 text-success';
if (status === 'warn') return 'border-warning/30 bg-warning/15 text-warning';
return undefined;
}
/** Нормализация значений ready.checks и health/readiness в badge. */
export function checkStatusBadge(value: unknown): CheckBadge {
const raw = String(value ?? '').trim();
const lower = raw.toLowerCase();
if (lower === 'ok' || lower === 'ready' || lower === 'true' || lower === 'up') {
return {
label: 'OK',
variant: 'default',
class: 'border-success/30 bg-success/15 text-success'
};
}
if (lower === 'memory') {
return {
label: 'In-memory',
variant: 'secondary',
hint: 'Очередь задач в памяти процесса, не shared между воркерами.'
};
}
if (
lower === 'failed' ||
lower === 'false' ||
lower === 'error' ||
lower === 'down' ||
lower === 'unavailable'
) {
return { label: 'Ошибка', variant: 'destructive' };
}
if (raw === '') {
return { label: '—', variant: 'outline' };
}
return { label: raw, variant: 'outline' };
}
/** Человекочитаемое имя проверки readiness. */
export function checkDisplayName(key: string): string {
switch (key) {
case 'postgres':
return 'PostgreSQL';
case 'store':
return 'Хранилище';
case 'jobs':
return 'Очередь задач';
default:
return key;
}
}
export function livenessBadge(health: HealthStatus | null): CheckBadge {
if (health === null) return { label: '—', variant: 'outline' };
if (health.ok) {
return {
label: 'В норме',
variant: 'default',
class: 'border-success/30 bg-success/15 text-success'
};
}
return { label: 'Недоступен', variant: 'destructive' };
}
export function readinessBadge(ready: ReadyStatus | null): CheckBadge {
if (ready === null) return { label: '—', variant: 'outline' };
if (ready.status === 'ready') {
return {
label: 'Готов',
variant: 'default',
class: 'border-success/30 bg-success/15 text-success'
};
}
return { label: ready.status || 'Не готов', variant: 'destructive' };
}
+295
View File
@@ -0,0 +1,295 @@
import type { BirdStatus, PeerRow, SpeakerRow } from '$lib/api/types.js';
export type NetworkOverallStatus = 'ok' | 'warn' | 'error';
export type NetworkMetrics = {
peersTotal: number;
peersEnabled: number;
peersEstablished: number;
peersMismatch: number;
speakersTotal: number;
speakersOnline: number;
speakersRemote: number;
speakersRemoteOnline: number;
speakersDrift: number;
pollErrors: number;
hasLiveData: boolean;
};
export type SpeakerStatusBadge = {
label: string;
variant: 'default' | 'secondary' | 'destructive' | 'outline';
};
export type NetworkIssue = {
id: string;
message: string;
severity: 'warn' | 'error';
};
function isRemoteSpeaker(s: SpeakerRow): boolean {
const role = (s.role ?? '').toLowerCase();
return role !== 'master' && Boolean(s.agent_domain?.trim());
}
export function speakerHasDrift(s: SpeakerRow): boolean {
const pub = s.published_revision_id?.trim();
if (!pub) return false;
return (s.last_applied_revision_id ?? '') !== pub;
}
export function speakerIsOnline(s: SpeakerRow): boolean {
if (s.live) {
return s.live.agent_ok === true && s.live.bgp_poll_ok !== false;
}
if (s.sync_status === 'synced') return true;
if (s.sync_status === 'error' || s.last_dispatch_error) return false;
return s.dispatch_status === 'ok';
}
export function speakerDisplayStatus(s: SpeakerRow): SpeakerStatusBadge {
if (s.live) {
if (s.live.agent_ok === true && s.live.bgp_poll_ok !== false) {
return { label: 'Online', variant: 'default' };
}
if (s.live.bgp_poll_error || s.live.agent_error) {
return { label: 'Offline', variant: 'destructive' };
}
return { label: 'Degraded', variant: 'secondary' };
}
if (s.sync_status === 'synced') return { label: 'Connected', variant: 'default' };
if (s.sync_status === 'error' || s.last_dispatch_error) {
return { label: 'Offline', variant: 'destructive' };
}
if (s.dispatch_status === 'ok') return { label: 'Synced', variant: 'outline' };
return { label: 'Unknown', variant: 'outline' };
}
export function speakerLabel(s: SpeakerRow): string {
return s.live?.label ?? s.agent_domain ?? s.endpoint ?? s.id;
}
export function speakerBgpText(s: SpeakerRow): string {
if (s.live) {
return `${s.live.bgp_established ?? 0}/${s.live.bgp_sessions_total ?? 0}`;
}
return '—';
}
export function peersForSpeaker(peers: PeerRow[], speakerId: string): PeerRow[] {
return peers.filter(
(p) =>
p.bgp_speaker_id === speakerId || p.bgp_speaker_id === null || p.bgp_speaker_id === undefined
);
}
export function aggregateNetworkMetrics(
peers: PeerRow[],
speakers: SpeakerRow[],
bird?: BirdStatus | null
): NetworkMetrics {
const enabledPeers = peers.filter((p) => p.enabled !== false);
const established = enabledPeers.filter((p) => p.session_state === 'Established').length;
const mismatch = peers.filter((p) => p.session_mismatch).length;
const remoteSpeakers = speakers.filter(isRemoteSpeaker);
const online = speakers.filter(speakerIsOnline).length;
const remoteOnline = remoteSpeakers.filter(speakerIsOnline).length;
const drift = speakers.filter(speakerHasDrift).length;
const pollErrors = speakers.filter(
(s) => s.live?.bgp_poll_error || (s.live && s.live.agent_ok === false)
).length;
const hasLiveData =
speakers.some((s) => s.live != null) || peers.some((p) => p.session_on_speakers);
void bird;
return {
peersTotal: peers.length,
peersEnabled: enabledPeers.length,
peersEstablished: established,
peersMismatch: mismatch,
speakersTotal: speakers.length,
speakersOnline: online,
speakersRemote: remoteSpeakers.length,
speakersRemoteOnline: remoteOnline,
speakersDrift: drift,
pollErrors,
hasLiveData
};
}
export function deriveNetworkOverallStatus(metrics: NetworkMetrics): NetworkOverallStatus {
if (!metrics.hasLiveData && metrics.speakersTotal === 0 && metrics.peersTotal === 0) {
return 'ok';
}
const enabledNotEstablished =
metrics.peersEnabled > 0 ? metrics.peersEnabled - metrics.peersEstablished : 0;
const majorityPeersDown =
metrics.peersEnabled > 0 && enabledNotEstablished / metrics.peersEnabled > 0.5;
if ((metrics.speakersRemote > 0 && metrics.speakersRemoteOnline === 0) || majorityPeersDown) {
return 'error';
}
if (
metrics.pollErrors > 0 ||
metrics.peersMismatch > 0 ||
metrics.speakersDrift > 0 ||
metrics.speakersOnline < metrics.speakersTotal
) {
return 'warn';
}
return 'ok';
}
export function networkOverallStatusLabel(status: NetworkOverallStatus): string {
switch (status) {
case 'ok':
return 'В норме';
case 'warn':
return 'Требует внимания';
case 'error':
return 'Проблема';
}
}
export function networkOverallStatusHint(
status: NetworkOverallStatus,
metrics: NetworkMetrics
): string {
switch (status) {
case 'ok':
return metrics.hasLiveData
? `${metrics.peersEstablished} Established, ${metrics.speakersOnline}/${metrics.speakersTotal} спикеров online`
: 'Сеть настроена; обновите для live-статуса';
case 'warn':
return 'Есть drift, mismatch или недоступные ноды — проверьте детали';
case 'error':
return 'Критичная деградация BGP или все remote-ноды недоступны';
}
}
export function collectNetworkIssues(
peers: PeerRow[],
speakers: SpeakerRow[],
limit = 3
): NetworkIssue[] {
const issues: NetworkIssue[] = [];
for (const s of speakers) {
if (!speakerIsOnline(s)) {
issues.push({
id: `speaker-offline-${s.id}`,
message: `Нода offline: ${speakerLabel(s)}`,
severity: 'error'
});
} else if (speakerHasDrift(s)) {
issues.push({
id: `speaker-drift-${s.id}`,
message: `Drift ревизии: ${speakerLabel(s)}`,
severity: 'warn'
});
} else if (s.live?.bgp_poll_error) {
issues.push({
id: `speaker-poll-${s.id}`,
message: `Ошибка BGP-опроса: ${speakerLabel(s)}`,
severity: 'warn'
});
} else if (s.last_dispatch_error) {
const err = formatSpeakerError(s.last_dispatch_error);
issues.push({
id: `speaker-dispatch-${s.id}`,
message: `Dispatch: ${speakerLabel(s)}${err ? `${err.detail.slice(0, 80)}` : ''}`,
severity: 'warn'
});
}
}
for (const p of peers) {
if (p.session_mismatch) {
const name = p.name?.trim() || p.neighbor;
issues.push({
id: `peer-mismatch-${p.id}`,
message: `Mismatch сессии: ${name}`,
severity: 'warn'
});
}
}
return issues.slice(0, limit);
}
export const NETWORK_AUTO_REFRESH_KEY = 'evobgp.network.autoRefresh';
export const NETWORK_AUTO_REFRESH_MS = 15_000;
export function readNetworkAutoRefresh(): boolean {
if (typeof localStorage === 'undefined') return false;
return localStorage.getItem(NETWORK_AUTO_REFRESH_KEY) === '1';
}
export function writeNetworkAutoRefresh(enabled: boolean): void {
if (typeof localStorage === 'undefined') return;
localStorage.setItem(NETWORK_AUTO_REFRESH_KEY, enabled ? '1' : '0');
}
export type FormattedSpeakerError = {
title: string;
detail: string;
};
/** Humanize stored dispatch/agent errors (avoid raw JSON in UI). */
export function formatSpeakerError(raw: string | null | undefined): FormattedSpeakerError | null {
if (!raw?.trim()) return null;
const text = raw.trim();
const jsonMatch = text.match(/\{[\s\S]*\}/);
if (jsonMatch) {
try {
const obj = JSON.parse(jsonMatch[0]) as {
detail?: string;
title?: string;
status?: number;
};
const detail = String(obj.detail ?? text);
if (/403/.test(detail) && /bundle/i.test(detail)) {
return {
title: 'Dispatch: доступ к бандлу',
detail:
'Нода не смогла скачать бандл с CP (403). Проверьте node API-ключ (роль node) и EVOBGP_NODE_TOKEN на реплике — см. docs/access.md.'
};
}
const httpPrefix = text.match(/^HTTP \d+:\s*/)?.[0] ?? '';
return {
title: obj.title && obj.title !== 'Bad Gateway' ? obj.title : 'Ошибка dispatch',
detail: httpPrefix ? `${httpPrefix.trim()} ${detail}`.trim() : detail
};
} catch {
/* fall through */
}
}
if (/^HTTP \d+:/.test(text)) {
return { title: 'Ошибка HTTP', detail: text };
}
return { title: 'Ошибка', detail: text };
}
export function speakerDispatchError(s: SpeakerRow): FormattedSpeakerError | null {
const liveRev = s.live?.agent_last_applied_revision_id?.trim();
const pub = s.published_revision_id?.trim();
// CP meta can keep a stale dispatch error after a later successful agent sync.
if (s.live?.agent_ok && liveRev && pub && liveRev === pub) {
return null;
}
return formatSpeakerError(s.last_dispatch_error);
}
export function speakerLiveAgentError(s: SpeakerRow): FormattedSpeakerError | null {
return formatSpeakerError(s.live?.agent_error);
}
export function speakerLiveBgpError(s: SpeakerRow): FormattedSpeakerError | null {
return formatSpeakerError(s.live?.bgp_poll_error);
}
@@ -0,0 +1,43 @@
import type { JobRow } from '$lib/api/types.js';
function metaString(meta: Record<string, unknown> | undefined, key: string): string | null {
if (!meta || typeof meta !== 'object') return null;
const v = meta[key];
return typeof v === 'string' && v.trim().length > 0 ? v : null;
}
/** Заголовок задачи для UI (русский), без технического `kind`. */
export function jobKindTitle(job: JobRow, moduleNameById?: ReadonlyMap<string, string>): string {
switch (job.kind) {
case 'module_refresh': {
const mid = metaString(job.meta as Record<string, unknown> | undefined, 'module_id');
const name = mid ? moduleNameById?.get(mid) : undefined;
if (name) return `Обновление модуля «${name}»`;
return 'Обновление модуля';
}
case 'deploy_apply':
return 'Применение конфигурации';
case 'peer_reconcile':
return 'Обновление BGP пиров';
case 'revision_rollback':
return 'Откат ревизии';
case 'bird_reload':
return 'Перезагрузка BIRD';
case 'maintenance_policy_run':
return 'Обслуживание PostgreSQL (политика)';
default:
return job.kind;
}
}
/** Вторая строка под заголовком: `module_id`, если имя модуля ещё не известно. */
export function jobKindSubtitle(
job: JobRow,
moduleNameById?: ReadonlyMap<string, string>
): string | null {
if (job.kind !== 'module_refresh') return null;
const mid = metaString(job.meta as Record<string, unknown> | undefined, 'module_id');
if (!mid) return null;
if (moduleNameById?.has(mid)) return null;
return mid;
}
+14
View File
@@ -0,0 +1,14 @@
import { apiJSON } from '$lib/api/client.js';
import type { ApiKeysResponse } from '$lib/api/types.js';
export const apiKeysKeys = {
all: ['api-keys'] as const,
list: (limit?: number) => [...apiKeysKeys.all, 'list', limit] as const
};
export function apiKeysQueryOptions(limit = 500) {
return {
queryKey: apiKeysKeys.list(limit),
queryFn: () => apiJSON<ApiKeysResponse>(`/v1/api-keys?limit=${limit}`)
};
}
+15
View File
@@ -0,0 +1,15 @@
import { apiJSON } from '$lib/api/client.js';
import type { AuthSession } from '$lib/api/types.js';
export const authKeys = {
all: ['auth'] as const,
session: () => [...authKeys.all, 'session'] as const
};
export function authSessionQueryOptions() {
return {
queryKey: authKeys.session(),
queryFn: () => apiJSON<AuthSession>('/v1/auth/session'),
retry: false
};
}
+44
View File
@@ -0,0 +1,44 @@
import { healthQueryOptions } from './health.js';
import { modulesQueryOptions } from './modules.js';
import { jobsQueryOptions, revisionsQueryOptions } from './operations.js';
import { peersQueryOptions, speakersQueryOptions } from './network.js';
export type DashboardSnapshot = {
healthy: boolean;
modulesCount: number;
revisionsCount: number;
peersCount: number;
speakersCount: number;
runningJobs: number;
};
export const dashboardKeys = {
all: ['dashboard'] as const,
stats: ['dashboard', 'stats'] as const
};
export function dashboardStatsQueryOptions() {
return {
queryKey: dashboardKeys.stats,
queryFn: async (): Promise<DashboardSnapshot> => {
const [health, modules, revisions, peers, speakers, jobs] = await Promise.all([
healthQueryOptions().queryFn(),
modulesQueryOptions(200).queryFn(),
revisionsQueryOptions(50).queryFn(),
peersQueryOptions(200).queryFn(),
speakersQueryOptions(200).queryFn(),
jobsQueryOptions(50).queryFn()
]);
const runningJobs = jobs.items.filter((j) => j.status === 'running').length;
return {
healthy: health.ok,
modulesCount: modules.items.length,
revisionsCount: revisions.items.length,
peersCount: peers.items.length,
speakersCount: speakers.items.length,
runningJobs
};
},
staleTime: 15_000
};
}
+16
View File
@@ -0,0 +1,16 @@
import { apiFetch } from '$lib/api/client.js';
export const healthKeys = {
all: ['health'] as const
};
export function healthQueryOptions() {
return {
queryKey: healthKeys.all,
queryFn: async () => {
const res = await apiFetch('/v1/health');
if (!res.ok) throw new Error(`GET /v1/health: HTTP ${res.status}`);
return { ok: true as const };
}
};
}
+14
View File
@@ -0,0 +1,14 @@
import { apiJSON } from '$lib/api/client.js';
import type { ModulesResponse } from '$lib/api/types.js';
export const modulesKeys = {
all: ['modules'] as const,
list: (limit?: number) => [...modulesKeys.all, 'list', limit] as const
};
export function modulesQueryOptions(limit = 50) {
return {
queryKey: modulesKeys.list(limit),
queryFn: () => apiJSON<ModulesResponse>(`/v1/modules?limit=${limit}`)
};
}

Some files were not shown because too many files have changed in this diff Show More