Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c66cc9317d | ||
|
|
9ea4a68ccf | ||
|
|
8204105fd6 |
@@ -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.
|
||||||
@@ -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)
|
## Обязательный запуск скрипта (MUST)
|
||||||
|
|
||||||
@@ -41,15 +47,74 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
|
|||||||
|
|
||||||
| type | Когда | Версия |
|
| type | Когда | Версия |
|
||||||
|------|--------|--------|
|
|------|--------|--------|
|
||||||
| `feat` | новая функциональность | minor |
|
| `feat` | **новая** пользовательская возможность (раньше нельзя было) | minor |
|
||||||
| `fix` | исправление бага | patch |
|
| `fix` | восстановление **ожидаемого** поведения; баг, регрессия, падение UI | patch |
|
||||||
| `perf` | ускорение без смены API | patch |
|
| `perf` | ускорение без смены API | patch |
|
||||||
| `refactor` | реструктуризация без смены поведения | — |
|
| `refactor` | реструктуризация **без** новой возможности и **без** исправления бага | — |
|
||||||
| `docs` | только документация | — |
|
| `docs` | только документация | — |
|
||||||
| `test` | тесты | — |
|
| `test` | тесты | — |
|
||||||
| `ci` | CI/CD (`.gitea/`, workflows) | — |
|
| `ci` | CI/CD (`.gitea/`, workflows) | — |
|
||||||
| `chore` | обслуживание, deps, `.cursor/` | — |
|
| `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
|
## Breaking changes
|
||||||
|
|
||||||
- Заголовок: `feat!` / `fix!` **или** в теле строка `BREAKING CHANGE:` (на английском ключевое слово) + описание impact **на русском**.
|
- Заголовок: `feat!` / `fix!` **или** в теле строка `BREAKING CHANGE:` (на английском ключевое слово) + описание impact **на русском**.
|
||||||
@@ -96,7 +161,7 @@ powershell -NoProfile -File scripts/commit/staged-context.ps1
|
|||||||
<тело RU>
|
<тело RU>
|
||||||
```
|
```
|
||||||
|
|
||||||
**2. Пояснение (RU):** почему выбран type; риск/impact; был ли split.
|
**2. Пояснение (RU):** semver impact (`minor`|`patch`|`none`|`major`); почему выбран type; риск/impact; был ли split.
|
||||||
|
|
||||||
## Примеры
|
## Примеры
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,14 @@
|
|||||||
name: commit-message
|
name: commit-message
|
||||||
description: >-
|
description: >-
|
||||||
ОБЯЗАТЕЛЬНО при commit, коммит, закоммить, commit message, conventional commit,
|
ОБЯЗАТЕЛЬНО при commit, коммит, закоммить, commit message, conventional commit,
|
||||||
staged, semantic-release, «сгенерируй коммит», git commit: ПЕРВЫМ делом Shell —
|
staged, semantic-release, «сгенерируй коммит», git commit, /commit-message: ПЕРВЫМ
|
||||||
scripts/commit/staged-context.ps1; затем Conventional Commit (заголовок EN, тело RU).
|
делом 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)
|
# Commit message (EvoBGP)
|
||||||
|
|
||||||
## Когда применять (сразу читать этот скилл)
|
## Когда применять (сразу читать этот скилл)
|
||||||
@@ -90,9 +94,24 @@ git commit -m "$( @'
|
|||||||
|
|
||||||
По `groups[].diff_excerpt`, `stat`, `files`:
|
По `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.
|
- **scope** — из JSON группы или доминирующий при merge.
|
||||||
- **summary** — конкретный, английский, императив.
|
- **summary** — конкретный, английский, императив; для `fix` — что **починено** (`fix broken …`, `prevent … loop`).
|
||||||
- **body** — русский: что, зачем, edge cases, breaking impact.
|
- **body** — русский: что, зачем, edge cases, breaking impact.
|
||||||
|
|
||||||
## Вывод пользователю
|
## Вывод пользователю
|
||||||
@@ -109,7 +128,7 @@ git commit -m "$( @'
|
|||||||
|
|
||||||
### 2. Пояснение (RU)
|
### 2. Пояснение (RU)
|
||||||
|
|
||||||
- Почему выбран type/scope.
|
- **Semver impact:** `minor` | `patch` | `none` | `major` — и почему не другой type.
|
||||||
- Риски и impact.
|
- Риски и impact.
|
||||||
- Split: сколько коммитов и почему.
|
- Split: сколько коммитов и почему.
|
||||||
|
|
||||||
|
|||||||
@@ -38,13 +38,15 @@
|
|||||||
|
|
||||||
## Коммиты (Conventional Commits)
|
## Коммиты (Conventional Commits)
|
||||||
|
|
||||||
Если пользователь просит **коммит**, **commit message**, **закоммить**, **git commit** или это следует из плана — **сразу**:
|
Если пользователь просит **коммит**, **commit message**, **закоммить**, **git commit**, **`/commit-message`** или это следует из плана — **сразу**:
|
||||||
|
|
||||||
1. Shell: `powershell -NoProfile -File scripts/commit/staged-context.ps1` (первый вызов, до текста коммита).
|
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).
|
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 (скилл).
|
Без вывода скрипта (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`.
|
- Консоль пользователя: **PowerShell**; пути в стиле `deploy\compose`.
|
||||||
|
|||||||
+10
-1
@@ -36,7 +36,16 @@
|
|||||||
|
|
||||||
### Сообщения коммитов (Cursor)
|
### Сообщения коммитов (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
|
## Репозиторий и CI
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ EvoBGP использует [Conventional Commits](https://www.conventionalcommi
|
|||||||
|
|
||||||
Первый релиз при отсутствии git-тегов — **1.0.0**, если есть releasable-коммиты.
|
Первый релиз при отсутствии 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).
|
Подробные правила сообщений коммитов: [.cursor/rules/conventional-commits.mdc](../.cursor/rules/conventional-commits.mdc).
|
||||||
|
|
||||||
## CI-пайплайн (один push в main)
|
## 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 csvExporting = $state(false);
|
||||||
let csvFileInput = $state<HTMLInputElement | null>(null);
|
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const selectedCount = $derived(selectedIds.size);
|
const activeSelected = $derived.by(() => {
|
||||||
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
|
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 = [
|
const columns = [
|
||||||
{ id: 'select', label: '', class: 'w-10' },
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
@@ -75,11 +80,6 @@
|
|||||||
{ id: 'actions', label: '', class: 'w-20' }
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
] as const;
|
] 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) {
|
function toggleSelection(id: string) {
|
||||||
const next = new Set(selectedIds);
|
const next = new Set(selectedIds);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
deletingBulk = true;
|
deletingBulk = true;
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
try {
|
try {
|
||||||
for (const id of selectedIds) {
|
for (const id of activeSelected) {
|
||||||
try {
|
try {
|
||||||
await apiMutate(`/v1/modules/${moduleId}/as-entries/${id}`, 'DELETE', undefined, {
|
await apiMutate(`/v1/modules/${moduleId}/as-entries/${id}`, 'DELETE', undefined, {
|
||||||
idempotent: false
|
idempotent: false
|
||||||
|
|||||||
@@ -35,12 +35,23 @@
|
|||||||
|
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let form = $state<AsEntryCreate>({ asn: 0, community_id: null });
|
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(() => {
|
$effect(() => {
|
||||||
if (open) {
|
if (!open) {
|
||||||
form = edit
|
initKey = '';
|
||||||
? { asn: edit.asn, community_id: edit.community_id }
|
return;
|
||||||
: { asn: 0, community_id: null };
|
}
|
||||||
|
const nextKey = edit?.id ?? 'new';
|
||||||
|
if (nextKey !== initKey) {
|
||||||
|
initKey = nextKey;
|
||||||
|
resetForm();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -75,7 +86,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog {open} onOpenChange={handleOpenChange}>
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
<DialogContent class="sm:max-w-sm">
|
<DialogContent class="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
|
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
|
||||||
|
|||||||
@@ -51,6 +51,7 @@
|
|||||||
prefix_path: '',
|
prefix_path: '',
|
||||||
community_id: null
|
community_id: null
|
||||||
});
|
});
|
||||||
|
let initKey = $state('');
|
||||||
|
|
||||||
function clearPreview() {
|
function clearPreview() {
|
||||||
previewLoading = false;
|
previewLoading = false;
|
||||||
@@ -61,18 +62,28 @@
|
|||||||
previewOk = false;
|
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(() => {
|
$effect(() => {
|
||||||
if (open) {
|
if (!open) {
|
||||||
clearPreview();
|
initKey = '';
|
||||||
form = edit
|
return;
|
||||||
? {
|
}
|
||||||
url: edit.url,
|
const nextKey = edit?.id ?? 'new';
|
||||||
source_kind: normalizeCdnSourceKind(edit.source_kind),
|
if (nextKey !== initKey) {
|
||||||
prefix_path: edit.prefix_path ?? '',
|
initKey = nextKey;
|
||||||
community_id: edit.community_id,
|
resetForm();
|
||||||
refresh_interval_sec: edit.refresh_interval_sec
|
|
||||||
}
|
|
||||||
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null };
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -150,7 +161,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog {open} onOpenChange={handleOpenChange}>
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
<DialogContent class="sm:max-w-lg">
|
<DialogContent class="sm:max-w-lg">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
|
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
|
||||||
|
|||||||
@@ -39,8 +39,12 @@
|
|||||||
let selectedIds = $state(new Set<string>());
|
let selectedIds = $state(new Set<string>());
|
||||||
let deletingBulk = $state(false);
|
let deletingBulk = $state(false);
|
||||||
|
|
||||||
const selectedCount = $derived(selectedIds.size);
|
const activeSelected = $derived.by(() => {
|
||||||
const allSelected = $derived(sources.length > 0 && selectedIds.size === sources.length);
|
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 = [
|
const columns = [
|
||||||
{ id: 'select', label: '', class: 'w-10' },
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
@@ -62,11 +66,6 @@
|
|||||||
{ id: 'actions', label: '', class: 'w-20' }
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
] as const;
|
] 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) {
|
function toggleSelection(id: string) {
|
||||||
const next = new Set(selectedIds);
|
const next = new Set(selectedIds);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
@@ -120,7 +119,7 @@
|
|||||||
deletingBulk = true;
|
deletingBulk = true;
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
try {
|
try {
|
||||||
for (const id of selectedIds) {
|
for (const id of activeSelected) {
|
||||||
try {
|
try {
|
||||||
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${id}`, 'DELETE', undefined, {
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${id}`, 'DELETE', undefined, {
|
||||||
idempotent: false
|
idempotent: false
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog {open} onOpenChange={handleOpenChange}>
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
<DialogContent class="sm:max-w-md">
|
<DialogContent class="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Новый модуль</DialogTitle>
|
<DialogTitle>Новый модуль</DialogTitle>
|
||||||
|
|||||||
@@ -39,15 +39,15 @@
|
|||||||
<Badge variant={moduleEnabledBadgeVariant(!!mod.enabled)} class="text-xs">
|
<Badge variant={moduleEnabledBadgeVariant(!!mod.enabled)} class="text-xs">
|
||||||
{moduleEnabledRu(!!mod.enabled)}
|
{moduleEnabledRu(!!mod.enabled)}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button variant="outline" size="sm" onclick={onRefresh} disabled={refreshing}>
|
<Button variant="outline" size="sm" onclick={() => onRefresh()} disabled={refreshing}>
|
||||||
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onclick={onEdit}>
|
<Button variant="outline" size="sm" onclick={() => onEdit()}>
|
||||||
<Pencil />
|
<Pencil />
|
||||||
Редактировать
|
Редактировать
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" size="sm" onclick={onDelete}>
|
<Button variant="destructive" size="sm" onclick={() => onDelete()}>
|
||||||
<Trash2 />
|
<Trash2 />
|
||||||
Удалить
|
Удалить
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -44,8 +44,12 @@
|
|||||||
let csvExporting = $state(false);
|
let csvExporting = $state(false);
|
||||||
let csvFileInput = $state<HTMLInputElement | null>(null);
|
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const selectedCount = $derived(selectedIds.size);
|
const activeSelected = $derived.by(() => {
|
||||||
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
|
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 = [
|
const columns = [
|
||||||
{ id: 'select', label: '', class: 'w-10' },
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
@@ -54,11 +58,6 @@
|
|||||||
{ id: 'actions', label: '', class: 'w-20' }
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
] as const;
|
] 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) {
|
function toggleSelection(id: string) {
|
||||||
const next = new Set(selectedIds);
|
const next = new Set(selectedIds);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
@@ -112,7 +111,7 @@
|
|||||||
deletingBulk = true;
|
deletingBulk = true;
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
try {
|
try {
|
||||||
for (const id of selectedIds) {
|
for (const id of activeSelected) {
|
||||||
try {
|
try {
|
||||||
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${id}`, 'DELETE', undefined, {
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${id}`, 'DELETE', undefined, {
|
||||||
idempotent: false
|
idempotent: false
|
||||||
|
|||||||
@@ -34,12 +34,23 @@
|
|||||||
|
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let form = $state<DomainEntryCreate>({ fqdn: '', community_id: null });
|
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(() => {
|
$effect(() => {
|
||||||
if (open) {
|
if (!open) {
|
||||||
form = edit
|
initKey = '';
|
||||||
? { fqdn: edit.fqdn, community_id: edit.community_id }
|
return;
|
||||||
: { fqdn: '', community_id: null };
|
}
|
||||||
|
const nextKey = edit?.id ?? 'new';
|
||||||
|
if (nextKey !== initKey) {
|
||||||
|
initKey = nextKey;
|
||||||
|
resetForm();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -68,7 +79,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog {open} onOpenChange={handleOpenChange}>
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
<DialogContent class="sm:max-w-sm">
|
<DialogContent class="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
|
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
|
||||||
|
|||||||
@@ -71,19 +71,30 @@
|
|||||||
|
|
||||||
let editForm = $state<ModulePatch>({});
|
let editForm = $state<ModulePatch>({});
|
||||||
let editSaving = $state(false);
|
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(() => {
|
$effect(() => {
|
||||||
if (open && mod) {
|
if (!open) {
|
||||||
editForm = {
|
initKey = '';
|
||||||
name: mod.name,
|
return;
|
||||||
enabled: mod.enabled,
|
}
|
||||||
priority: mod.priority,
|
const nextKey = mod.id;
|
||||||
refresh_interval_sec: mod.refresh_interval_sec,
|
if (nextKey !== initKey) {
|
||||||
cron_expr: mod.cron_expr,
|
initKey = nextKey;
|
||||||
default_community_id: mod.default_community_id,
|
resetEditForm();
|
||||||
doh_profile_ids: moduleDohProfileIds(mod),
|
|
||||||
doh_resolver_policy: mod.doh_resolver_policy ?? 'primary_only'
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -136,7 +147,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog {open} onOpenChange={handleOpenChange}>
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
<DialogContent class="sm:max-w-md">
|
<DialogContent class="sm:max-w-md">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Редактировать модуль</DialogTitle>
|
<DialogTitle>Редактировать модуль</DialogTitle>
|
||||||
|
|||||||
@@ -28,12 +28,23 @@
|
|||||||
|
|
||||||
let saving = $state(false);
|
let saving = $state(false);
|
||||||
let form = $state<IpRangeEntryCreate>({ prefix: '', community_id: '' });
|
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(() => {
|
$effect(() => {
|
||||||
if (open) {
|
if (!open) {
|
||||||
form = edit
|
initKey = '';
|
||||||
? { prefix: edit.prefix, community_id: edit.community_id }
|
return;
|
||||||
: { prefix: '', community_id: '' };
|
}
|
||||||
|
const nextKey = edit?.id ?? 'new';
|
||||||
|
if (nextKey !== initKey) {
|
||||||
|
initKey = nextKey;
|
||||||
|
resetForm();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -62,7 +73,7 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Dialog {open} onOpenChange={handleOpenChange}>
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
<DialogContent class="sm:max-w-sm">
|
<DialogContent class="sm:max-w-sm">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
|
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
|
||||||
|
|||||||
@@ -44,8 +44,12 @@
|
|||||||
let csvExporting = $state(false);
|
let csvExporting = $state(false);
|
||||||
let csvFileInput = $state<HTMLInputElement | null>(null);
|
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const selectedCount = $derived(selectedIds.size);
|
const activeSelected = $derived.by(() => {
|
||||||
const allSelected = $derived(entries.length > 0 && selectedIds.size === entries.length);
|
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 = [
|
const columns = [
|
||||||
{ id: 'select', label: '', class: 'w-10' },
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
@@ -59,11 +63,6 @@
|
|||||||
{ id: 'actions', label: '', class: 'w-20' }
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
] as const;
|
] 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) {
|
function toggleSelection(id: string) {
|
||||||
const next = new Set(selectedIds);
|
const next = new Set(selectedIds);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
@@ -120,7 +119,7 @@
|
|||||||
deletingBulk = true;
|
deletingBulk = true;
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
try {
|
try {
|
||||||
for (const id of selectedIds) {
|
for (const id of activeSelected) {
|
||||||
try {
|
try {
|
||||||
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${id}`, 'DELETE', undefined, {
|
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${id}`, 'DELETE', undefined, {
|
||||||
idempotent: false
|
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);
|
const open = $derived(!!state?.open);
|
||||||
|
|
||||||
function onOpenChange(v: boolean) {
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -37,7 +42,10 @@
|
|||||||
? 'text-destructive-foreground bg-destructive hover:bg-destructive/90'
|
? 'text-destructive-foreground bg-destructive hover:bg-destructive/90'
|
||||||
: ''}
|
: ''}
|
||||||
disabled={state.loading}
|
disabled={state.loading}
|
||||||
onclick={state.onConfirm}
|
onclick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
void handleConfirm();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{state.loading ? '…' : (state.confirmLabel ?? 'Подтвердить')}
|
{state.loading ? '…' : (state.confirmLabel ?? 'Подтвердить')}
|
||||||
</AlertDialogAction>
|
</AlertDialogAction>
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ export function confirm(options: ConfirmOptions) {
|
|||||||
open: true,
|
open: true,
|
||||||
loading: false,
|
loading: false,
|
||||||
onConfirm: async () => {
|
onConfirm: async () => {
|
||||||
if (!confirmState.current) return;
|
const current = confirmState.current;
|
||||||
confirmState.current = { ...confirmState.current, loading: true };
|
if (!current || current.loading) return;
|
||||||
|
confirmState.current = { ...current, loading: true };
|
||||||
try {
|
try {
|
||||||
await options.onConfirm();
|
await options.onConfirm();
|
||||||
resolve(true);
|
resolve(true);
|
||||||
|
|||||||
@@ -1,219 +1,221 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
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 {
|
import type {
|
||||||
BgpCommunity,
|
BgpCommunity,
|
||||||
BgpCommunityCreate,
|
|
||||||
CommunitiesResponse,
|
CommunitiesResponse,
|
||||||
DohProfile,
|
DohProfile,
|
||||||
DohProfileCreate,
|
|
||||||
DohProfilesResponse
|
DohProfilesResponse
|
||||||
} from '$lib/api/types.js';
|
} 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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/components/ui/card/index.js';
|
} from '$lib/ui/core/card/index.js';
|
||||||
import FormField from '$lib/ui/patterns/form/form-field.svelte';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
import AppInput from '$lib/ui/patterns/form/app-input.svelte';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
|
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||||
import {
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
Dialog,
|
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
DialogContent,
|
import DirectoriesCommunitiesCard from '$lib/components/directories/DirectoriesCommunitiesCard.svelte';
|
||||||
DialogHeader,
|
import DirectoriesDohProfilesCard from '$lib/components/directories/DirectoriesDohProfilesCard.svelte';
|
||||||
DialogTitle,
|
import { cn } from '$lib/utils.js';
|
||||||
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';
|
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import BookOpen from '@lucide/svelte/icons/book-open';
|
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 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 dohProfiles = $state<DohProfile[]>([]);
|
||||||
let dohLoading = $state(false);
|
let loading = $state(false);
|
||||||
let dohDialog = $state(false);
|
let initialLoading = $state(true);
|
||||||
let dohEdit = $state<DohProfile | null>(null);
|
let loadError = $state<string | null>(null);
|
||||||
let dohForm = $state<DohProfileCreate & { timeout_ms?: number | null }>({
|
let lastUpdated = $state<Date | null>(null);
|
||||||
url: '',
|
|
||||||
timeout_ms: null,
|
|
||||||
vault_secret_ref: null
|
|
||||||
});
|
|
||||||
let dohSaving = $state(false);
|
|
||||||
let dohDeleteTarget = $state<DohProfile | null>(null);
|
|
||||||
|
|
||||||
async function loadComm() {
|
const statAccents = [
|
||||||
commLoading = true;
|
{
|
||||||
try {
|
border: 'border-l-chart-2',
|
||||||
const r = await apiJSON<CommunitiesResponse>('/v1/communities?limit=200');
|
bg: 'bg-chart-2/5',
|
||||||
communities = r.items;
|
iconBg: 'bg-chart-2/15',
|
||||||
} catch (e) {
|
iconText: 'text-chart-2'
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
},
|
||||||
} finally {
|
{
|
||||||
commLoading = false;
|
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() {
|
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 {
|
try {
|
||||||
const r = await apiJSON<DohProfilesResponse>('/v1/doh-profiles?limit=200');
|
await Promise.all([loadCommunities(), loadDoh()]);
|
||||||
dohProfiles = r.items;
|
lastUpdated = new Date();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
dohLoading = false;
|
loading = false;
|
||||||
|
initialLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
async function refreshCommunities() {
|
||||||
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;
|
|
||||||
try {
|
try {
|
||||||
const body = { ...commForm, title: commForm.title?.trim() || undefined };
|
loadError = null;
|
||||||
if (commEdit) {
|
await loadCommunities();
|
||||||
await apiMutate(`/v1/communities/${commEdit.id}`, 'PATCH', body);
|
lastUpdated = new Date();
|
||||||
toast.success('Запись сообщества обновлена');
|
|
||||||
} else {
|
|
||||||
await apiMutate('/v1/communities', 'POST', body);
|
|
||||||
toast.success('Сообщество создано');
|
|
||||||
}
|
|
||||||
commDialog = false;
|
|
||||||
await loadComm();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
} finally {
|
notifyApiError(e);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DoH actions ---
|
async function refreshDoh() {
|
||||||
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;
|
|
||||||
try {
|
try {
|
||||||
if (dohEdit) {
|
loadError = null;
|
||||||
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;
|
|
||||||
await loadDoh();
|
await loadDoh();
|
||||||
|
lastUpdated = new Date();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
} finally {
|
notifyApiError(e);
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Справочники"
|
title="Справочники"
|
||||||
description="Сообщества BGP и DoH-профили для резолвинга доменов."
|
description={lastUpdated
|
||||||
|
? `Сообщества BGP и DoH-профили для резолвинга доменов. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||||
|
: 'Сообщества BGP и DoH-профили для резолвинга доменов.'}
|
||||||
icon={BookOpen}
|
icon={BookOpen}
|
||||||
iconClass="bg-chart-2/15 text-chart-2"
|
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">
|
<Tabs value="communities">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
@@ -221,215 +223,24 @@
|
|||||||
<TabsTrigger value="doh">DoH профили</TabsTrigger>
|
<TabsTrigger value="doh">DoH профили</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<!-- Communities -->
|
|
||||||
<TabsContent value="communities" class="mt-4">
|
<TabsContent value="communities" class="mt-4">
|
||||||
<Card>
|
<DirectoriesCommunitiesCard
|
||||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
items={communities}
|
||||||
<div>
|
{loading}
|
||||||
<CardTitle class="text-base">Сообщества BGP</CardTitle>
|
{initialLoading}
|
||||||
<CardDescription>Используются для тегирования префиксов</CardDescription>
|
error={loadError}
|
||||||
</div>
|
onRefresh={refreshCommunities}
|
||||||
<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>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- DoH Profiles -->
|
|
||||||
<TabsContent value="doh" class="mt-4">
|
<TabsContent value="doh" class="mt-4">
|
||||||
<Card>
|
<DirectoriesDohProfilesCard
|
||||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
items={dohProfiles}
|
||||||
<div>
|
{loading}
|
||||||
<CardTitle class="text-base">DoH профили</CardTitle>
|
{initialLoading}
|
||||||
<CardDescription>DNS-over-HTTPS серверы для резолвинга доменных модулей</CardDescription
|
error={loadError}
|
||||||
>
|
onRefresh={refreshDoh}
|
||||||
</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>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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
@@ -1,250 +1,214 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
import { resolve } from '$app/paths';
|
||||||
import type {
|
import { apiJSON } from '$lib/api/client.js';
|
||||||
PeerRow,
|
import type { PeerRow, PeersResponse, SpeakerRow, SpeakersResponse } from '$lib/api/types.js';
|
||||||
BgpPeerCreate,
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
PeersResponse,
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
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 {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
CardDescription
|
CardDescription
|
||||||
} from '$lib/components/ui/card/index.js';
|
} from '$lib/ui/core/card/index.js';
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||||
import {
|
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||||
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';
|
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
|
import { notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
// --- Peers ---
|
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 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 speakers = $state<SpeakerRow[]>([]);
|
||||||
|
let peersLoading = $state(false);
|
||||||
let speakersLoading = $state(false);
|
let speakersLoading = $state(false);
|
||||||
let speakerDialog = $state(false);
|
let initialLoading = $state(true);
|
||||||
let speakerEdit = $state<SpeakerRow | null>(null);
|
let loadError = $state<string | null>(null);
|
||||||
let speakerForm = $state<BgpSpeakerCreate>({ endpoint: '', role: 'operator' });
|
let lastUpdated = $state<Date | null>(null);
|
||||||
let speakerSaving = $state(false);
|
|
||||||
let applyingId = $state<string | null>(null);
|
|
||||||
const speakerById = $derived.by(() => new Map(speakers.map((s) => [s.id, s])));
|
|
||||||
|
|
||||||
function speakerLabelById(id: string | null | undefined) {
|
const establishedCount = $derived(peers.filter((p) => p.session_state === 'Established').length);
|
||||||
if (!id) return '—';
|
|
||||||
return speakerById.get(id)?.endpoint ?? id;
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
peersLoading = true;
|
||||||
speakersLoading = true;
|
|
||||||
try {
|
try {
|
||||||
const [pr, sr] = await Promise.all([
|
const pr = await apiJSON<PeersResponse>('/v1/peers?limit=200');
|
||||||
apiJSON<PeersResponse>('/v1/peers?limit=200'),
|
|
||||||
apiJSON<SpeakersResponse>('/v1/speakers?limit=200')
|
|
||||||
]);
|
|
||||||
peers = pr.items;
|
peers = pr.items;
|
||||||
speakers = sr.items;
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
loadError = e instanceof Error ? e.message : String(e);
|
||||||
|
notifyApiError(e);
|
||||||
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
peersLoading = false;
|
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;
|
speakersLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(loadAll);
|
async function load() {
|
||||||
|
loadError = null;
|
||||||
// --- 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;
|
|
||||||
try {
|
try {
|
||||||
await apiMutate(`/v1/peers/${p.id}`, 'PATCH', { enabled });
|
await Promise.all([loadPeers(), loadSpeakers()]);
|
||||||
toast.success(enabled ? 'Пир включён' : 'Пир отключён');
|
lastUpdated = new Date();
|
||||||
await loadAll();
|
} catch {
|
||||||
} catch (e) {
|
// errors handled in loaders
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
|
||||||
} finally {
|
} finally {
|
||||||
peerToggleId = null;
|
initialLoading = false;
|
||||||
}
|
|
||||||
}
|
|
||||||
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));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Speaker actions ---
|
async function refreshPeers() {
|
||||||
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;
|
|
||||||
try {
|
try {
|
||||||
if (speakerEdit) {
|
await loadPeers();
|
||||||
await apiMutate(`/v1/speakers/${speakerEdit.id}`, 'PATCH', speakerForm);
|
lastUpdated = new Date();
|
||||||
toast.success('Спикер обновлён');
|
} catch {
|
||||||
} else {
|
// notifyApiError in loadPeers
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function sessionBadge(state: string) {
|
async function refreshSpeakers() {
|
||||||
if (state === 'Established') return 'default';
|
try {
|
||||||
if (state === 'Active' || state === 'Connect') return 'secondary';
|
await loadSpeakers();
|
||||||
return 'outline';
|
lastUpdated = new Date();
|
||||||
|
} catch {
|
||||||
|
// notifyApiError in loadSpeakers
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMount(load);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Сеть"
|
title="Сеть"
|
||||||
description="BGP-пиры и спикеры (BIRD-агенты)."
|
description={lastUpdated
|
||||||
|
? `BGP-пиры и спикеры (BIRD-агенты). Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||||
|
: 'BGP-пиры и спикеры (BIRD-агенты).'}
|
||||||
icon={NetworkIcon}
|
icon={NetworkIcon}
|
||||||
iconClass="bg-chart-3/15 text-chart-3"
|
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">
|
<Tabs value="peers">
|
||||||
<TabsList>
|
<TabsList>
|
||||||
@@ -252,260 +216,25 @@
|
|||||||
<TabsTrigger value="speakers">Спикеры</TabsTrigger>
|
<TabsTrigger value="speakers">Спикеры</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<!-- Peers -->
|
|
||||||
<TabsContent value="peers" class="mt-4">
|
<TabsContent value="peers" class="mt-4">
|
||||||
<Card>
|
<NetworkPeersCard
|
||||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
items={peers}
|
||||||
<div>
|
{speakers}
|
||||||
<CardTitle class="text-base">BGP-пиры</CardTitle>
|
loading={peersLoading}
|
||||||
<CardDescription>Настройка BGP-соседей</CardDescription>
|
{initialLoading}
|
||||||
</div>
|
error={loadError}
|
||||||
<div class="flex gap-2">
|
onRefresh={refreshPeers}
|
||||||
<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>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<!-- Speakers -->
|
|
||||||
<TabsContent value="speakers" class="mt-4">
|
<TabsContent value="speakers" class="mt-4">
|
||||||
<Card>
|
<NetworkSpeakersCard
|
||||||
<CardHeader class="flex flex-row items-center justify-between pb-2">
|
items={speakers}
|
||||||
<div>
|
loading={speakersLoading}
|
||||||
<CardTitle class="text-base">Спикеры</CardTitle>
|
{initialLoading}
|
||||||
<CardDescription>BIRD-агенты, применяющие конфигурацию</CardDescription>
|
error={loadError}
|
||||||
</div>
|
onRefresh={refreshSpeakers}
|
||||||
<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>
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</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>
|
|
||||||
|
|||||||
Reference in New Issue
Block a user