Compare commits

..
3 Commits
Author SHA1 Message Date
Denozordec c66cc9317d feat(web): refactor directories and network pages with improved state management and UI components
CI / changes (push) Successful in 6s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 31s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 3m42s
- Updated imports to utilize core UI components for better maintainability.
- Refactored state management for communities and DoH profiles, enhancing loading and error handling.
- Introduced derived states for KPI cards, providing better insights into community and DoH profile counts.
- Streamlined loading functions to improve data fetching efficiency and user experience.
- Enhanced UI layout with new icons and improved component structure for clarity.
2026-05-20 14:13:32 +07:00
Denozordec 9ea4a68ccf docs: update AGENTS, rules, and skills documentation for Conventional Commits
CI / changes (push) Successful in 7s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Has been skipped
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 17s
- Enhanced AGENTS.md to clarify the use of `/commit-message` and the exclusion of the Source Control button for generating commit messages.
- Updated conventional-commits.mdc to include new triggers and guidelines for commit message types, emphasizing the importance of semver impact.
- Revised commit-message SKILL.md to specify the mandatory execution of scripts and the correct usage of types and scopes in commit messages.
- Improved README documentation to reflect the updated guidelines and workflows for generating commit messages.
2026-05-20 12:52:31 +07:00
Denozordec 8204105fd6 feat(web): enhance module entry dialogs and selection handling
CI / changes (push) Successful in 8s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 38s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m1s
- Refactored module entry dialogs to reset forms based on edit state and improve state management.
- Updated selection logic in various components to utilize derived states for active selections, enhancing bulk operations.
- Improved dialog bindings for better state synchronization and user experience.
- Streamlined component structure for maintainability and clarity.
2026-05-20 12:35:45 +07:00
25 changed files with 1489 additions and 921 deletions
+37
View File
@@ -0,0 +1,37 @@
# Сгенерировать commit message (EvoBGP)
Сгенерируй сообщение коммита для **текущих staged-изменений**. Не выполняй `git commit`, если пользователь явно не просил закоммитить.
## Обязательный workflow (MUST)
1. Прочитай скилл [`.cursor/skills/commit-message/SKILL.md`](../skills/commit-message/SKILL.md).
2. Следуй правилу [`.cursor/rules/conventional-commits.mdc`](../rules/conventional-commits.mdc).
3. **Первым вызовом Shell** из корня репозитория:
```powershell
powershell -NoProfile -File scripts/commit/staged-context.ps1
```
4. Строй текст **только** по JSON stdout (`groups`, `stat`, `diff_excerpt`). Exit `1` → index пуст, сообщи пользователю.
5. **Запрещено** обходить скрипт через один `git diff --cached`.
## Формат вывода
Для каждого коммита (при auto-split — по одному блоку):
```
<type>(<scope>): <summary in English>
<тело на русском>
```
Плюс пояснение (RU): semver impact (`minor`|`patch`|`none`|`major`), почему выбран type, был ли split.
## Semver (кратко)
- Новая пользовательская возможность → `feat` (minor)
- Починка ожидаемого поведения / баг → `fix` (patch)
- Follow-up баги после недавнего `feat` в том же scope → **`fix`**, не `feat`
- Только перестройка без нового поведения → `refactor` (none)
Заголовок — EN, императив, ≤72 символов. Тело — RU.
+70 -5
View File
@@ -11,7 +11,13 @@ alwaysApply: false
## Триггеры (применить правило + скилл)
Любой запрос на коммит или сообщение коммита: `commit`, `коммит`, `закоммить`, `git commit`, `commit message`, `conventional commit`, `staged`, «сгенерируй коммит» — в т.ч. если это указано в плане или [AGENTS.md](../../AGENTS.md).
Любой запрос на коммит или сообщение коммита: `commit`, `коммит`, `закоммить`, `git commit`, `commit message`, `conventional commit`, `staged`, «сгенерируй коммит», **`/commit-message`** — в т.ч. если это указано в плане или [AGENTS.md](../../AGENTS.md).
### Не путать с кнопкой ✨ в Source Control
Команда **`cursor.generateGitCommitMessage`** (sparkle в поле commit message) **не** читает Rules, Skills и `staged-context.ps1` — только staged diff и история коммитов ([ограничение Cursor](https://forum.cursor.com/t/how-to-set-prompt-for-generate-commit-message/148606)).
**Замена для EvoBGP:** Agent → `/commit-message` или команда [`.cursor/commands/commit-message.md`](../commands/commit-message.md).
## Обязательный запуск скрипта (MUST)
@@ -41,15 +47,74 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
| type | Когда | Версия |
|------|--------|--------|
| `feat` | новая функциональность | minor |
| `fix` | исправление бага | patch |
| `feat` | **новая** пользовательская возможность (раньше нельзя было) | minor |
| `fix` | восстановление **ожидаемого** поведения; баг, регрессия, падение UI | patch |
| `perf` | ускорение без смены API | patch |
| `refactor` | реструктуризация без смены поведения | — |
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | — |
| `docs` | только документация | — |
| `test` | тесты | — |
| `ci` | CI/CD (`.gitea/`, workflows) | — |
| `chore` | обслуживание, deps, `.cursor/` | — |
### Выбор type: semver, а не «красивые слова»
**Главный вопрос:** что изменится для пользователя после релиза?
1. Появилось **новое** действие / экран / API / настройка, которых не было → `feat`
2. То, что **должно было работать**, не работало (кнопки, диалоги, сохранение, 500) → `fix`
3. Только перестройка кода или UI на другой паттерн, поведение для пользователя то же → `refactor`
4. Ускорение без изменения контракта → `perf`
**Не путать с формулировкой diff:**
| В diff / задаче часто пишут | Неверный type | Верный type, если… |
|-----------------------------|---------------|---------------------|
| enhance, improve, polish UI | `feat` | …только чиним сломанное после прошлого PR → `fix` |
| refactor pages, unify tables | `feat` | …новой возможности нет, лишь перенос на AppDataTable → `refactor` |
| follow-up после feat(web) | `feat` | …исправляем баги того же экрана → `fix` |
**Follow-up rule:** коммит сразу после `feat` в той же области, который **не добавляет** новую возможность, а устраняет дефект (effect loop, не открывается dialog, confirm не срабатывает) — **`fix`**, не `feat`.
**Split при смешанном diff:** новая страница/flow → `feat`; отдельным коммитом правки багов → `fix`. Не объединять в один `feat`.
**Breaking changes** — только `feat!` / `fix!` / `BREAKING CHANGE:` когда пользователь **обязан** менять конфиг, API или привычный workflow.
### Обязательно в пояснении агенту
При каждом предложении коммита указать:
- **Semver impact:** `minor` | `patch` | `none` | `major`
- **Почему не другой type** (одно предложение), если diff большой или формулировка двусмысленная
Пример неправильно / правильно:
```
# Плохо — patch-фикс, minor-bump
feat(web): enhance module entry dialogs and selection handling
# Хорошо
fix(web): stop effect loop breaking module action buttons
Исправлен effect_update_depth_exceeded и bind:open у Dialog; кнопки редактирования/удаления снова работают.
```
```
# Плохо — рефакторинг без новой фичи
feat(web): migrate modules list to AppDataTable
# Хорошо — если не было нового user-facing
refactor(web): migrate modules list to AppDataTable
Единый паттерн таблиц; поведение списка модулей без изменений.
```
```
# Хорошо feat — действительно новое
feat(web): add module create dialog on /modules
Диалог создания модуля с POST /v1/modules; раньше создание было только через API.
```
## Breaking changes
- Заголовок: `feat!` / `fix!` **или** в теле строка `BREAKING CHANGE:` (на английском ключевое слово) + описание impact **на русском**.
@@ -96,7 +161,7 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
<тело RU>
```
**2. Пояснение (RU):** почему выбран type; риск/impact; был ли split.
**2. Пояснение (RU):** semver impact (`minor`|`patch`|`none`|`major`); почему выбран type; риск/impact; был ли split.
## Примеры
+24 -5
View File
@@ -2,10 +2,14 @@
name: commit-message
description: >-
ОБЯЗАТЕЛЬНО при commit, коммит, закоммить, commit message, conventional commit,
staged, semantic-release, «сгенерируй коммит», git commit: ПЕРВЫМ делом Shell —
scripts/commit/staged-context.ps1; затем Conventional Commit (заголовок EN, тело RU).
staged, semantic-release, «сгенерируй коммит», git commit, /commit-message: ПЕРВЫМ
делом Shell — scripts/commit/staged-context.ps1; затем Conventional Commit (заголовок EN, тело RU).
---
> **Кнопка ✨ Generate commit message в Source Control** не использует этот скилл и Rules.
> Эквивалент: Agent Chat → **`/commit-message`** или «сгенерируй коммит по staged».
> См. [docs/README.md](../../docs/README.md#сообщения-коммитов-cursor).
# Commit message (EvoBGP)
## Когда применять (сразу читать этот скилл)
@@ -90,9 +94,24 @@ git commit -m "$( @'
По `groups[].diff_excerpt`, `stat`, `files`:
- **type** — по смыслу diff (`feat` / `fix` / …), не по умолчанию `chore`.
### Шаг A — semver (до выбора type)
| Вопрос | Если «да» → |
|--------|-------------|
| Пользователь получает **новую** возможность? | `feat` (minor) |
| Восстанавливается **ожидаемое** поведение / устранён баг? | `fix` (patch) |
| Только скорость, контракт тот же? | `perf` (patch) |
| Только структура кода/UI, поведение то же? | `refactor` (none) |
**Follow-up:** правки сразу после `feat` в том же scope без новой возможности → **`fix`**, не `feat` (слова *enhance/improve/refactor* в задаче не делают commit `feat`).
**Запрещено** по умолчанию ставить `feat` для «большого diff» в `web/` — type по **semver impact**, не по объёму.
### Шаг B — type, scope, текст
- **type** — результат шага A, не «chore по умолчанию» и не `feat` из-за слова enhance.
- **scope** — из JSON группы или доминирующий при merge.
- **summary** — конкретный, английский, императив.
- **summary** — конкретный, английский, императив; для `fix` — что **починено** (`fix broken …`, `prevent … loop`).
- **body** — русский: что, зачем, edge cases, breaking impact.
## Вывод пользователю
@@ -109,7 +128,7 @@ git commit -m "$( @'
### 2. Пояснение (RU)
- Почему выбран type/scope.
- **Semver impact:** `minor` | `patch` | `none` | `major` — и почему не другой type.
- Риски и impact.
- Split: сколько коммитов и почему.
+3 -1
View File
@@ -38,13 +38,15 @@
## Коммиты (Conventional Commits)
Если пользователь просит **коммит**, **commit message**, **закоммить**, **git commit** или это следует из плана — **сразу**:
Если пользователь просит **коммит**, **commit message**, **закоммить**, **git commit**, **`/commit-message`** или это следует из плана — **сразу**:
1. Shell: `powershell -NoProfile -File scripts/commit/staged-context.ps1` (первый вызов, до текста коммита).
2. Скилл [.cursor/skills/commit-message/SKILL.md](.cursor/skills/commit-message/SKILL.md) и правило [.cursor/rules/conventional-commits.mdc](.cursor/rules/conventional-commits.mdc).
Без вывода скрипта (exit 0) **не** придумывать сообщение коммита. Заголовок — EN, тело — RU; несвязанные области — auto-split (скилл).
**Кнопка ✨ Generate commit message в Source Control** skill/rule **не** использует. Для сообщений по правилам EvoBGP — Agent Chat → **`/commit-message`** (см. [.cursor/commands/commit-message.md](.cursor/commands/commit-message.md)).
## Команды и среда
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
+10 -1
View File
@@ -36,7 +36,16 @@
### Сообщения коммитов (Cursor)
После `git add` попросите агента: **«сгенерируй коммит по staged»**, **«закоммить»**, **«commit message»** — агент **обязан первым делом** запустить `scripts/commit/staged-context.ps1`, затем скилл [commit-message](../.cursor/skills/commit-message/SKILL.md) (заголовок EN, тело RU, auto-split). Просмотр групп вручную: `powershell -NoProfile -File scripts/commit/staged-context.ps1 | ConvertFrom-Json`.
После `git add`:
| Способ | Skill + rule + `staged-context.ps1` |
|--------|-------------------------------------|
| Agent → **`/commit-message`** или «сгенерируй коммит по staged» | **Да** |
| Кнопка **✨ Generate commit message** в Source Control | **Нет** (только diff + история; [ограничение Cursor](https://forum.cursor.com/t/how-to-set-prompt-for-generate-commit-message/148606)) |
Рекомендуемый workflow: Agent Chat → **`/commit-message`** ([команда](../.cursor/commands/commit-message.md), [скилл](../.cursor/skills/commit-message/SKILL.md), [правило](../.cursor/rules/conventional-commits.mdc)). Агент **обязан первым делом** запустить `scripts/commit/staged-context.ps1` (заголовок EN, тело RU, auto-split).
Просмотр групп вручную: `powershell -NoProfile -File scripts/commit/staged-context.ps1 | ConvertFrom-Json`.
## Репозиторий и CI
+2
View File
@@ -13,6 +13,8 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты.
**Как не перепутать `feat` и `fix`:** см. раздел «Выбор type: semver, а не «красивые слова»» в [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc). Кратко: `feat` — новая возможность (minor); `fix` — починка ожидаемого поведения (patch); follow-up баги после недавнего `feat` — всегда `fix`, даже если diff большой.
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
## CI-пайплайн (один push в main)
@@ -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 '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/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 '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/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>
@@ -46,8 +46,13 @@
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const selectedCount = $derived(selectedIds.size);
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
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' },
@@ -75,11 +80,6 @@
{ id: 'actions', label: '', class: 'w-20' }
] as const;
$effect(() => {
const validIds = new Set(entries.map((e) => e.id));
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
});
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
@@ -133,7 +133,7 @@
deletingBulk = true;
let deleted = 0;
try {
for (const id of selectedIds) {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/as-entries/${id}`, 'DELETE', undefined, {
idempotent: false
@@ -35,12 +35,23 @@
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) {
form = edit
? { asn: edit.asn, community_id: edit.community_id }
: { asn: 0, community_id: null };
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
@@ -75,7 +86,7 @@
}
</script>
<Dialog {open} onOpenChange={handleOpenChange}>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
@@ -51,6 +51,7 @@
prefix_path: '',
community_id: null
});
let initKey = $state('');
function clearPreview() {
previewLoading = false;
@@ -61,18 +62,28 @@
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) {
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 };
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
@@ -150,7 +161,7 @@
}
</script>
<Dialog {open} onOpenChange={handleOpenChange}>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
@@ -39,8 +39,12 @@
let selectedIds = $state(new Set<string>());
let deletingBulk = $state(false);
const selectedCount = $derived(selectedIds.size);
const allSelected = $derived(sources.length > 0 && selectedIds.size === sources.length);
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' },
@@ -62,11 +66,6 @@
{ id: 'actions', label: '', class: 'w-20' }
] as const;
$effect(() => {
const validIds = new Set(sources.map((s) => s.id));
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
});
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
@@ -120,7 +119,7 @@
deletingBulk = true;
let deleted = 0;
try {
for (const id of selectedIds) {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${id}`, 'DELETE', undefined, {
idempotent: false
@@ -72,7 +72,7 @@
}
</script>
<Dialog {open} onOpenChange={handleOpenChange}>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Новый модуль</DialogTitle>
@@ -39,15 +39,15 @@
<Badge variant={moduleEnabledBadgeVariant(!!mod.enabled)} class="text-xs">
{moduleEnabledRu(!!mod.enabled)}
</Badge>
<Button variant="outline" size="sm" onclick={onRefresh} disabled={refreshing}>
<Button variant="outline" size="sm" onclick={() => onRefresh()} disabled={refreshing}>
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
<Button variant="outline" size="sm" onclick={onEdit}>
<Button variant="outline" size="sm" onclick={() => onEdit()}>
<Pencil />
Редактировать
</Button>
<Button variant="destructive" size="sm" onclick={onDelete}>
<Button variant="destructive" size="sm" onclick={() => onDelete()}>
<Trash2 />
Удалить
</Button>
@@ -44,8 +44,12 @@
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const selectedCount = $derived(selectedIds.size);
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
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' },
@@ -54,11 +58,6 @@
{ id: 'actions', label: '', class: 'w-20' }
] as const;
$effect(() => {
const validIds = new Set(entries.map((e) => e.id));
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
});
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
@@ -112,7 +111,7 @@
deletingBulk = true;
let deleted = 0;
try {
for (const id of selectedIds) {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${id}`, 'DELETE', undefined, {
idempotent: false
@@ -34,12 +34,23 @@
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) {
form = edit
? { fqdn: edit.fqdn, community_id: edit.community_id }
: { fqdn: '', community_id: null };
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
@@ -68,7 +79,7 @@
}
</script>
<Dialog {open} onOpenChange={handleOpenChange}>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
@@ -71,19 +71,30 @@
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 && mod) {
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'
};
if (!open) {
initKey = '';
return;
}
const nextKey = mod.id;
if (nextKey !== initKey) {
initKey = nextKey;
resetEditForm();
}
});
@@ -136,7 +147,7 @@
}
</script>
<Dialog {open} onOpenChange={handleOpenChange}>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-md">
<DialogHeader>
<DialogTitle>Редактировать модуль</DialogTitle>
@@ -28,12 +28,23 @@
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) {
form = edit
? { prefix: edit.prefix, community_id: edit.community_id }
: { prefix: '', community_id: '' };
if (!open) {
initKey = '';
return;
}
const nextKey = edit?.id ?? 'new';
if (nextKey !== initKey) {
initKey = nextKey;
resetForm();
}
});
@@ -62,7 +73,7 @@
}
</script>
<Dialog {open} onOpenChange={handleOpenChange}>
<Dialog bind:open onOpenChange={handleOpenChange}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
@@ -44,8 +44,12 @@
let csvExporting = $state(false);
let csvFileInput = $state<HTMLInputElement | null>(null);
const selectedCount = $derived(selectedIds.size);
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
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' },
@@ -59,11 +63,6 @@
{ id: 'actions', label: '', class: 'w-20' }
] as const;
$effect(() => {
const validIds = new Set(entries.map((e) => e.id));
selectedIds = new Set([...selectedIds].filter((id) => validIds.has(id)));
});
function toggleSelection(id: string) {
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id);
@@ -120,7 +119,7 @@
deletingBulk = true;
let deleted = 0;
try {
for (const id of selectedIds) {
for (const id of activeSelected) {
try {
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${id}`, 'DELETE', undefined, {
idempotent: false
@@ -0,0 +1,290 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { PeerRow, BgpPeerCreate, SpeakerRow } from '$lib/api/types.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import { Label } from '$lib/ui/core/label/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '$lib/ui/core/dialog/index.js';
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
import { Switch } from '$lib/ui/core/switch/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
import { confirm } from '$lib/ui/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 speakerLabelById(id: string | null | undefined) {
if (!id) return '—';
return speakerById.get(id)?.endpoint ?? id;
}
function sessionBadge(state: string) {
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'}
<Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge>
{:else if column.id === 'speaker'}
<span class="text-xs text-muted-foreground">{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,177 @@
<script lang="ts">
import { apiMutate } from '$lib/api/client.js';
import type { SpeakerRow, BgpSpeakerCreate } from '$lib/api/types.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/ui/core/card/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter
} from '$lib/ui/core/dialog/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
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';
type Props = {
items: SpeakerRow[];
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<SpeakerRow | null>(null);
let form = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
let saving = $state(false);
let applyingId = $state<string | null>(null);
const columns = [
{
id: 'endpoint',
label: 'Endpoint',
sortable: true,
sortValue: (s: SpeakerRow) => s.endpoint
},
{ id: 'role', label: 'Роль', sortable: true, sortValue: (s: SpeakerRow) => s.role },
{ id: 'last_applied_revision_id', label: 'Последняя ревизия' },
{ id: 'actions', label: '', class: 'w-32' }
] as const;
function openCreate() {
editTarget = null;
form = { endpoint: '', role: 'operator' };
dialogOpen = true;
}
function openEdit(s: SpeakerRow) {
editTarget = s;
form = { endpoint: s.endpoint, role: s.role };
dialogOpen = true;
}
async function applySpeaker(id: string) {
applyingId = id;
try {
await apiMutate(`/v1/speakers/${id}/apply`, 'POST', {});
notify.success('Apply запущен');
} catch (e) {
notifyApiError(e);
} finally {
applyingId = null;
}
}
async function save() {
if (!form.endpoint.trim()) {
notify.error('Укажите endpoint');
return;
}
saving = true;
try {
if (editTarget) {
await apiMutate(`/v1/speakers/${editTarget.id}`, 'PATCH', form);
notify.success('Спикер обновлён');
} else {
await apiMutate('/v1/speakers', 'POST', form);
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">Спикеры</CardTitle>
<CardDescription>BIRD-агенты, применяющие конфигурацию на нодах</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="Добавьте BIRD-агент для применения конфигурации."
>
{#snippet cell({ row: s, column })}
{#if column.id === 'endpoint'}
<span class="font-mono text-sm">{s.endpoint}</span>
{:else if column.id === 'role'}
<Badge variant="outline">{s.role}</Badge>
{:else if column.id === 'last_applied_revision_id'}
<span class="font-mono text-xs text-muted-foreground">
{s.last_applied_revision_id ? s.last_applied_revision_id.slice(0, 8) + '…' : '—'}
</span>
{:else if column.id === 'actions'}
<div class="flex gap-1">
<Button
variant="outline"
size="xs"
title="Запустить применение ревизии на спикере"
onclick={() => applySpeaker(s.id)}
disabled={applyingId === s.id}
>
<Play class="size-3" />
{applyingId === s.id ? 'Apply…' : 'Apply'}
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(s)}>
<Pencil 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>
</DialogHeader>
<div class="space-y-4 py-2">
<FormField label="Endpoint" id="s-endpoint" required>
<AppInput id="s-endpoint" placeholder="http://bird-agent:8081" bind:value={form.endpoint} />
</FormField>
<FormField label="Роль" id="s-role">
<AppInput id="s-role" placeholder="operator" 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>
@@ -15,7 +15,12 @@
const open = $derived(!!state?.open);
function onOpenChange(v: boolean) {
if (!v) closeConfirm();
if (!v && state && !state.loading) closeConfirm();
}
async function handleConfirm() {
if (!state || state.loading) return;
await state.onConfirm();
}
</script>
@@ -37,7 +42,10 @@
? 'text-destructive-foreground bg-destructive hover:bg-destructive/90'
: ''}
disabled={state.loading}
onclick={state.onConfirm}
onclick={(e) => {
e.preventDefault();
void handleConfirm();
}}
>
{state.loading ? '…' : (state.confirmLabel ?? 'Подтвердить')}
</AlertDialogAction>
@@ -21,8 +21,9 @@ export function confirm(options: ConfirmOptions) {
open: true,
loading: false,
onConfirm: async () => {
if (!confirmState.current) return;
confirmState.current = { ...confirmState.current, loading: true };
const current = confirmState.current;
if (!current || current.loading) return;
confirmState.current = { ...current, loading: true };
try {
await options.onConfirm();
resolve(true);
+185 -374
View File
@@ -1,219 +1,221 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import { resolve } from '$app/paths';
import { apiJSON } from '$lib/api/client.js';
import type {
BgpCommunity,
BgpCommunityCreate,
CommunitiesResponse,
DohProfile,
DohProfileCreate,
DohProfilesResponse
} from '$lib/api/types.js';
import { Button } from '$lib/components/ui/button/index.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/components/ui/card/index.js';
import FormField from '$lib/ui/patterns/form/form-field.svelte';
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '$lib/components/ui/dialog/index.js';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction
} from '$lib/components/ui/alert-dialog/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table/index.js';
import { toast } from 'svelte-sonner';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
} from '$lib/ui/core/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import { notifyApiError } from '$lib/ui/app/toast.js';
import DirectoriesCommunitiesCard from '$lib/components/directories/DirectoriesCommunitiesCard.svelte';
import DirectoriesDohProfilesCard from '$lib/components/directories/DirectoriesDohProfilesCard.svelte';
import { cn } from '$lib/utils.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import BookOpen from '@lucide/svelte/icons/book-open';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
import Info from '@lucide/svelte/icons/info';
import Tags from '@lucide/svelte/icons/tags';
import Globe from '@lucide/svelte/icons/globe';
import Library from '@lucide/svelte/icons/library';
import ArrowRight from '@lucide/svelte/icons/arrow-right';
// --- Communities ---
let communities = $state<BgpCommunity[]>([]);
let commLoading = $state(false);
let commDialog = $state(false);
let commEdit = $state<BgpCommunity | null>(null);
let commForm = $state<BgpCommunityCreate>({ community: '', title: '' });
let commSaving = $state(false);
let commDeleteTarget = $state<BgpCommunity | null>(null);
// --- DoH Profiles ---
let dohProfiles = $state<DohProfile[]>([]);
let dohLoading = $state(false);
let dohDialog = $state(false);
let dohEdit = $state<DohProfile | null>(null);
let dohForm = $state<DohProfileCreate & { timeout_ms?: number | null }>({
url: '',
timeout_ms: null,
vault_secret_ref: null
});
let dohSaving = $state(false);
let dohDeleteTarget = $state<DohProfile | null>(null);
let loading = $state(false);
let initialLoading = $state(true);
let loadError = $state<string | null>(null);
let lastUpdated = $state<Date | null>(null);
async function loadComm() {
commLoading = true;
try {
const r = await apiJSON<CommunitiesResponse>('/v1/communities?limit=200');
communities = r.items;
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
commLoading = false;
const statAccents = [
{
border: 'border-l-chart-2',
bg: 'bg-chart-2/5',
iconBg: 'bg-chart-2/15',
iconText: 'text-chart-2'
},
{
border: 'border-l-chart-3',
bg: 'bg-chart-3/5',
iconBg: 'bg-chart-3/15',
iconText: 'text-chart-3'
},
{
border: 'border-l-chart-1',
bg: 'bg-chart-1/5',
iconBg: 'bg-chart-1/15',
iconText: 'text-chart-1'
}
] as const;
const kpiCards = $derived.by(() => [
{
id: 'communities',
label: 'Сообщества BGP',
value: initialLoading ? '—' : String(communities.length),
description: 'теги префиксов в AS- и CDN-модулях',
icon: Tags,
accent: statAccents[0],
badge: 'community'
},
{
id: 'doh',
label: 'DoH профили',
value: initialLoading ? '—' : String(dohProfiles.length),
description: 'резолвинг доменных модулей',
icon: Globe,
accent: statAccents[1],
badge: 'DNS-over-HTTPS'
},
{
id: 'shared',
label: 'Справочники',
value: 'Общие',
description: 'используются всеми модулями tenant',
icon: Library,
accent: statAccents[2],
badge: 'tenant-wide',
href: '/modules' as const
}
]);
async function loadCommunities() {
const r = await apiJSON<CommunitiesResponse>('/v1/communities?limit=200');
communities = r.items;
}
async function loadDoh() {
dohLoading = true;
const r = await apiJSON<DohProfilesResponse>('/v1/doh-profiles?limit=200');
dohProfiles = r.items;
}
async function load() {
if (!initialLoading) loading = true;
loadError = null;
try {
const r = await apiJSON<DohProfilesResponse>('/v1/doh-profiles?limit=200');
dohProfiles = r.items;
await Promise.all([loadCommunities(), loadDoh()]);
lastUpdated = new Date();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
loadError = e instanceof Error ? e.message : String(e);
notifyApiError(e);
} finally {
dohLoading = false;
loading = false;
initialLoading = false;
}
}
onMount(() => {
loadComm();
loadDoh();
});
// --- Community actions ---
function commDisplay(c: BgpCommunity | null) {
if (!c) return '';
const t = c.title?.trim();
return t || c.community;
}
function openCommCreate() {
commEdit = null;
commForm = { community: '', title: '' };
commDialog = true;
}
function openCommEdit(c: BgpCommunity) {
commEdit = c;
commForm = { community: c.community, title: c.title ?? '' };
commDialog = true;
}
async function saveComm() {
if (!commForm.community.trim()) {
toast.error('Укажите community');
return;
}
commSaving = true;
async function refreshCommunities() {
try {
const body = { ...commForm, title: commForm.title?.trim() || undefined };
if (commEdit) {
await apiMutate(`/v1/communities/${commEdit.id}`, 'PATCH', body);
toast.success('Запись сообщества обновлена');
} else {
await apiMutate('/v1/communities', 'POST', body);
toast.success('Сообщество создано');
}
commDialog = false;
await loadComm();
loadError = null;
await loadCommunities();
lastUpdated = new Date();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
commSaving = false;
}
}
async function deleteComm() {
if (!commDeleteTarget) return;
try {
await apiMutate(`/v1/communities/${commDeleteTarget.id}`, 'DELETE', undefined, {
idempotent: false
});
toast.success('Удалено');
commDeleteTarget = null;
await loadComm();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
loadError = e instanceof Error ? e.message : String(e);
notifyApiError(e);
}
}
// --- DoH actions ---
function openDohCreate() {
dohEdit = null;
dohForm = { url: '', timeout_ms: null, vault_secret_ref: null };
dohDialog = true;
}
function openDohEdit(d: DohProfile) {
dohEdit = d;
dohForm = { url: d.url, timeout_ms: d.timeout_ms, vault_secret_ref: d.vault_secret_ref };
dohDialog = true;
}
async function saveDoh() {
if (!dohForm.url.trim()) {
toast.error('Укажите URL');
return;
}
dohSaving = true;
async function refreshDoh() {
try {
if (dohEdit) {
await apiMutate(`/v1/doh-profiles/${dohEdit.id}`, 'PATCH', dohForm);
toast.success('DoH профиль обновлён');
} else {
await apiMutate('/v1/doh-profiles', 'POST', dohForm);
toast.success('DoH профиль создан');
}
dohDialog = false;
loadError = null;
await loadDoh();
lastUpdated = new Date();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
dohSaving = false;
}
}
async function deleteDoh() {
if (!dohDeleteTarget) return;
try {
await apiMutate(`/v1/doh-profiles/${dohDeleteTarget.id}`, 'DELETE', undefined, {
idempotent: false
});
toast.success('Удалено');
dohDeleteTarget = null;
await loadDoh();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
loadError = e instanceof Error ? e.message : String(e);
notifyApiError(e);
}
}
onMount(load);
</script>
<div class="flex flex-col gap-6">
<PageHeader
title="Справочники"
description="Сообщества BGP и DoH-профили для резолвинга доменов."
description={lastUpdated
? `Сообщества BGP и DoH-профили для резолвинга доменов. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'Сообщества BGP и DoH-профили для резолвинга доменов.'}
icon={BookOpen}
iconClass="bg-chart-2/15 text-chart-2"
/>
>
{#snippet actions()}
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
<RefreshCw class={loading ? 'animate-spin' : ''} />
Обновить
</Button>
{/snippet}
</PageHeader>
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>О справочниках</AlertTitle>
<AlertDescription>
Сообщества BGP используются в AS- и CDN-модулях для тегирования префиксов. DoH-профили — в
доменных модулях для DNS-over-HTTPS резолвинга. Настройка модулей — на странице
<Button variant="link" class="h-auto p-0" href={resolve('/modules')}>Модули</Button>.
</AlertDescription>
</Alert>
<div class="grid gap-4 sm:grid-cols-3">
{#if initialLoading}
{#each Array(3) 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 transition-colors',
a.border,
a.bg,
card.href ? 'hover:border-primary/35' : ''
)}
>
<CardHeader class="pb-2">
<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>
{#if card.href}
<Button variant="ghost" size="icon-sm" href={resolve(card.href)}>
<ArrowRight class="size-3.5" aria-hidden="true" />
</Button>
{/if}
</div>
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant="outline">{card.badge}</Badge>
<p class="text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
<Tabs value="communities">
<TabsList>
@@ -221,215 +223,24 @@
<TabsTrigger value="doh">DoH профили</TabsTrigger>
</TabsList>
<!-- Communities -->
<TabsContent value="communities" class="mt-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle class="text-base">Сообщества BGP</CardTitle>
<CardDescription>Используются для тегирования префиксов</CardDescription>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={loadComm} disabled={commLoading}>
<RefreshCw class={commLoading ? 'animate-spin' : ''} />
</Button>
<Button size="sm" onclick={openCommCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Код сообщества</TableHead>
<TableHead>Название</TableHead>
<TableHead>ID</TableHead>
<TableHead class="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each communities as c (c.id)}
<TableRow>
<TableCell class="font-mono text-sm font-medium">{c.community}</TableCell>
<TableCell>{c.title?.trim() || '—'}</TableCell>
<TableCell class="font-mono text-xs text-muted-foreground">{c.id}</TableCell>
<TableCell>
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openCommEdit(c)}
><Pencil class="size-3.5" /></Button
>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => (commDeleteTarget = c)}><Trash2 class="size-3.5" /></Button
>
</div>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground text-center py-8">
{commLoading ? 'Загрузка…' : 'Нет записей. Создайте первую.'}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
<DirectoriesCommunitiesCard
items={communities}
{loading}
{initialLoading}
error={loadError}
onRefresh={refreshCommunities}
/>
</TabsContent>
<!-- DoH Profiles -->
<TabsContent value="doh" class="mt-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle class="text-base">DoH профили</CardTitle>
<CardDescription>DNS-over-HTTPS серверы для резолвинга доменных модулей</CardDescription
>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={loadDoh} disabled={dohLoading}>
<RefreshCw class={dohLoading ? 'animate-spin' : ''} />
</Button>
<Button size="sm" onclick={openDohCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>URL</TableHead>
<TableHead>Таймаут (мс)</TableHead>
<TableHead>ID</TableHead>
<TableHead class="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each dohProfiles as d (d.id)}
<TableRow>
<TableCell class="font-mono text-sm">{d.url}</TableCell>
<TableCell class="text-muted-foreground">{d.timeout_ms ?? '—'}</TableCell>
<TableCell class="font-mono text-xs text-muted-foreground">{d.id}</TableCell>
<TableCell>
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openDohEdit(d)}
><Pencil class="size-3.5" /></Button
>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => (dohDeleteTarget = d)}><Trash2 class="size-3.5" /></Button
>
</div>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground text-center py-8">
{dohLoading ? 'Загрузка…' : 'Нет DoH профилей.'}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
<DirectoriesDohProfilesCard
items={dohProfiles}
{loading}
{initialLoading}
error={loadError}
onRefresh={refreshDoh}
/>
</TabsContent>
</Tabs>
</div>
<!-- Community Dialog -->
<Dialog bind:open={commDialog}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{commEdit ? 'Редактировать сообщество 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={commForm.community} placeholder="65001:120" />
</FormField>
<FormField label="Название" id="c-title" description="Человекочитаемое имя для списков">
<AppInput id="c-title" bind:value={commForm.title} placeholder="Название" />
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (commDialog = false)}>Отмена</Button>
<Button onclick={saveComm} disabled={commSaving}
>{commSaving ? 'Сохранение…' : commEdit ? 'Сохранить' : 'Создать'}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={!!commDeleteTarget}
onOpenChange={(v) => {
if (!v) commDeleteTarget = null;
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить сообщество «{commDisplay(commDeleteTarget)}»?</AlertDialogTitle>
<AlertDialogDescription
>Это приведёт к удалению привязки во всех модулях.</AlertDialogDescription
>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onclick={() => (commDeleteTarget = null)}>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={deleteComm}>Удалить</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- DoH Dialog -->
<Dialog bind:open={dohDialog}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{dohEdit ? 'Редактировать' : 'Новый'} 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={dohForm.url}
placeholder="https://dns.google/dns-query"
/>
</FormField>
<FormField label="Таймаут (мс)" id="doh-timeout">
<AppInput
id="doh-timeout"
type="number"
bind:value={dohForm.timeout_ms}
placeholder="5000"
/>
</FormField>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (dohDialog = false)}>Отмена</Button>
<Button onclick={saveDoh} disabled={dohSaving}
>{dohSaving ? 'Сохранение…' : dohEdit ? 'Сохранить' : 'Создать'}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={!!dohDeleteTarget}
onOpenChange={(v) => {
if (!v) dohDeleteTarget = null;
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить DoH профиль?</AlertDialogTitle>
<AlertDialogDescription>{dohDeleteTarget?.url}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onclick={() => (dohDeleteTarget = null)}>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={deleteDoh}>Удалить</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
+185 -456
View File
@@ -1,250 +1,214 @@
<script lang="ts">
import { onMount } from 'svelte';
import { apiJSON, apiMutate } from '$lib/api/client.js';
import type {
PeerRow,
BgpPeerCreate,
PeersResponse,
SpeakerRow,
BgpSpeakerCreate,
SpeakersResponse
} from '$lib/api/types.js';
import { Badge } from '$lib/components/ui/badge/index.js';
import { Button } from '$lib/components/ui/button/index.js';
import { resolve } from '$app/paths';
import { apiJSON } from '$lib/api/client.js';
import type { PeerRow, PeersResponse, SpeakerRow, SpeakersResponse } from '$lib/api/types.js';
import { Badge } from '$lib/ui/core/badge/index.js';
import { Button } from '$lib/ui/core/button/index.js';
import {
Card,
CardContent,
CardHeader,
CardTitle,
CardDescription
} from '$lib/components/ui/card/index.js';
import { Input } from '$lib/components/ui/input/index.js';
import { Label } from '$lib/components/ui/label/index.js';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger
} from '$lib/components/ui/select/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription
} from '$lib/components/ui/dialog/index.js';
import {
AlertDialog,
AlertDialogContent,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogCancel,
AlertDialogAction
} from '$lib/components/ui/alert-dialog/index.js';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '$lib/components/ui/table/index.js';
import { Switch } from '$lib/components/ui/switch/index.js';
import { toast } from 'svelte-sonner';
import Plus from '@lucide/svelte/icons/plus';
import Pencil from '@lucide/svelte/icons/pencil';
import Trash2 from '@lucide/svelte/icons/trash-2';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import Play from '@lucide/svelte/icons/play';
import NetworkIcon from '@lucide/svelte/icons/network';
} from '$lib/ui/core/card/index.js';
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
// --- Peers ---
import { notifyApiError } from '$lib/ui/app/toast.js';
import NetworkPeersCard from '$lib/components/network/NetworkPeersCard.svelte';
import NetworkSpeakersCard from '$lib/components/network/NetworkSpeakersCard.svelte';
import { cn } from '$lib/utils.js';
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
import NetworkIcon from '@lucide/svelte/icons/network';
import Info from '@lucide/svelte/icons/info';
import Share2 from '@lucide/svelte/icons/share-2';
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
import Server from '@lucide/svelte/icons/server';
let peers = $state<PeerRow[]>([]);
let peersLoading = $state(false);
let peerDialog = $state(false);
let peerEdit = $state<PeerRow | null>(null);
let peerForm = $state<BgpPeerCreate & { bgp_speaker_id?: string | null }>({
name: '',
neighbor: '',
remote_asn: 0,
bgp_speaker_id: null,
enabled: true
});
let peerSaving = $state(false);
let peerToggleId = $state<string | null>(null);
let peerDeleteTarget = $state<PeerRow | null>(null);
// --- Speakers ---
let speakers = $state<SpeakerRow[]>([]);
let peersLoading = $state(false);
let speakersLoading = $state(false);
let speakerDialog = $state(false);
let speakerEdit = $state<SpeakerRow | null>(null);
let speakerForm = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
let speakerSaving = $state(false);
let applyingId = $state<string | null>(null);
const speakerById = $derived.by(() => new Map(speakers.map((s) => [s.id, s])));
let initialLoading = $state(true);
let loadError = $state<string | null>(null);
let lastUpdated = $state<Date | null>(null);
function speakerLabelById(id: string | null | undefined) {
if (!id) return '—';
return speakerById.get(id)?.endpoint ?? id;
}
const establishedCount = $derived(peers.filter((p) => p.session_state === 'Established').length);
async function loadAll() {
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'
}
] as const;
const kpiCards = $derived.by(() => [
{
id: 'peers',
label: 'BGP-пиры',
value: initialLoading ? '—' : String(peers.length),
description: 'настроенные BGP-соседи',
icon: Share2,
accent: statAccents[0],
badge: 'peers'
},
{
id: 'established',
label: 'Активные сессии',
value: initialLoading ? '—' : String(establishedCount),
description:
establishedCount > 0 ? 'Established из текущей выборки' : 'нет установленных сессий',
icon: CheckCircle2,
accent: statAccents[1],
badge: establishedCount > 0 ? 'Established' : 'нет сессий',
badgeClass: establishedCount > 0 ? 'border-success/30 bg-success/15 text-success' : undefined
},
{
id: 'speakers',
label: 'Спикеры',
value: initialLoading ? '—' : String(speakers.length),
description: 'BIRD-агенты на нодах',
icon: Server,
accent: statAccents[2],
badge: 'agents'
}
]);
const refreshing = $derived(peersLoading || speakersLoading);
async function loadPeers() {
peersLoading = true;
speakersLoading = true;
try {
const [pr, sr] = await Promise.all([
apiJSON<PeersResponse>('/v1/peers?limit=200'),
apiJSON<SpeakersResponse>('/v1/speakers?limit=200')
]);
const pr = await apiJSON<PeersResponse>('/v1/peers?limit=200');
peers = pr.items;
speakers = sr.items;
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
loadError = e instanceof Error ? e.message : String(e);
notifyApiError(e);
throw e;
} finally {
peersLoading = false;
}
}
async function loadSpeakers() {
speakersLoading = true;
try {
const sr = await apiJSON<SpeakersResponse>('/v1/speakers?limit=200');
speakers = sr.items;
} catch (e) {
loadError = e instanceof Error ? e.message : String(e);
notifyApiError(e);
throw e;
} finally {
speakersLoading = false;
}
}
onMount(loadAll);
// --- Peer actions ---
function openPeerCreate() {
peerEdit = null;
peerForm = { name: '', neighbor: '', remote_asn: 0, bgp_speaker_id: null, enabled: true };
peerDialog = true;
}
function openPeerEdit(p: PeerRow) {
peerEdit = p;
peerForm = {
name: p.name ?? '',
neighbor: p.neighbor,
remote_asn: p.remote_asn ?? 0,
bgp_speaker_id: p.bgp_speaker_id,
enabled: p.enabled !== false
};
peerDialog = true;
}
async function setPeerEnabled(p: PeerRow, enabled: boolean) {
peerToggleId = p.id;
async function load() {
loadError = null;
try {
await apiMutate(`/v1/peers/${p.id}`, 'PATCH', { enabled });
toast.success(enabled ? 'Пир включён' : 'Пир отключён');
await loadAll();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
await Promise.all([loadPeers(), loadSpeakers()]);
lastUpdated = new Date();
} catch {
// errors handled in loaders
} finally {
peerToggleId = null;
}
}
async function savePeer() {
if (!peerForm.neighbor.trim()) {
toast.error('Укажите адрес соседа');
return;
}
if (!peerForm.remote_asn || peerForm.remote_asn <= 0) {
toast.error('Remote ASN должен быть больше 0');
return;
}
peerSaving = true;
try {
if (peerEdit) {
await apiMutate(`/v1/peers/${peerEdit.id}`, 'PATCH', peerForm);
toast.success('Пир обновлён');
} else {
await apiMutate('/v1/peers', 'POST', {
...peerForm,
enabled: peerForm.enabled !== false
});
toast.success('Пир создан');
}
peerDialog = false;
await loadAll();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
peerSaving = false;
}
}
async function deletePeer() {
if (!peerDeleteTarget) return;
try {
await apiMutate(`/v1/peers/${peerDeleteTarget.id}`, 'DELETE', undefined, {
idempotent: false
});
toast.success('Пир удалён');
peerDeleteTarget = null;
await loadAll();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
initialLoading = false;
}
}
// --- Speaker actions ---
function openSpeakerCreate() {
speakerEdit = null;
speakerForm = { endpoint: '', role: 'operator' };
speakerDialog = true;
}
function openSpeakerEdit(s: SpeakerRow) {
speakerEdit = s;
speakerForm = { endpoint: s.endpoint, role: s.role };
speakerDialog = true;
}
async function saveSpeaker() {
if (!speakerForm.endpoint) {
toast.error('Укажите endpoint');
return;
}
speakerSaving = true;
async function refreshPeers() {
try {
if (speakerEdit) {
await apiMutate(`/v1/speakers/${speakerEdit.id}`, 'PATCH', speakerForm);
toast.success('Спикер обновлён');
} else {
await apiMutate('/v1/speakers', 'POST', speakerForm);
toast.success('Спикер создан');
}
speakerDialog = false;
await loadAll();
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
speakerSaving = false;
}
}
async function applySpeaker(id: string) {
applyingId = id;
try {
await apiMutate(`/v1/speakers/${id}/apply`, 'POST', {});
toast.success('Apply запущен');
} catch (e) {
toast.error(e instanceof Error ? e.message : String(e));
} finally {
applyingId = null;
await loadPeers();
lastUpdated = new Date();
} catch {
// notifyApiError in loadPeers
}
}
function sessionBadge(state: string) {
if (state === 'Established') return 'default';
if (state === 'Active' || state === 'Connect') return 'secondary';
return 'outline';
async function refreshSpeakers() {
try {
await loadSpeakers();
lastUpdated = new Date();
} catch {
// notifyApiError in loadSpeakers
}
}
onMount(load);
</script>
<div class="flex flex-col gap-6">
<PageHeader
title="Сеть"
description="BGP-пиры и спикеры (BIRD-агенты)."
description={lastUpdated
? `BGP-пиры и спикеры (BIRD-агенты). Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
: 'BGP-пиры и спикеры (BIRD-агенты).'}
icon={NetworkIcon}
iconClass="bg-chart-3/15 text-chart-3"
/>
>
{#snippet actions()}
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
Обновить
</Button>
{/snippet}
</PageHeader>
<Alert class="border-info/30 bg-info/5">
<Info class="text-info" />
<AlertTitle>О сетевой конфигурации</AlertTitle>
<AlertDescription>
Пиры привязаны к спикерам (BIRD-агентам). Apply запускает применение ревизии на ноде. Полный
список ревизий и задач — на странице
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>.
</AlertDescription>
</Alert>
<div class="grid gap-4 sm:grid-cols-3">
{#if initialLoading}
{#each Array(3) 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">
<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>
<CardTitle class="text-3xl font-bold tabular-nums">{card.value}</CardTitle>
</CardHeader>
<CardContent class="space-y-2">
<Badge variant="outline" class={card.badgeClass}>{card.badge}</Badge>
<p class="text-xs text-muted-foreground">{card.description}</p>
</CardContent>
</Card>
{/each}
{/if}
</div>
<Tabs value="peers">
<TabsList>
@@ -252,260 +216,25 @@
<TabsTrigger value="speakers">Спикеры</TabsTrigger>
</TabsList>
<!-- Peers -->
<TabsContent value="peers" class="mt-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle class="text-base">BGP-пиры</CardTitle>
<CardDescription>Настройка BGP-соседей</CardDescription>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={loadAll} disabled={peersLoading}>
<RefreshCw class={peersLoading ? 'animate-spin' : ''} />
</Button>
<Button size="sm" onclick={openPeerCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Имя</TableHead>
<TableHead>Адрес</TableHead>
<TableHead>Remote ASN</TableHead>
<TableHead class="w-[4.5rem] text-center">Вкл.</TableHead>
<TableHead>Состояние сессии</TableHead>
<TableHead>Спикер</TableHead>
<TableHead class="w-20"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each peers as p (p.id)}
<TableRow>
<TableCell>{p.name?.trim() || '—'}</TableCell>
<TableCell class="font-mono">{p.neighbor}</TableCell>
<TableCell class="font-mono">{p.remote_asn ?? '—'}</TableCell>
<TableCell class="text-center">
<Switch
size="sm"
checked={p.enabled !== false}
disabled={peersLoading || peerToggleId === p.id}
onCheckedChange={(v) => setPeerEnabled(p, v)}
/>
</TableCell>
<TableCell>
<Badge variant={sessionBadge(p.session_state)}>{p.session_state || '—'}</Badge>
</TableCell>
<TableCell class="text-xs text-muted-foreground"
>{speakerLabelById(p.bgp_speaker_id)}</TableCell
>
<TableCell>
<div class="flex gap-1">
<Button variant="ghost" size="icon-sm" onclick={() => openPeerEdit(p)}
><Pencil class="size-3.5" /></Button
>
<Button
variant="ghost"
size="icon-sm"
class="text-destructive"
onclick={() => (peerDeleteTarget = p)}><Trash2 class="size-3.5" /></Button
>
</div>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={7} class="text-muted-foreground text-center py-8">
{peersLoading ? 'Загрузка…' : 'Нет пиров.'}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
<NetworkPeersCard
items={peers}
{speakers}
loading={peersLoading}
{initialLoading}
error={loadError}
onRefresh={refreshPeers}
/>
</TabsContent>
<!-- Speakers -->
<TabsContent value="speakers" class="mt-4">
<Card>
<CardHeader class="flex flex-row items-center justify-between pb-2">
<div>
<CardTitle class="text-base">Спикеры</CardTitle>
<CardDescription>BIRD-агенты, применяющие конфигурацию</CardDescription>
</div>
<div class="flex gap-2">
<Button variant="outline" size="sm" onclick={loadAll} disabled={speakersLoading}>
<RefreshCw class={speakersLoading ? 'animate-spin' : ''} />
</Button>
<Button size="sm" onclick={openSpeakerCreate}><Plus />Добавить</Button>
</div>
</CardHeader>
<CardContent class="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Endpoint</TableHead>
<TableHead>Роль</TableHead>
<TableHead>Последняя ревизия</TableHead>
<TableHead class="w-32"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{#each speakers as s (s.id)}
<TableRow>
<TableCell class="font-mono text-sm">{s.endpoint}</TableCell>
<TableCell><Badge variant="outline">{s.role}</Badge></TableCell>
<TableCell class="font-mono text-xs text-muted-foreground"
>{s.last_applied_revision_id
? s.last_applied_revision_id.slice(0, 8) + '…'
: '—'}</TableCell
>
<TableCell>
<div class="flex gap-1">
<Button
variant="outline"
size="xs"
title="Запустить применение ревизии на спикере"
onclick={() => applySpeaker(s.id)}
disabled={applyingId === s.id}
>
<Play class="size-3" />
{applyingId === s.id ? 'Apply…' : 'Apply'}
</Button>
<Button variant="ghost" size="icon-sm" onclick={() => openSpeakerEdit(s)}
><Pencil class="size-3.5" /></Button
>
</div>
</TableCell>
</TableRow>
{:else}
<TableRow>
<TableCell colspan={4} class="text-muted-foreground text-center py-8">
{speakersLoading ? 'Загрузка…' : 'Нет спикеров.'}
</TableCell>
</TableRow>
{/each}
</TableBody>
</Table>
</CardContent>
</Card>
<NetworkSpeakersCard
items={speakers}
loading={speakersLoading}
{initialLoading}
error={loadError}
onRefresh={refreshSpeakers}
/>
</TabsContent>
</Tabs>
</div>
<!-- Peer Dialog -->
<Dialog bind:open={peerDialog}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{peerEdit ? 'Редактировать пира' : 'Новый пир'}</DialogTitle>
<DialogDescription>BGP-сосед для установки сессии</DialogDescription>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="p-name">Имя пира (опционально)</Label>
<Input id="p-name" placeholder="Core-RTR-1" bind:value={peerForm.name} />
</div>
<div class="space-y-1.5">
<Label for="p-neighbor">Адрес соседа</Label>
<Input id="p-neighbor" placeholder="192.0.2.1" bind:value={peerForm.neighbor} />
</div>
<div class="space-y-1.5">
<Label for="p-asn">Remote ASN</Label>
<Input id="p-asn" type="number" placeholder="65000" bind:value={peerForm.remote_asn} />
</div>
<div class="space-y-1.5">
<Label for="p-speaker">Спикер (опционально)</Label>
<Select
type="single"
value={peerForm.bgp_speaker_id ?? ''}
onValueChange={(v) => {
peerForm = { ...peerForm, bgp_speaker_id: v || null };
}}
>
<SelectTrigger id="p-speaker" class="w-full">
{peerForm.bgp_speaker_id ? speakerLabelById(peerForm.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>
</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="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={peerForm.enabled !== false}
onCheckedChange={(v) => {
peerForm = { ...peerForm, enabled: v };
}}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (peerDialog = false)}>Отмена</Button>
<Button onclick={savePeer} disabled={peerSaving}
>{peerSaving ? 'Сохранение…' : peerEdit ? 'Сохранить' : 'Создать'}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog
open={!!peerDeleteTarget}
onOpenChange={(v) => {
if (!v) peerDeleteTarget = null;
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Удалить пира?</AlertDialogTitle>
<AlertDialogDescription>{peerDeleteTarget?.neighbor}</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onclick={() => (peerDeleteTarget = null)}>Отмена</AlertDialogCancel>
<AlertDialogAction onclick={deletePeer}>Удалить</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<!-- Speaker Dialog -->
<Dialog bind:open={speakerDialog}>
<DialogContent class="sm:max-w-sm">
<DialogHeader>
<DialogTitle>{speakerEdit ? 'Редактировать спикера' : 'Новый спикер'}</DialogTitle>
</DialogHeader>
<div class="space-y-4 py-2">
<div class="space-y-1.5">
<Label for="s-endpoint">Endpoint</Label>
<Input
id="s-endpoint"
placeholder="http://bird-agent:8081"
bind:value={speakerForm.endpoint}
/>
</div>
<div class="space-y-1.5">
<Label for="s-role">Роль</Label>
<Input id="s-role" placeholder="operator" bind:value={speakerForm.role} />
</div>
</div>
<DialogFooter>
<Button variant="outline" onclick={() => (speakerDialog = false)}>Отмена</Button>
<Button onclick={saveSpeaker} disabled={speakerSaving}
>{speakerSaving ? 'Сохранение…' : speakerEdit ? 'Сохранить' : 'Создать'}</Button
>
</DialogFooter>
</DialogContent>
</Dialog>