Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c66cc9317d | ||
|
|
9ea4a68ccf | ||
|
|
8204105fd6 | ||
|
|
0c11ecfa48 | ||
|
|
ab8660ac42 | ||
|
|
ce747673fd | ||
|
|
e558570967 |
@@ -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>
|
||||||
@@ -0,0 +1,342 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiFetch, apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { AsEntry, BgpCommunity, ModuleRow } from '$lib/api/types.js';
|
||||||
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
sanitizeFilenamePart,
|
||||||
|
supportsCsvIO
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
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 ModuleAsEntryDialog from '$lib/components/modules/ModuleAsEntryDialog.svelte';
|
||||||
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
|
import Download from '@lucide/svelte/icons/download';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
moduleId: string;
|
||||||
|
mod: ModuleRow;
|
||||||
|
entries: AsEntry[];
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
loading?: boolean;
|
||||||
|
onChanged: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
|
||||||
|
|
||||||
|
let dialogOpen = $state(false);
|
||||||
|
let editTarget = $state<AsEntry | null>(null);
|
||||||
|
let selectedIds = $state(new Set<string>());
|
||||||
|
let deletingBulk = $state(false);
|
||||||
|
let csvImporting = $state(false);
|
||||||
|
let csvExporting = $state(false);
|
||||||
|
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const activeSelected = $derived.by(() => {
|
||||||
|
const allowed = new Set(entries.map((e) => e.id));
|
||||||
|
return [...selectedIds].filter((id) => allowed.has(id));
|
||||||
|
});
|
||||||
|
const selectedCount = $derived(activeSelected.length);
|
||||||
|
|
||||||
|
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
|
{ id: 'asn', label: 'ASN', sortable: true, sortValue: (e: AsEntry) => e.asn },
|
||||||
|
{
|
||||||
|
id: 'name',
|
||||||
|
label: 'Название AS',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (e: AsEntry) => e.asn_name ?? ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prefixes',
|
||||||
|
label: 'Префиксов',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (e: AsEntry) => e.prefix_count ?? 0,
|
||||||
|
class: 'text-right'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'updated',
|
||||||
|
label: 'Обновлено',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (e: AsEntry) => e.asn_resolved_at ?? ''
|
||||||
|
},
|
||||||
|
{ id: 'community', label: 'Community' },
|
||||||
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function toggleSelection(id: string) {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
selectedIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(checked: boolean) {
|
||||||
|
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editTarget = null;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(entry: AsEntry) {
|
||||||
|
editTarget = entry;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(entry: AsEntry) {
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить запись?',
|
||||||
|
description: `ASN: ${entry.asn}`,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries/${entry.id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
notify.success('Удалено');
|
||||||
|
await onChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить выбранные AS-записи?',
|
||||||
|
description: `Будет удалено: ${selectedCount}`,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: bulkDelete
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
deletingBulk = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of activeSelected) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries/${id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) notify.success(`Удалено AS-записей: ${deleted}`);
|
||||||
|
await onChanged();
|
||||||
|
} finally {
|
||||||
|
deletingBulk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorText(res: Response): Promise<string> {
|
||||||
|
const body = (await res.text()).trim();
|
||||||
|
return body || `HTTP ${res.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportCsv() {
|
||||||
|
if (!supportsCsvIO(mod.type) || csvExporting) return;
|
||||||
|
csvExporting = true;
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'text/csv' }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
notify.error(await readErrorText(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
csvExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImportPicker() {
|
||||||
|
if (!supportsCsvIO(mod.type) || csvImporting) return;
|
||||||
|
csvFileInput?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportChange(event: Event) {
|
||||||
|
const input = event.currentTarget as HTMLInputElement | null;
|
||||||
|
const file = input?.files?.[0];
|
||||||
|
if (!file || csvImporting) return;
|
||||||
|
csvImporting = true;
|
||||||
|
try {
|
||||||
|
const fileText = await file.text();
|
||||||
|
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/csv' },
|
||||||
|
body: fileText
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
notify.error(await readErrorText(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = (await res.json()) as { imported?: number };
|
||||||
|
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
|
||||||
|
await onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
csvImporting = false;
|
||||||
|
if (input) input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
bind:this={csvFileInput}
|
||||||
|
onchange={handleImportChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="text-base">AS-записи</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Номер AS и community; имя, число префиксов и дата обновляются при успешном refresh
|
||||||
|
(RIPEstat).
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={openImportPicker}
|
||||||
|
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
|
||||||
|
>
|
||||||
|
<Upload />
|
||||||
|
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={exportCsv}
|
||||||
|
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
|
||||||
|
>
|
||||||
|
<Download />
|
||||||
|
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить ({selectedCount})
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-4 pt-0">
|
||||||
|
<AppDataTable
|
||||||
|
columns={[...columns]}
|
||||||
|
rows={entries}
|
||||||
|
rowKey={(e) => e.id}
|
||||||
|
{loading}
|
||||||
|
emptyTitle="Нет AS-записей"
|
||||||
|
emptyDescription="Добавьте ASN или импортируйте CSV."
|
||||||
|
>
|
||||||
|
{#snippet toolbar()}
|
||||||
|
{#if entries.length > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={(v) => toggleAll(v === true)}
|
||||||
|
aria-label="Выбрать все AS-записи"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet cell({ row: entry, column })}
|
||||||
|
{#if column.id === 'select'}
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(entry.id)}
|
||||||
|
aria-label={`Выбрать AS ${entry.asn}`}
|
||||||
|
onCheckedChange={() => toggleSelection(entry.id)}
|
||||||
|
/>
|
||||||
|
{:else if column.id === 'asn'}
|
||||||
|
<span class="font-mono">{entry.asn}</span>
|
||||||
|
{:else if column.id === 'name'}
|
||||||
|
<span
|
||||||
|
class="max-w-[14rem] truncate text-sm text-muted-foreground"
|
||||||
|
title={entry.asn_name ?? ''}
|
||||||
|
>
|
||||||
|
{entry.asn_name?.trim() ? entry.asn_name : '—'}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'prefixes'}
|
||||||
|
<span class="font-mono text-sm">
|
||||||
|
{entry.prefix_count != null ? entry.prefix_count : '—'}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'updated'}
|
||||||
|
<span class="text-sm whitespace-nowrap text-muted-foreground">
|
||||||
|
{formatDateTime(entry.asn_resolved_at)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'community'}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{communityLabel(entry.community_id, communities)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'actions'}
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
|
||||||
|
<Pencil class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-destructive"
|
||||||
|
onclick={() => requestDelete(entry)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ModuleAsEntryDialog
|
||||||
|
bind:open={dialogOpen}
|
||||||
|
{moduleId}
|
||||||
|
edit={editTarget}
|
||||||
|
{communities}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onClose={() => {
|
||||||
|
editTarget = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { AsEntry, AsEntryCreate, AsEntryPatch, BgpCommunity } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
communityOptionLabel,
|
||||||
|
fromNullableSelect,
|
||||||
|
NONE_OPTION,
|
||||||
|
nullableSelectValue
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/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 { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
moduleId: string;
|
||||||
|
edit: AsEntry | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let form = $state<AsEntryCreate>({ asn: 0, community_id: null });
|
||||||
|
let initKey = $state('');
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form = edit
|
||||||
|
? { asn: edit.asn, community_id: edit.community_id }
|
||||||
|
: { asn: 0, community_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
initKey = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextKey = edit?.id ?? 'new';
|
||||||
|
if (nextKey !== initKey) {
|
||||||
|
initKey = nextKey;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const asn = Number(form.asn);
|
||||||
|
if (!Number.isFinite(asn) || asn < 1 || asn > 4294967295) {
|
||||||
|
notify.error('Укажите корректный ASN (1–4294967295)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const body: AsEntryCreate | AsEntryPatch = { asn, community_id: form.community_id };
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries/${edit.id}`, 'PATCH', body);
|
||||||
|
notify.success('Запись обновлена');
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/as-entries`, 'POST', body as AsEntryCreate);
|
||||||
|
notify.success('Запись добавлена');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{edit ? 'Редактировать запись' : 'Новая AS-запись'}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Номер автономной системы и community для политики анонса.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="as-asn">ASN</Label>
|
||||||
|
<Input
|
||||||
|
id="as-asn"
|
||||||
|
type="number"
|
||||||
|
placeholder="12345"
|
||||||
|
bind:value={form.asn}
|
||||||
|
min={1}
|
||||||
|
max={4294967295}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="as-comm">Community</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={nullableSelectValue(form.community_id)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.community_id = fromNullableSelect(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="as-comm" class="w-full">
|
||||||
|
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type {
|
||||||
|
BgpCommunity,
|
||||||
|
CdnPreviewResponse,
|
||||||
|
CdnSource,
|
||||||
|
CdnSourceCreate
|
||||||
|
} from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
communityOptionLabel,
|
||||||
|
fromNullableSelect,
|
||||||
|
normalizeCdnSourceKind,
|
||||||
|
NONE_OPTION,
|
||||||
|
nullableSelectValue
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter
|
||||||
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
moduleId: string;
|
||||||
|
edit: CdnSource | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let previewLoading = $state(false);
|
||||||
|
let previewItems = $state<string[]>([]);
|
||||||
|
let previewTotal = $state(0);
|
||||||
|
let previewTruncated = $state(false);
|
||||||
|
let previewError = $state<string | null>(null);
|
||||||
|
let previewOk = $state(false);
|
||||||
|
let form = $state<CdnSourceCreate & { refresh_interval_sec?: number | null }>({
|
||||||
|
url: '',
|
||||||
|
source_kind: 'plaintext',
|
||||||
|
prefix_path: '',
|
||||||
|
community_id: null
|
||||||
|
});
|
||||||
|
let initKey = $state('');
|
||||||
|
|
||||||
|
function clearPreview() {
|
||||||
|
previewLoading = false;
|
||||||
|
previewItems = [];
|
||||||
|
previewTotal = 0;
|
||||||
|
previewTruncated = false;
|
||||||
|
previewError = null;
|
||||||
|
previewOk = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
clearPreview();
|
||||||
|
form = edit
|
||||||
|
? {
|
||||||
|
url: edit.url,
|
||||||
|
source_kind: normalizeCdnSourceKind(edit.source_kind),
|
||||||
|
prefix_path: edit.prefix_path ?? '',
|
||||||
|
community_id: edit.community_id,
|
||||||
|
refresh_interval_sec: edit.refresh_interval_sec
|
||||||
|
}
|
||||||
|
: { url: '', source_kind: 'plaintext', prefix_path: '', community_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
initKey = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextKey = edit?.id ?? 'new';
|
||||||
|
if (nextKey !== initKey) {
|
||||||
|
initKey = nextKey;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function previewCdn() {
|
||||||
|
const urlTrim = form.url.trim();
|
||||||
|
if (!urlTrim) {
|
||||||
|
notify.error('Укажите URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
previewLoading = true;
|
||||||
|
previewError = null;
|
||||||
|
previewOk = false;
|
||||||
|
try {
|
||||||
|
const res = await apiMutate<CdnPreviewResponse>(
|
||||||
|
`/v1/modules/${moduleId}/cdn-sources/preview`,
|
||||||
|
'POST',
|
||||||
|
{
|
||||||
|
url: urlTrim,
|
||||||
|
source_kind: form.source_kind,
|
||||||
|
prefix_path: form.prefix_path?.trim() ?? ''
|
||||||
|
}
|
||||||
|
);
|
||||||
|
previewItems = res.items;
|
||||||
|
previewTotal = res.total;
|
||||||
|
previewTruncated = res.truncated;
|
||||||
|
previewOk = true;
|
||||||
|
} catch (e) {
|
||||||
|
previewError = e instanceof Error ? e.message : String(e);
|
||||||
|
previewItems = [];
|
||||||
|
previewTotal = 0;
|
||||||
|
previewTruncated = false;
|
||||||
|
previewOk = false;
|
||||||
|
} finally {
|
||||||
|
previewLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const urlTrim = form.url.trim();
|
||||||
|
if (!urlTrim) {
|
||||||
|
notify.error('Укажите URL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
const body = {
|
||||||
|
...form,
|
||||||
|
url: urlTrim,
|
||||||
|
source_kind: form.source_kind,
|
||||||
|
prefix_path: form.prefix_path?.trim() ?? ''
|
||||||
|
};
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${edit.id}`, 'PATCH', body);
|
||||||
|
notify.success('Источник обновлён');
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources`, 'POST', body);
|
||||||
|
notify.success('Источник добавлен');
|
||||||
|
}
|
||||||
|
clearPreview();
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) {
|
||||||
|
clearPreview();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{edit ? 'Редактировать источник' : 'Новый CDN-источник'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-url">URL</Label>
|
||||||
|
<Input id="cdn-url" placeholder="https://example.com/list.txt" bind:value={form.url} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-kind">Тип источника</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={form.source_kind}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.source_kind = v || 'plaintext';
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="cdn-kind" class="w-full">
|
||||||
|
{form.source_kind === 'json' ? 'json' : 'plaintext'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="plaintext">plaintext</SelectItem>
|
||||||
|
<SelectItem value="json">json</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-prefix-path">JSON path (prefix_path)</Label>
|
||||||
|
<Input
|
||||||
|
id="cdn-prefix-path"
|
||||||
|
placeholder="напр. prefixes[] или data.items[].cidr"
|
||||||
|
bind:value={form.prefix_path}
|
||||||
|
/>
|
||||||
|
{#if form.source_kind === 'json' && !form.prefix_path?.trim()}
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Для JSON укажите путь к полям с CIDR; пустой путь может не дать префиксов.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-comm">Community</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={nullableSelectValue(form.community_id)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.community_id = fromNullableSelect(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="cdn-comm" class="w-full">
|
||||||
|
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="cdn-interval">Интервал обновления (сек)</Label>
|
||||||
|
<Input
|
||||||
|
id="cdn-interval"
|
||||||
|
type="number"
|
||||||
|
placeholder="3600"
|
||||||
|
bind:value={form.refresh_interval_sec}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2 rounded-lg border border-border p-3">
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onclick={previewCdn}
|
||||||
|
disabled={previewLoading}
|
||||||
|
>
|
||||||
|
{previewLoading ? 'Загрузка…' : 'Предпросмотр'}
|
||||||
|
</Button>
|
||||||
|
{#if previewError}
|
||||||
|
<span class="text-sm text-destructive">{previewError}</span>
|
||||||
|
{:else if previewOk}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
Всего: {previewTotal}{#if previewTruncated}
|
||||||
|
<span class="text-amber-600 dark:text-amber-500"> (обрезано)</span>{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if previewItems.length}
|
||||||
|
<ul class="max-h-48 overflow-y-auto rounded-md border bg-muted/40 p-2 font-mono text-xs">
|
||||||
|
{#each previewItems as item, i (`${i}-${item}`)}
|
||||||
|
<li class="py-0.5">{item}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { BgpCommunity, CdnSource } from '$lib/api/types.js';
|
||||||
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
normalizeCdnSourceKind
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
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 ModuleCdnSourceDialog from '$lib/components/modules/ModuleCdnSourceDialog.svelte';
|
||||||
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
moduleId: string;
|
||||||
|
sources: CdnSource[];
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
loading?: boolean;
|
||||||
|
onChanged: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { moduleId, sources, communities, loading = false, onChanged }: Props = $props();
|
||||||
|
|
||||||
|
let dialogOpen = $state(false);
|
||||||
|
let editTarget = $state<CdnSource | null>(null);
|
||||||
|
let selectedIds = $state(new Set<string>());
|
||||||
|
let deletingBulk = $state(false);
|
||||||
|
|
||||||
|
const activeSelected = $derived.by(() => {
|
||||||
|
const allowed = new Set(sources.map((s) => s.id));
|
||||||
|
return [...selectedIds].filter((id) => allowed.has(id));
|
||||||
|
});
|
||||||
|
const selectedCount = $derived(activeSelected.length);
|
||||||
|
const allSelected = $derived(sources.length > 0 && sources.every((s) => selectedIds.has(s.id)));
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
|
{ id: 'url', label: 'URL', sortable: true, sortValue: (s: CdnSource) => s.url },
|
||||||
|
{ id: 'kind', label: 'Тип', sortable: true, sortValue: (s: CdnSource) => s.source_kind },
|
||||||
|
{ id: 'community', label: 'Community' },
|
||||||
|
{
|
||||||
|
id: 'interval',
|
||||||
|
label: 'Интервал',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (s: CdnSource) => s.refresh_interval_sec ?? 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'refreshed',
|
||||||
|
label: 'Последнее обновление',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (s: CdnSource) => s.last_refreshed_at ?? ''
|
||||||
|
},
|
||||||
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function toggleSelection(id: string) {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
selectedIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(checked: boolean) {
|
||||||
|
selectedIds = checked ? new Set(sources.map((s) => s.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editTarget = null;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(src: CdnSource) {
|
||||||
|
editTarget = src;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(src: CdnSource) {
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить CDN-источник?',
|
||||||
|
description: src.url,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${src.id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
notify.success('Удалено');
|
||||||
|
await onChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить выбранные CDN-источники?',
|
||||||
|
description: `Будет удалено: ${selectedCount}`,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: bulkDelete
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
deletingBulk = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of activeSelected) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/cdn-sources/${id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) notify.success(`Удалено CDN-источников: ${deleted}`);
|
||||||
|
await onChanged();
|
||||||
|
} finally {
|
||||||
|
deletingBulk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="text-base">CDN-источники</CardTitle>
|
||||||
|
<CardDescription>URL источников для скачивания списков CIDR.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||||
|
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить ({selectedCount})
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-4 pt-0">
|
||||||
|
<AppDataTable
|
||||||
|
columns={[...columns]}
|
||||||
|
rows={sources}
|
||||||
|
rowKey={(s) => s.id}
|
||||||
|
{loading}
|
||||||
|
emptyTitle="Нет CDN-источников"
|
||||||
|
emptyDescription="Добавьте URL для загрузки списков CIDR."
|
||||||
|
>
|
||||||
|
{#snippet toolbar()}
|
||||||
|
{#if sources.length > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={(v) => toggleAll(v === true)}
|
||||||
|
aria-label="Выбрать все CDN-источники"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet cell({ row: src, column })}
|
||||||
|
{#if column.id === 'select'}
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(src.id)}
|
||||||
|
aria-label="Выбрать CDN-источник"
|
||||||
|
onCheckedChange={() => toggleSelection(src.id)}
|
||||||
|
/>
|
||||||
|
{:else if column.id === 'url'}
|
||||||
|
<span class="max-w-xs truncate font-mono text-xs" title={src.url}>{src.url}</span>
|
||||||
|
{:else if column.id === 'kind'}
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<Badge variant="outline">{normalizeCdnSourceKind(src.source_kind)}</Badge>
|
||||||
|
{#if src.prefix_path?.trim()}
|
||||||
|
<span
|
||||||
|
class="font-mono text-xs break-all text-muted-foreground"
|
||||||
|
title={src.prefix_path}>{src.prefix_path}</span
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else if column.id === 'community'}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{communityLabel(src.community_id, communities)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'interval'}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{src.refresh_interval_sec != null ? `${src.refresh_interval_sec}с` : '—'}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'refreshed'}
|
||||||
|
<span class="text-sm whitespace-nowrap text-muted-foreground">
|
||||||
|
{formatDateTime(src.last_refreshed_at)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'actions'}
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(src)}>
|
||||||
|
<Pencil class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-destructive"
|
||||||
|
onclick={() => requestDelete(src)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ModuleCdnSourceDialog
|
||||||
|
bind:open={dialogOpen}
|
||||||
|
{moduleId}
|
||||||
|
edit={editTarget}
|
||||||
|
{communities}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onClose={() => {
|
||||||
|
editTarget = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { ModuleCreate } from '$lib/api/types.js';
|
||||||
|
import { moduleTypeRu } from '$lib/ui-labels.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/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 { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onCreated: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), onClose, onCreated }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let form = $state<ModuleCreate>({
|
||||||
|
type: 'AS_PREFIXES',
|
||||||
|
name: '',
|
||||||
|
enabled: true,
|
||||||
|
priority: 0
|
||||||
|
});
|
||||||
|
|
||||||
|
const moduleTypes = [
|
||||||
|
{ value: 'AS_PREFIXES', label: moduleTypeRu('AS_PREFIXES') },
|
||||||
|
{ value: 'CDN_CIDRS', label: moduleTypeRu('CDN_CIDRS') },
|
||||||
|
{ value: 'DOMAINS', label: moduleTypeRu('DOMAINS') },
|
||||||
|
{ value: 'IP_RANGES', label: moduleTypeRu('IP_RANGES') }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function create() {
|
||||||
|
if (!form.name.trim()) {
|
||||||
|
notify.error('Укажите название модуля');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
await apiMutate('/v1/modules', 'POST', form);
|
||||||
|
notify.success('Модуль создан');
|
||||||
|
open = false;
|
||||||
|
resetForm();
|
||||||
|
await onCreated();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) {
|
||||||
|
onClose();
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Новый модуль</DialogTitle>
|
||||||
|
<DialogDescription>Создание нового модуля префиксов.</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="m-name">Название</Label>
|
||||||
|
<Input id="m-name" bind:value={form.name} placeholder="my-asn-module" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="m-type">Тип</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={form.type}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) form.type = v as typeof form.type;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="m-type" class="w-full">
|
||||||
|
{moduleTypes.find((t) => t.value === form.type)?.label ?? 'Выберите тип'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each moduleTypes as t (t.value)}
|
||||||
|
<SelectItem value={t.value}>{t.label}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="m-priority">Приоритет</Label>
|
||||||
|
<Input id="m-priority" type="number" bind:value={form.priority} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="m-interval">Интервал (сек)</Label>
|
||||||
|
<Input
|
||||||
|
id="m-interval"
|
||||||
|
type="number"
|
||||||
|
placeholder="3600"
|
||||||
|
bind:value={form.refresh_interval_sec}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
|
<Label for="m-enabled" class="leading-snug text-foreground">Включён</Label>
|
||||||
|
<p class="text-xs leading-snug text-muted-foreground">
|
||||||
|
Модуль участвует в сборке ревизий, если включён.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="m-enabled"
|
||||||
|
class="shrink-0"
|
||||||
|
checked={form.enabled !== false}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
form = { ...form, enabled: v };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={create} disabled={saving}>{saving ? 'Создание…' : 'Создать'}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { ModuleRow } from '$lib/api/types.js';
|
||||||
|
import { moduleEnabledRu, moduleEnabledBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
|
||||||
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
|
import ArrowLeft from '@lucide/svelte/icons/arrow-left';
|
||||||
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
import Blocks from '@lucide/svelte/icons/blocks';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mod: ModuleRow;
|
||||||
|
refreshing: boolean;
|
||||||
|
onRefresh: () => void;
|
||||||
|
onEdit: () => void;
|
||||||
|
onDelete: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { mod, refreshing, onRefresh, onEdit, onDelete }: Props = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex min-w-0 items-start gap-2">
|
||||||
|
<Button variant="ghost" size="icon-sm" class="mt-1 shrink-0" href={resolve('/modules')}>
|
||||||
|
<ArrowLeft class="size-4" />
|
||||||
|
</Button>
|
||||||
|
<PageHeader
|
||||||
|
class="min-w-0 flex-1"
|
||||||
|
title={mod.name}
|
||||||
|
description={mod.id}
|
||||||
|
icon={Blocks}
|
||||||
|
iconClass="bg-chart-2/15 text-chart-2"
|
||||||
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge variant="outline">{moduleTypeRu(mod.type)}</Badge>
|
||||||
|
<Badge variant={moduleEnabledBadgeVariant(!!mod.enabled)} class="text-xs">
|
||||||
|
{moduleEnabledRu(!!mod.enabled)}
|
||||||
|
</Badge>
|
||||||
|
<Button variant="outline" size="sm" onclick={() => onRefresh()} disabled={refreshing}>
|
||||||
|
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onclick={() => onEdit()}>
|
||||||
|
<Pencil />
|
||||||
|
Редактировать
|
||||||
|
</Button>
|
||||||
|
<Button variant="destructive" size="sm" onclick={() => onDelete()}>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/snippet}
|
||||||
|
</PageHeader>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiFetch, apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { BgpCommunity, DomainEntry, ModuleRow } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
sanitizeFilenamePart,
|
||||||
|
supportsCsvIO
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
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 ModuleDomainEntryDialog from '$lib/components/modules/ModuleDomainEntryDialog.svelte';
|
||||||
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
|
import Download from '@lucide/svelte/icons/download';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
moduleId: string;
|
||||||
|
mod: ModuleRow;
|
||||||
|
entries: DomainEntry[];
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
loading?: boolean;
|
||||||
|
onChanged: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
|
||||||
|
|
||||||
|
let dialogOpen = $state(false);
|
||||||
|
let editTarget = $state<DomainEntry | null>(null);
|
||||||
|
let selectedIds = $state(new Set<string>());
|
||||||
|
let deletingBulk = $state(false);
|
||||||
|
let csvImporting = $state(false);
|
||||||
|
let csvExporting = $state(false);
|
||||||
|
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const activeSelected = $derived.by(() => {
|
||||||
|
const allowed = new Set(entries.map((e) => e.id));
|
||||||
|
return [...selectedIds].filter((id) => allowed.has(id));
|
||||||
|
});
|
||||||
|
const selectedCount = $derived(activeSelected.length);
|
||||||
|
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
|
{ id: 'fqdn', label: 'FQDN', sortable: true, sortValue: (e: DomainEntry) => e.fqdn },
|
||||||
|
{ id: 'community', label: 'Community' },
|
||||||
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function toggleSelection(id: string) {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
selectedIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(checked: boolean) {
|
||||||
|
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editTarget = null;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(entry: DomainEntry) {
|
||||||
|
editTarget = entry;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(entry: DomainEntry) {
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить домен?',
|
||||||
|
description: entry.fqdn,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${entry.id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
notify.success('Удалено');
|
||||||
|
await onChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить выбранные домены?',
|
||||||
|
description: `Будет удалено: ${selectedCount}`,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: bulkDelete
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
deletingBulk = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of activeSelected) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) notify.success(`Удалено доменов: ${deleted}`);
|
||||||
|
await onChanged();
|
||||||
|
} finally {
|
||||||
|
deletingBulk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorText(res: Response): Promise<string> {
|
||||||
|
const body = (await res.text()).trim();
|
||||||
|
return body || `HTTP ${res.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportCsv() {
|
||||||
|
if (!supportsCsvIO(mod.type) || csvExporting) return;
|
||||||
|
csvExporting = true;
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'text/csv' }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
notify.error(await readErrorText(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
csvExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImportPicker() {
|
||||||
|
if (!supportsCsvIO(mod.type) || csvImporting) return;
|
||||||
|
csvFileInput?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportChange(event: Event) {
|
||||||
|
const input = event.currentTarget as HTMLInputElement | null;
|
||||||
|
const file = input?.files?.[0];
|
||||||
|
if (!file || csvImporting) return;
|
||||||
|
csvImporting = true;
|
||||||
|
try {
|
||||||
|
const fileText = await file.text();
|
||||||
|
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/csv' },
|
||||||
|
body: fileText
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
notify.error(await readErrorText(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = (await res.json()) as { imported?: number };
|
||||||
|
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
|
||||||
|
await onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
csvImporting = false;
|
||||||
|
if (input) input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
bind:this={csvFileInput}
|
||||||
|
onchange={handleImportChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="text-base">Домены</CardTitle>
|
||||||
|
<CardDescription>FQDN для резолвинга через DoH.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={openImportPicker}
|
||||||
|
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
|
||||||
|
>
|
||||||
|
<Upload />
|
||||||
|
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={exportCsv}
|
||||||
|
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
|
||||||
|
>
|
||||||
|
<Download />
|
||||||
|
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить ({selectedCount})
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-4 pt-0">
|
||||||
|
<AppDataTable
|
||||||
|
columns={[...columns]}
|
||||||
|
rows={entries}
|
||||||
|
rowKey={(e) => e.id}
|
||||||
|
{loading}
|
||||||
|
emptyTitle="Нет доменов"
|
||||||
|
emptyDescription="Добавьте FQDN или импортируйте CSV."
|
||||||
|
>
|
||||||
|
{#snippet toolbar()}
|
||||||
|
{#if entries.length > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={(v) => toggleAll(v === true)}
|
||||||
|
aria-label="Выбрать все домены"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet cell({ row: entry, column })}
|
||||||
|
{#if column.id === 'select'}
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(entry.id)}
|
||||||
|
aria-label={`Выбрать домен ${entry.fqdn}`}
|
||||||
|
onCheckedChange={() => toggleSelection(entry.id)}
|
||||||
|
/>
|
||||||
|
{:else if column.id === 'fqdn'}
|
||||||
|
<span class="font-mono">{entry.fqdn}</span>
|
||||||
|
{:else if column.id === 'community'}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{communityLabel(entry.community_id, communities)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'actions'}
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
|
||||||
|
<Pencil class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-destructive"
|
||||||
|
onclick={() => requestDelete(entry)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ModuleDomainEntryDialog
|
||||||
|
bind:open={dialogOpen}
|
||||||
|
{moduleId}
|
||||||
|
edit={editTarget}
|
||||||
|
{communities}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onClose={() => {
|
||||||
|
editTarget = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { BgpCommunity, DomainEntry, DomainEntryCreate } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
communityOptionLabel,
|
||||||
|
fromNullableSelect,
|
||||||
|
NONE_OPTION,
|
||||||
|
nullableSelectValue
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter
|
||||||
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
moduleId: string;
|
||||||
|
edit: DomainEntry | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let form = $state<DomainEntryCreate>({ fqdn: '', community_id: null });
|
||||||
|
let initKey = $state('');
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form = edit
|
||||||
|
? { fqdn: edit.fqdn, community_id: edit.community_id }
|
||||||
|
: { fqdn: '', community_id: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
initKey = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextKey = edit?.id ?? 'new';
|
||||||
|
if (nextKey !== initKey) {
|
||||||
|
initKey = nextKey;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries/${edit.id}`, 'PATCH', form);
|
||||||
|
notify.success('Домен обновлён');
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/domain-entries`, 'POST', form);
|
||||||
|
notify.success('Домен добавлен');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{edit ? 'Редактировать домен' : 'Новый домен'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="dom-fqdn">FQDN</Label>
|
||||||
|
<Input id="dom-fqdn" placeholder="example.com" bind:value={form.fqdn} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="dom-comm">Community</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={nullableSelectValue(form.community_id)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.community_id = fromNullableSelect(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="dom-comm" class="w-full">
|
||||||
|
{form.community_id ? communityLabel(form.community_id, communities) : 'Не выбрано'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type {
|
||||||
|
BgpCommunity,
|
||||||
|
DohProfile,
|
||||||
|
DohResolverPolicy,
|
||||||
|
ModulePatch,
|
||||||
|
ModuleRow
|
||||||
|
} from '$lib/api/types.js';
|
||||||
|
import { dohPolicyRu } from '$lib/ui-labels.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
communityOptionLabel,
|
||||||
|
fromNullableSelect,
|
||||||
|
moduleDohProfileIds,
|
||||||
|
NONE_OPTION,
|
||||||
|
nullableSelectValue
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter
|
||||||
|
} 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 { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
const dohPolicyOptions: { value: DohResolverPolicy; label: string; hint: string }[] = [
|
||||||
|
{
|
||||||
|
value: 'primary_only',
|
||||||
|
label: 'Только первый',
|
||||||
|
hint: 'Используется первый выбранный DoH-профиль.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'failover',
|
||||||
|
label: 'Резервирование',
|
||||||
|
hint: 'Профили по порядку до первого успешного ответа.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'union',
|
||||||
|
label: 'Объединение',
|
||||||
|
hint: 'Все A/AAAA со всех профилей (geo-split DNS).'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
mod: ModuleRow;
|
||||||
|
moduleId: string;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
dohProfiles: DohProfile[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
open = $bindable(),
|
||||||
|
mod,
|
||||||
|
moduleId,
|
||||||
|
communities,
|
||||||
|
dohProfiles,
|
||||||
|
onSaved,
|
||||||
|
onClose
|
||||||
|
}: Props = $props();
|
||||||
|
|
||||||
|
let editForm = $state<ModulePatch>({});
|
||||||
|
let editSaving = $state(false);
|
||||||
|
let initKey = $state('');
|
||||||
|
|
||||||
|
function resetEditForm() {
|
||||||
|
editForm = {
|
||||||
|
name: mod.name,
|
||||||
|
enabled: mod.enabled,
|
||||||
|
priority: mod.priority,
|
||||||
|
refresh_interval_sec: mod.refresh_interval_sec,
|
||||||
|
cron_expr: mod.cron_expr,
|
||||||
|
default_community_id: mod.default_community_id,
|
||||||
|
doh_profile_ids: moduleDohProfileIds(mod),
|
||||||
|
doh_resolver_policy: mod.doh_resolver_policy ?? 'primary_only'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
initKey = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextKey = mod.id;
|
||||||
|
if (nextKey !== initKey) {
|
||||||
|
initKey = nextKey;
|
||||||
|
resetEditForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function toggleEditDohProfile(id: string, checked: boolean) {
|
||||||
|
let ids = [...(editForm.doh_profile_ids ?? [])];
|
||||||
|
if (checked) {
|
||||||
|
if (!ids.includes(id)) ids.push(id);
|
||||||
|
} else {
|
||||||
|
ids = ids.filter((x) => x !== id);
|
||||||
|
}
|
||||||
|
editForm = { ...editForm, doh_profile_ids: ids };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isEditDohProfileSelected(id: string): boolean {
|
||||||
|
return (editForm.doh_profile_ids ?? []).includes(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveMod() {
|
||||||
|
editSaving = true;
|
||||||
|
try {
|
||||||
|
const cron =
|
||||||
|
typeof editForm.cron_expr === 'string' ? editForm.cron_expr.trim() : editForm.cron_expr;
|
||||||
|
const intervalRaw = editForm.refresh_interval_sec;
|
||||||
|
const interval =
|
||||||
|
intervalRaw === null || intervalRaw === undefined ? null : Number(intervalRaw);
|
||||||
|
const payload: ModulePatch = {
|
||||||
|
...editForm,
|
||||||
|
cron_expr: cron ? cron : null,
|
||||||
|
refresh_interval_sec: Number.isFinite(interval) ? interval : null,
|
||||||
|
default_community_id: fromNullableSelect(
|
||||||
|
nullableSelectValue(editForm.default_community_id)
|
||||||
|
),
|
||||||
|
doh_profile_ids: editForm.doh_profile_ids ?? [],
|
||||||
|
doh_resolver_policy: editForm.doh_resolver_policy ?? 'primary_only'
|
||||||
|
};
|
||||||
|
await apiMutate<ModuleRow>(`/v1/modules/${moduleId}`, 'PATCH', payload);
|
||||||
|
notify.success('Модуль обновлён');
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
editSaving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Редактировать модуль</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-name">Название</Label>
|
||||||
|
<Input id="e-name" bind:value={editForm.name} />
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-priority">Приоритет</Label>
|
||||||
|
<Input id="e-priority" type="number" bind:value={editForm.priority} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-interval">Интервал (сек)</Label>
|
||||||
|
<Input id="e-interval" type="number" bind:value={editForm.refresh_interval_sec} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-cron">Cron-выражение</Label>
|
||||||
|
<Input id="e-cron" placeholder="0 */6 * * *" bind:value={editForm.cron_expr} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-comm">Community по умолчанию</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={nullableSelectValue(editForm.default_community_id)}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
editForm.default_community_id = fromNullableSelect(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="e-comm" class="w-full">
|
||||||
|
{editForm.default_community_id
|
||||||
|
? communityLabel(editForm.default_community_id, communities)
|
||||||
|
: 'Не выбрано'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NONE_OPTION}>Не выбрано</SelectItem>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2"
|
||||||
|
onclick={() => {
|
||||||
|
editForm.default_community_id = null;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{#if mod.type === 'DOMAINS'}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="e-doh-policy">Политика DoH</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={editForm.doh_resolver_policy ?? 'primary_only'}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
if (v) editForm.doh_resolver_policy = v as DohResolverPolicy;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="e-doh-policy" class="w-full">
|
||||||
|
{dohPolicyRu(editForm.doh_resolver_policy)}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each dohPolicyOptions as opt (opt.value)}
|
||||||
|
<SelectItem value={opt.value}>{opt.label}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
{dohPolicyOptions.find(
|
||||||
|
(o) => o.value === (editForm.doh_resolver_policy ?? 'primary_only')
|
||||||
|
)?.hint}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<Label>DoH-профили</Label>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Порядок выбора = порядок в списке (сверху вниз).
|
||||||
|
</p>
|
||||||
|
<div class="max-h-40 space-y-2 overflow-y-auto rounded-md border p-3">
|
||||||
|
{#each dohProfiles as d (d.id)}
|
||||||
|
<label class="flex items-start gap-2 text-sm">
|
||||||
|
<Checkbox
|
||||||
|
checked={isEditDohProfileSelected(d.id)}
|
||||||
|
onCheckedChange={(v) => toggleEditDohProfile(d.id, v === true)}
|
||||||
|
/>
|
||||||
|
<span class="min-w-0 break-all">
|
||||||
|
<span class="font-medium">{d.name?.trim() ? d.name : d.url}</span>
|
||||||
|
{#if d.name?.trim()}
|
||||||
|
<span class="block font-mono text-xs text-muted-foreground">{d.url}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">Нет профилей — создайте в справочниках.</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="h-7 px-2"
|
||||||
|
onclick={() => {
|
||||||
|
editForm = { ...editForm, doh_profile_ids: [] };
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сбросить профили
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div
|
||||||
|
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
|
||||||
|
>
|
||||||
|
<div class="grid min-w-0 flex-1 gap-1 pr-2">
|
||||||
|
<Label for="e-enabled" class="leading-snug text-foreground">Включён</Label>
|
||||||
|
<p class="text-xs leading-snug text-muted-foreground">
|
||||||
|
Отключённые модули не участвуют в обновлении конфигурации.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="e-enabled"
|
||||||
|
class="shrink-0"
|
||||||
|
checked={editForm.enabled !== false}
|
||||||
|
onCheckedChange={(v) => {
|
||||||
|
editForm = { ...editForm, enabled: v };
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={saveMod} disabled={editSaving}>
|
||||||
|
{editSaving ? 'Сохранение…' : 'Сохранить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { BgpCommunity, IpRangeEntry, IpRangeEntryCreate } from '$lib/api/types.js';
|
||||||
|
import { communityLabel, communityOptionLabel } from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogFooter
|
||||||
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
open: boolean;
|
||||||
|
moduleId: string;
|
||||||
|
edit: IpRangeEntry | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
onSaved: () => void | Promise<void>;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { open = $bindable(), moduleId, edit, communities, onSaved, onClose }: Props = $props();
|
||||||
|
|
||||||
|
let saving = $state(false);
|
||||||
|
let form = $state<IpRangeEntryCreate>({ prefix: '', community_id: '' });
|
||||||
|
let initKey = $state('');
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
form = edit
|
||||||
|
? { prefix: edit.prefix, community_id: edit.community_id }
|
||||||
|
: { prefix: '', community_id: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!open) {
|
||||||
|
initKey = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const nextKey = edit?.id ?? 'new';
|
||||||
|
if (nextKey !== initKey) {
|
||||||
|
initKey = nextKey;
|
||||||
|
resetForm();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
saving = true;
|
||||||
|
try {
|
||||||
|
if (edit) {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${edit.id}`, 'PATCH', form);
|
||||||
|
notify.success('Диапазон обновлён');
|
||||||
|
} else {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries`, 'POST', form);
|
||||||
|
notify.success('Диапазон добавлен');
|
||||||
|
}
|
||||||
|
open = false;
|
||||||
|
await onSaved();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
saving = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleOpenChange(next: boolean) {
|
||||||
|
open = next;
|
||||||
|
if (!next) onClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Dialog bind:open onOpenChange={handleOpenChange}>
|
||||||
|
<DialogContent class="sm:max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{edit ? 'Редактировать диапазон' : 'Новый IP-диапазон'}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class="space-y-4 py-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="ip-prefix">Префикс (CIDR)</Label>
|
||||||
|
<Input id="ip-prefix" placeholder="203.0.113.0/24" bind:value={form.prefix} />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<Label for="ip-comm">Community (обязательно)</Label>
|
||||||
|
<Select
|
||||||
|
type="single"
|
||||||
|
value={form.community_id}
|
||||||
|
onValueChange={(v) => {
|
||||||
|
form.community_id = v;
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="ip-comm" class="w-full">
|
||||||
|
{form.community_id
|
||||||
|
? communityLabel(form.community_id, communities)
|
||||||
|
: 'Выберите community'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{#each communities as c (c.id)}
|
||||||
|
<SelectItem value={c.id}>{communityOptionLabel(c)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onclick={() => handleOpenChange(false)}>Отмена</Button>
|
||||||
|
<Button onclick={save} disabled={saving}>
|
||||||
|
{saving ? 'Сохранение…' : edit ? 'Сохранить' : 'Добавить'}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,310 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { apiFetch, apiMutate } from '$lib/api/client.js';
|
||||||
|
import type { BgpCommunity, IpRangeEntry, ModuleRow } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
sanitizeFilenamePart,
|
||||||
|
supportsCsvIO
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
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 ModuleIpRangeEntryDialog from '$lib/components/modules/ModuleIpRangeEntryDialog.svelte';
|
||||||
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
|
import Pencil from '@lucide/svelte/icons/pencil';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
import Upload from '@lucide/svelte/icons/upload';
|
||||||
|
import Download from '@lucide/svelte/icons/download';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
moduleId: string;
|
||||||
|
mod: ModuleRow;
|
||||||
|
entries: IpRangeEntry[];
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
loading?: boolean;
|
||||||
|
onChanged: () => void | Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { moduleId, mod, entries, communities, loading = false, onChanged }: Props = $props();
|
||||||
|
|
||||||
|
let dialogOpen = $state(false);
|
||||||
|
let editTarget = $state<IpRangeEntry | null>(null);
|
||||||
|
let selectedIds = $state(new Set<string>());
|
||||||
|
let deletingBulk = $state(false);
|
||||||
|
let csvImporting = $state(false);
|
||||||
|
let csvExporting = $state(false);
|
||||||
|
let csvFileInput = $state<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
|
const activeSelected = $derived.by(() => {
|
||||||
|
const allowed = new Set(entries.map((e) => e.id));
|
||||||
|
return [...selectedIds].filter((id) => allowed.has(id));
|
||||||
|
});
|
||||||
|
const selectedCount = $derived(activeSelected.length);
|
||||||
|
const allSelected = $derived(entries.length > 0 && entries.every((e) => selectedIds.has(e.id)));
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
|
{
|
||||||
|
id: 'prefix',
|
||||||
|
label: 'Префикс (CIDR)',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (e: IpRangeEntry) => e.prefix
|
||||||
|
},
|
||||||
|
{ id: 'community', label: 'Community' },
|
||||||
|
{ id: 'actions', label: '', class: 'w-20' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function toggleSelection(id: string) {
|
||||||
|
const next = new Set(selectedIds);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
selectedIds = next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll(checked: boolean) {
|
||||||
|
selectedIds = checked ? new Set(entries.map((e) => e.id)) : new Set<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
editTarget = null;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(entry: IpRangeEntry) {
|
||||||
|
editTarget = entry;
|
||||||
|
dialogOpen = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestDelete(entry: IpRangeEntry) {
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить диапазон?',
|
||||||
|
description: entry.prefix,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await apiMutate(
|
||||||
|
`/v1/modules/${moduleId}/ip-range-entries/${entry.id}`,
|
||||||
|
'DELETE',
|
||||||
|
undefined,
|
||||||
|
{ idempotent: false }
|
||||||
|
);
|
||||||
|
notify.success('Удалено');
|
||||||
|
await onChanged();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
void confirm({
|
||||||
|
title: 'Удалить выбранные диапазоны?',
|
||||||
|
description: `Будет удалено: ${selectedCount}`,
|
||||||
|
confirmLabel: 'Удалить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: bulkDelete
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bulkDelete() {
|
||||||
|
if (selectedCount === 0) return;
|
||||||
|
deletingBulk = true;
|
||||||
|
let deleted = 0;
|
||||||
|
try {
|
||||||
|
for (const id of activeSelected) {
|
||||||
|
try {
|
||||||
|
await apiMutate(`/v1/modules/${moduleId}/ip-range-entries/${id}`, 'DELETE', undefined, {
|
||||||
|
idempotent: false
|
||||||
|
});
|
||||||
|
deleted += 1;
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (deleted > 0) notify.success(`Удалено диапазонов: ${deleted}`);
|
||||||
|
await onChanged();
|
||||||
|
} finally {
|
||||||
|
deletingBulk = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readErrorText(res: Response): Promise<string> {
|
||||||
|
const body = (await res.text()).trim();
|
||||||
|
return body || `HTTP ${res.status}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportCsv() {
|
||||||
|
if (!supportsCsvIO(mod.type) || csvExporting) return;
|
||||||
|
csvExporting = true;
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'text/csv' }
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
notify.error(await readErrorText(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = `${sanitizeFilenamePart(mod.name)}-${mod.type.toLowerCase()}-entries.csv`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
csvExporting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openImportPicker() {
|
||||||
|
if (!supportsCsvIO(mod.type) || csvImporting) return;
|
||||||
|
csvFileInput?.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleImportChange(event: Event) {
|
||||||
|
const input = event.currentTarget as HTMLInputElement | null;
|
||||||
|
const file = input?.files?.[0];
|
||||||
|
if (!file || csvImporting) return;
|
||||||
|
csvImporting = true;
|
||||||
|
try {
|
||||||
|
const fileText = await file.text();
|
||||||
|
const res = await apiFetch(`/v1/modules/${moduleId}/entries.csv`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'text/csv' },
|
||||||
|
body: fileText
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
notify.error(await readErrorText(res));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const payload = (await res.json()) as { imported?: number };
|
||||||
|
notify.success(`Импортировано записей: ${payload.imported ?? 0}`);
|
||||||
|
await onChanged();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
csvImporting = false;
|
||||||
|
if (input) input.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<input
|
||||||
|
class="hidden"
|
||||||
|
type="file"
|
||||||
|
accept=".csv,text/csv"
|
||||||
|
bind:this={csvFileInput}
|
||||||
|
onchange={handleImportChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="flex flex-col gap-3 pb-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<CardTitle class="text-base">IP-диапазоны</CardTitle>
|
||||||
|
<CardDescription>Статические CIDR для анонса.</CardDescription>
|
||||||
|
</div>
|
||||||
|
<div class="flex shrink-0 flex-wrap items-center justify-end gap-2 self-start sm:self-auto">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={openImportPicker}
|
||||||
|
disabled={!supportsCsvIO(mod.type) || csvImporting || csvExporting}
|
||||||
|
>
|
||||||
|
<Upload />
|
||||||
|
{csvImporting ? 'Импорт…' : 'Импорт CSV'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={exportCsv}
|
||||||
|
disabled={!supportsCsvIO(mod.type) || csvExporting || csvImporting}
|
||||||
|
>
|
||||||
|
<Download />
|
||||||
|
{csvExporting ? 'Экспорт…' : 'Экспорт CSV'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onclick={openCreate}><Plus />Добавить</Button>
|
||||||
|
{#if selectedCount > 0}
|
||||||
|
<Button variant="destructive" size="sm" onclick={requestBulkDelete} disabled={deletingBulk}>
|
||||||
|
<Trash2 />
|
||||||
|
Удалить ({selectedCount})
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="p-4 pt-0">
|
||||||
|
<AppDataTable
|
||||||
|
columns={[...columns]}
|
||||||
|
rows={entries}
|
||||||
|
rowKey={(e) => e.id}
|
||||||
|
{loading}
|
||||||
|
emptyTitle="Нет диапазонов"
|
||||||
|
emptyDescription="Добавьте CIDR или импортируйте CSV."
|
||||||
|
>
|
||||||
|
{#snippet toolbar()}
|
||||||
|
{#if entries.length > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={(v) => toggleAll(v === true)}
|
||||||
|
aria-label="Выбрать все диапазоны"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
{#snippet cell({ row: entry, column })}
|
||||||
|
{#if column.id === 'select'}
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedIds.has(entry.id)}
|
||||||
|
aria-label={`Выбрать диапазон ${entry.prefix}`}
|
||||||
|
onCheckedChange={() => toggleSelection(entry.id)}
|
||||||
|
/>
|
||||||
|
{:else if column.id === 'prefix'}
|
||||||
|
<span class="font-mono">{entry.prefix}</span>
|
||||||
|
{:else if column.id === 'community'}
|
||||||
|
<span class="text-sm text-muted-foreground">
|
||||||
|
{communityLabel(entry.community_id, communities)}
|
||||||
|
</span>
|
||||||
|
{:else if column.id === 'actions'}
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<Button variant="ghost" size="icon-sm" onclick={() => openEdit(entry)}>
|
||||||
|
<Pencil class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
class="text-destructive"
|
||||||
|
onclick={() => requestDelete(entry)}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ModuleIpRangeEntryDialog
|
||||||
|
bind:open={dialogOpen}
|
||||||
|
{moduleId}
|
||||||
|
edit={editTarget}
|
||||||
|
{communities}
|
||||||
|
onSaved={onChanged}
|
||||||
|
onClose={() => {
|
||||||
|
editTarget = null;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '$lib/api/types.js';
|
||||||
|
import { formatDateTime, moduleIntervalLabel } from '$lib/modules/display.js';
|
||||||
|
import { dohPolicyRu } from '$lib/ui-labels.js';
|
||||||
|
import {
|
||||||
|
communityLabel,
|
||||||
|
dohProfileLabel,
|
||||||
|
moduleDohProfileIds
|
||||||
|
} from '$lib/components/modules/module-helpers.js';
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '$lib/ui/core/card/index.js';
|
||||||
|
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
|
import ArrowDownUp from '@lucide/svelte/icons/arrow-down-up';
|
||||||
|
import Timer from '@lucide/svelte/icons/timer';
|
||||||
|
import ShieldCheck from '@lucide/svelte/icons/shield-check';
|
||||||
|
import Network from '@lucide/svelte/icons/network';
|
||||||
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mod: ModuleRow | null;
|
||||||
|
communities: BgpCommunity[];
|
||||||
|
dohProfiles: DohProfile[];
|
||||||
|
asEntries: AsEntry[];
|
||||||
|
loading?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
let { mod, communities, dohProfiles, asEntries, loading = false }: Props = $props();
|
||||||
|
|
||||||
|
const asPrefixTotal = $derived(
|
||||||
|
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
const statAccents = [
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-3',
|
||||||
|
bg: 'bg-chart-3/5',
|
||||||
|
iconBg: 'bg-chart-3/15',
|
||||||
|
iconText: 'text-chart-3'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-5',
|
||||||
|
bg: 'bg-chart-5/5',
|
||||||
|
iconBg: 'bg-chart-5/15',
|
||||||
|
iconText: 'text-chart-5'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-4',
|
||||||
|
bg: 'bg-chart-4/5',
|
||||||
|
iconBg: 'bg-chart-4/15',
|
||||||
|
iconText: 'text-chart-4'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-2',
|
||||||
|
bg: 'bg-chart-2/5',
|
||||||
|
iconBg: 'bg-chart-2/15',
|
||||||
|
iconText: 'text-chart-2'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-muted-foreground',
|
||||||
|
bg: 'bg-muted/30',
|
||||||
|
iconBg: 'bg-muted',
|
||||||
|
iconText: 'text-muted-foreground'
|
||||||
|
}
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const kpiCards = $derived.by(() => {
|
||||||
|
if (!mod) return [];
|
||||||
|
const dohIds = moduleDohProfileIds(mod);
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'priority',
|
||||||
|
label: 'Приоритет',
|
||||||
|
value: String(mod.priority ?? 0),
|
||||||
|
description: 'порядок в сборке ревизии',
|
||||||
|
icon: ArrowDownUp,
|
||||||
|
accent: statAccents[0]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'interval',
|
||||||
|
label: 'Интервал',
|
||||||
|
value: moduleIntervalLabel(mod),
|
||||||
|
description: 'refresh_interval_sec / cron',
|
||||||
|
icon: Timer,
|
||||||
|
accent: statAccents[1],
|
||||||
|
mono: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'doh',
|
||||||
|
label: 'DoH',
|
||||||
|
value: mod.type === 'DOMAINS' ? dohPolicyRu(mod.doh_resolver_policy) : '—',
|
||||||
|
description:
|
||||||
|
mod.type === 'DOMAINS'
|
||||||
|
? dohIds.length
|
||||||
|
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join('; ')
|
||||||
|
: 'Системный DNS'
|
||||||
|
: 'не применимо',
|
||||||
|
icon: Network,
|
||||||
|
accent: statAccents[2]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'community',
|
||||||
|
label: 'Community по умолч.',
|
||||||
|
value: communityLabel(mod.default_community_id, communities),
|
||||||
|
description: 'для записей без своего community',
|
||||||
|
icon: ShieldCheck,
|
||||||
|
accent: statAccents[3]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'refreshed',
|
||||||
|
label: 'Последнее обновление',
|
||||||
|
value: formatDateTime(mod.last_refreshed_at),
|
||||||
|
description:
|
||||||
|
mod.type === 'AS_PREFIXES'
|
||||||
|
? `ASN: ${asEntries.length}, префиксов: ${asPrefixTotal}`
|
||||||
|
: 'время последнего refresh',
|
||||||
|
icon: RefreshCw,
|
||||||
|
accent: statAccents[4]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2 xl:grid-cols-5">
|
||||||
|
{#if loading}
|
||||||
|
{#each Array(5) as _, i (i)}
|
||||||
|
<CardSkeleton />
|
||||||
|
{/each}
|
||||||
|
{:else}
|
||||||
|
{#each kpiCards as card (card.id)}
|
||||||
|
{@const Icon = card.icon}
|
||||||
|
{@const a = card.accent}
|
||||||
|
<Card class={cn('overflow-hidden border-l-4 shadow-sm', a.border, a.bg)}>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<p class="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
<span
|
||||||
|
class={cn('flex size-7 shrink-0 items-center justify-center rounded-md', a.iconBg)}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<Icon class={cn('size-3.5', a.iconText)} />
|
||||||
|
</span>
|
||||||
|
{card.label}
|
||||||
|
</p>
|
||||||
|
<CardTitle
|
||||||
|
class={cn('text-base font-semibold break-all', card.mono ? 'font-mono text-sm' : '')}
|
||||||
|
>
|
||||||
|
{card.value}
|
||||||
|
</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<p class="line-clamp-2 text-xs text-muted-foreground">{card.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import type { AsEntry, CdnSource, DomainEntry, IpRangeEntry, ModuleRow } from '$lib/api/types.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
CardDescription
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
mod: ModuleRow;
|
||||||
|
asEntries: AsEntry[];
|
||||||
|
cdnSources: CdnSource[];
|
||||||
|
domainEntries: DomainEntry[];
|
||||||
|
ipEntries: IpRangeEntry[];
|
||||||
|
};
|
||||||
|
|
||||||
|
let { mod, asEntries, cdnSources, domainEntries, ipEntries }: Props = $props();
|
||||||
|
|
||||||
|
const asPrefixTotal = $derived(
|
||||||
|
asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">Операционный отчёт модуля</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Читаемая сводка по данным модуля: источники, объёмы и ожидаемый результат для
|
||||||
|
refresh/агрегации.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="grid gap-3 md:grid-cols-2">
|
||||||
|
{#if mod.type === 'DOMAINS'}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">Домены и ожидаемые IP</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
После refresh домены резолвятся в IP и конвертируются в префиксы.
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
{#each domainEntries.slice(0, 8) as entry (entry.id)}
|
||||||
|
<p class="font-mono text-xs break-all">{entry.fqdn}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">Нет доменов</p>
|
||||||
|
{/each}
|
||||||
|
{#if domainEntries.length > 8}
|
||||||
|
<p class="text-xs text-muted-foreground">…и ещё {domainEntries.length - 8}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if mod.type === 'AS_PREFIXES'}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">ASN и число полученных префиксов</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Счётчик префиксов обновляется после успешного refresh (RIPEstat).
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
<p class="text-xs">
|
||||||
|
Всего ASN: <span class="font-semibold">{asEntries.length}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-xs">
|
||||||
|
Сумма префиксов: <span class="font-semibold">{asPrefixTotal}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if mod.type === 'CDN_CIDRS'}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">CDN ссылки и импортируемые префиксы</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Каждый URL поставляет список CIDR для агрегации.
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
{#each cdnSources.slice(0, 6) as src (src.id)}
|
||||||
|
<p class="font-mono text-xs break-all">{src.url}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">Нет CDN источников</p>
|
||||||
|
{/each}
|
||||||
|
{#if cdnSources.length > 6}
|
||||||
|
<p class="text-xs text-muted-foreground">…и ещё {cdnSources.length - 6}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if mod.type === 'IP_RANGES'}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">IP ranges для агрегации</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Статические CIDR, которые попадают в итоговую ревизию.
|
||||||
|
</p>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
|
{#each ipEntries.slice(0, 8) as entry (entry.id)}
|
||||||
|
<p class="font-mono text-xs">{entry.prefix}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">Нет диапазонов</p>
|
||||||
|
{/each}
|
||||||
|
{#if ipEntries.length > 8}
|
||||||
|
<p class="text-xs text-muted-foreground">…и ещё {ipEntries.length - 8}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="rounded-lg border p-3">
|
||||||
|
<p class="text-sm font-medium">Результат операции</p>
|
||||||
|
<p class="text-xs text-muted-foreground">
|
||||||
|
Подробный результат по конкретному запуску refresh смотрите в Операции → Задачи →
|
||||||
|
module_refresh: там отображаются источники, количество префиксов и итог агрегации.
|
||||||
|
</p>
|
||||||
|
<Button variant="link" class="mt-2 h-auto p-0" href={resolve('/operations?tab=jobs')}>
|
||||||
|
Открыть задачи
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import type { BgpCommunity, DohProfile, ModuleRow } from '$lib/api/types.js';
|
||||||
|
|
||||||
|
export const NONE_OPTION = '__none__';
|
||||||
|
|
||||||
|
export function supportsCsvIO(type: ModuleRow['type'] | null | undefined): boolean {
|
||||||
|
return type === 'AS_PREFIXES' || type === 'DOMAINS' || type === 'IP_RANGES';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeFilenamePart(v: string): string {
|
||||||
|
const cleaned = v
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9._-]+/g, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^[-_.]+|[-_.]+$/g, '');
|
||||||
|
return cleaned || 'module';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function communityLabel(id: string | null, communities: BgpCommunity[]): string {
|
||||||
|
if (!id) return '—';
|
||||||
|
const c = communities.find((x) => x.id === id);
|
||||||
|
if (!c) return id.slice(0, 8) + '…';
|
||||||
|
const t = c.title?.trim();
|
||||||
|
return t || c.community;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function communityOptionLabel(c: BgpCommunity): string {
|
||||||
|
const t = c.title?.trim();
|
||||||
|
return t || c.community;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nullableSelectValue(value: string | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || value === '') return NONE_OPTION;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fromNullableSelect(value: string): string | null {
|
||||||
|
if (value === NONE_OPTION || value === '') return null;
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleDohProfileIds(modRow: ModuleRow | null): string[] {
|
||||||
|
if (!modRow) return [];
|
||||||
|
if (modRow.doh_profile_ids?.length) return modRow.doh_profile_ids;
|
||||||
|
return modRow.doh_profile_id ? [modRow.doh_profile_id] : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dohProfileLabel(id: string, dohProfiles: DohProfile[]): string {
|
||||||
|
const p = dohProfiles.find((d) => d.id === id);
|
||||||
|
return p ? (p.name?.trim() ? `${p.name} (${p.url})` : p.url) : id.slice(0, 8) + '…';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeCdnSourceKind(k: string): 'plaintext' | 'json' {
|
||||||
|
return k.trim().toLowerCase() === 'json' ? 'json' : 'plaintext';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function syncSelection(selected: Set<string>, existingIds: string[]): Set<string> {
|
||||||
|
const validIds = new Set(existingIds);
|
||||||
|
return new Set([...selected].filter((id) => validIds.has(id)));
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>
|
||||||
@@ -1,13 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { RevisionDiff, RevisionPrefix, RevisionRow } from '$lib/api/types.js';
|
import type { RevisionDiff, RevisionPrefix, RevisionRow } from '$lib/api/types.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from '$lib/components/ui/card/index.js';
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
import { sortRevisionDiffItems } from '$lib/sort-prefixes.js';
|
import { sortRevisionDiffItems } from '$lib/sort-prefixes.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
@@ -19,7 +21,6 @@
|
|||||||
onDiffRevAChange: (value: string) => void;
|
onDiffRevAChange: (value: string) => void;
|
||||||
onDiffRevBChange: (value: string) => void;
|
onDiffRevBChange: (value: string) => void;
|
||||||
onLoadDiff: () => void;
|
onLoadDiff: () => void;
|
||||||
formatDate: (d?: string | null) => string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -30,11 +31,13 @@
|
|||||||
diffLoading,
|
diffLoading,
|
||||||
onDiffRevAChange,
|
onDiffRevAChange,
|
||||||
onDiffRevBChange,
|
onDiffRevBChange,
|
||||||
onLoadDiff,
|
onLoadDiff
|
||||||
formatDate
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
/** Бэкенд отдаёт `prefixes.added` / `prefixes.removed`; верхний уровень added/removed — опционально. */
|
function revisionLabel(rev: RevisionRow): string {
|
||||||
|
return `${rev.id.slice(0, 8)}… (${formatDateTime(rev.created_at)})`;
|
||||||
|
}
|
||||||
|
|
||||||
function diffAddedRaw(d: RevisionDiff | null): (string | RevisionPrefix)[] {
|
function diffAddedRaw(d: RevisionDiff | null): (string | RevisionPrefix)[] {
|
||||||
if (!d) return [];
|
if (!d) return [];
|
||||||
if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added;
|
if (d.prefixes && Array.isArray(d.prefixes.added)) return d.prefixes.added;
|
||||||
@@ -82,26 +85,36 @@
|
|||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="min-h-0 space-y-4">
|
<CardContent class="min-h-0 space-y-4">
|
||||||
<div class="flex flex-col gap-2 sm:flex-row">
|
<div class="flex flex-col gap-2 sm:flex-row">
|
||||||
<select
|
<Select type="single" value={diffRevA} onValueChange={(v) => onDiffRevAChange(v ?? '')}>
|
||||||
value={diffRevA}
|
<SelectTrigger class="min-w-0 flex-1">
|
||||||
onchange={(e) => onDiffRevAChange((e.currentTarget as HTMLSelectElement).value)}
|
{diffRevA
|
||||||
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm"
|
? revisions.find((r) => r.id === diffRevA)
|
||||||
>
|
? revisionLabel(revisions.find((r) => r.id === diffRevA)!)
|
||||||
<option value="">Ревизия A</option>
|
: diffRevA
|
||||||
{#each revisions as rev (rev.id)}
|
: 'Ревизия A'}
|
||||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
</SelectTrigger>
|
||||||
{/each}
|
<SelectContent>
|
||||||
</select>
|
<SelectItem value="">Ревизия A</SelectItem>
|
||||||
<select
|
{#each revisions as rev (rev.id)}
|
||||||
value={diffRevB}
|
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
|
||||||
onchange={(e) => onDiffRevBChange((e.currentTarget as HTMLSelectElement).value)}
|
{/each}
|
||||||
class="h-8 min-w-0 flex-1 rounded-md border border-border bg-background px-2 text-sm"
|
</SelectContent>
|
||||||
>
|
</Select>
|
||||||
<option value="">Ревизия B</option>
|
<Select type="single" value={diffRevB} onValueChange={(v) => onDiffRevBChange(v ?? '')}>
|
||||||
{#each revisions as rev (rev.id)}
|
<SelectTrigger class="min-w-0 flex-1">
|
||||||
<option value={rev.id}>{rev.id.slice(0, 8)}… ({formatDate(rev.created_at)})</option>
|
{diffRevB
|
||||||
{/each}
|
? revisions.find((r) => r.id === diffRevB)
|
||||||
</select>
|
? revisionLabel(revisions.find((r) => r.id === diffRevB)!)
|
||||||
|
: diffRevB
|
||||||
|
: 'Ревизия B'}
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="">Ревизия B</SelectItem>
|
||||||
|
{#each revisions as rev (rev.id)}
|
||||||
|
<SelectItem value={rev.id}>{revisionLabel(rev)}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
class="shrink-0 self-start sm:self-auto"
|
class="shrink-0 self-start sm:self-auto"
|
||||||
@@ -117,10 +130,9 @@
|
|||||||
class="grid min-h-0 grid-cols-1 overflow-hidden rounded-md border border-border sm:grid-cols-2"
|
class="grid min-h-0 grid-cols-1 overflow-hidden rounded-md border border-border sm:grid-cols-2"
|
||||||
style="scrollbar-gutter: stable;"
|
style="scrollbar-gutter: stable;"
|
||||||
>
|
>
|
||||||
<!-- + Добавлено -->
|
|
||||||
<div class="flex min-h-0 min-w-0 flex-col border-b border-border sm:border-r sm:border-b-0">
|
<div class="flex min-h-0 min-w-0 flex-col border-b border-border sm:border-r sm:border-b-0">
|
||||||
<div
|
<div
|
||||||
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-green-600 dark:text-green-400"
|
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-success"
|
||||||
>
|
>
|
||||||
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">+</span>
|
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">+</span>
|
||||||
<span>Добавлено ({addedSorted.length})</span>
|
<span>Добавлено ({addedSorted.length})</span>
|
||||||
@@ -137,7 +149,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#each addedSorted as line, i (`a-${i}-${line}`)}
|
{#each addedSorted as line, i (`a-${i}-${line}`)}
|
||||||
<tr
|
<tr
|
||||||
class="border-b border-l-2 border-border/50 border-l-green-500/80 bg-green-500/[0.08] hover:bg-muted/30 dark:bg-green-500/15"
|
class="border-b border-l-2 border-success/30 bg-success/5 hover:bg-muted/30"
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
|
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
|
||||||
@@ -157,10 +169,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- − Удалено -->
|
|
||||||
<div class="flex min-h-0 min-w-0 flex-col">
|
<div class="flex min-h-0 min-w-0 flex-col">
|
||||||
<div
|
<div
|
||||||
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-red-600 dark:text-red-400"
|
class="flex shrink-0 items-center border-b border-border bg-muted/60 px-3 py-2 font-mono text-xs font-semibold text-destructive"
|
||||||
>
|
>
|
||||||
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">−</span>
|
<span class="mr-2 w-10 shrink-0 text-right text-muted-foreground select-none">−</span>
|
||||||
<span>Удалено ({removedSorted.length})</span>
|
<span>Удалено ({removedSorted.length})</span>
|
||||||
@@ -177,7 +188,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{#each removedSorted as line, i (`r-${i}-${line}`)}
|
{#each removedSorted as line, i (`r-${i}-${line}`)}
|
||||||
<tr
|
<tr
|
||||||
class="border-b border-l-2 border-border/50 border-l-red-500/80 bg-red-500/[0.08] hover:bg-muted/30 dark:bg-red-500/15"
|
class="border-b border-l-2 border-destructive/30 bg-destructive/5 hover:bg-muted/30"
|
||||||
>
|
>
|
||||||
<td
|
<td
|
||||||
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
|
class="w-10 shrink-0 border-r border-transparent py-0.5 pr-1 pl-2 text-right align-top text-[11px] text-muted-foreground tabular-nums select-none"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
import { Input } from '$lib/ui/core/input/index.js';
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
import { Label } from '$lib/ui/core/label/index.js';
|
||||||
import Filter from '@lucide/svelte/icons/filter';
|
import Filter from '@lucide/svelte/icons/filter';
|
||||||
import Search from '@lucide/svelte/icons/search';
|
import Search from '@lucide/svelte/icons/search';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
|
|||||||
@@ -2,15 +2,17 @@
|
|||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import type { JobRow } from '$lib/api/types.js';
|
import type { JobRow } from '$lib/api/types.js';
|
||||||
import type { JobDetailedReport, JobLogEntry } from './types.js';
|
import type { JobDetailedReport, JobLogEntry } from './types.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from '$lib/components/ui/card/index.js';
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
|
||||||
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import Eye from '@lucide/svelte/icons/eye';
|
import Eye from '@lucide/svelte/icons/eye';
|
||||||
import X from '@lucide/svelte/icons/x';
|
import X from '@lucide/svelte/icons/x';
|
||||||
@@ -49,7 +51,6 @@
|
|||||||
getJobLogEntries: (job: JobRow) => JobLogEntry[];
|
getJobLogEntries: (job: JobRow) => JobLogEntry[];
|
||||||
getJobLogTotal: (job: JobRow, entries?: JobLogEntry[]) => number;
|
getJobLogTotal: (job: JobRow, entries?: JobLogEntry[]) => number;
|
||||||
jobStatusVariant: (status: string) => 'default' | 'secondary' | 'outline' | 'destructive';
|
jobStatusVariant: (status: string) => 'default' | 'secondary' | 'outline' | 'destructive';
|
||||||
formatDate: (d?: string | null) => string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -69,8 +70,7 @@
|
|||||||
isJobExpanded,
|
isJobExpanded,
|
||||||
getJobLogEntries,
|
getJobLogEntries,
|
||||||
getJobLogTotal,
|
getJobLogTotal,
|
||||||
jobStatusVariant,
|
jobStatusVariant
|
||||||
formatDate
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef<
|
const reportCols = reportRowColumns as import('@tanstack/table-core').ColumnDef<
|
||||||
@@ -178,7 +178,7 @@
|
|||||||
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
|
<CalendarClock class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Создана
|
Создана
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1">{formatDate(job.created_at)}</p>
|
<p class="mt-1">{formatDateTime(job.created_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
|
class="rounded-md border border-chart-3/25 bg-chart-3/5 px-2.5 py-2 dark:bg-chart-3/10"
|
||||||
@@ -187,7 +187,7 @@
|
|||||||
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
|
<PlayCircle class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Запущена
|
Запущена
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1">{formatDate(job.started_at)}</p>
|
<p class="mt-1">{formatDateTime(job.started_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
|
class="rounded-md border border-chart-4/25 bg-chart-4/5 px-2.5 py-2 dark:bg-chart-4/10"
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
|
<Flag class="size-3.5 shrink-0" aria-hidden="true" />
|
||||||
Завершена
|
Завершена
|
||||||
</p>
|
</p>
|
||||||
<p class="mt-1">{formatDate(job.finished_at)}</p>
|
<p class="mt-1">{formatDateTime(job.finished_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -450,9 +450,14 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="text-muted-foreground py-10 text-center text-sm">
|
{#if jobsLoading}
|
||||||
{jobsLoading ? 'Загрузка…' : 'Нет задач'}
|
<div class="py-8 text-center text-sm text-muted-foreground">Загрузка…</div>
|
||||||
</div>
|
{:else}
|
||||||
|
<EmptyState
|
||||||
|
title="Нет задач"
|
||||||
|
description="Задачи появятся после refresh, apply или rollback."
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { BirdStatus } from '$lib/api/types.js';
|
import type { BirdStatus } from '$lib/api/types.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import { Card } from '$lib/components/ui/card/index.js';
|
import { Card } from '$lib/ui/core/card/index.js';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import Play from '@lucide/svelte/icons/play';
|
import Play from '@lucide/svelte/icons/play';
|
||||||
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
|
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
|
||||||
@@ -114,9 +114,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="font-semibold">Состояние BIRD</p>
|
<p class="font-semibold">Состояние BIRD</p>
|
||||||
{#if birdStatus}
|
{#if birdStatus}
|
||||||
<Badge variant={birdHealthyBadgeVariant(birdStatus.healthy)}
|
<Badge
|
||||||
>{birdHealthyShortLabel(birdStatus.healthy)}</Badge
|
variant={birdHealthyBadgeVariant(birdStatus.healthy)}
|
||||||
|
class={birdStatus.healthy === true
|
||||||
|
? 'border-success/30 bg-success/15 text-success'
|
||||||
|
: undefined}
|
||||||
>
|
>
|
||||||
|
{birdHealthyShortLabel(birdStatus.healthy)}
|
||||||
|
</Badge>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
<p class="max-w-[56ch] text-sm text-foreground/80">
|
<p class="max-w-[56ch] text-sm text-foreground/80">
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { RevisionRow } from '$lib/api/types.js';
|
import type { RevisionRow } from '$lib/api/types.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
CardDescription,
|
CardDescription,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle
|
CardTitle
|
||||||
} from '$lib/components/ui/card/index.js';
|
} from '$lib/ui/core/card/index.js';
|
||||||
import {
|
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||||
Table,
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow
|
|
||||||
} from '$lib/components/ui/table/index.js';
|
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import Undo from '@lucide/svelte/icons/undo';
|
import Undo from '@lucide/svelte/icons/undo';
|
||||||
import Eye from '@lucide/svelte/icons/eye';
|
import Eye from '@lucide/svelte/icons/eye';
|
||||||
@@ -28,7 +22,6 @@
|
|||||||
onOpenPreview: (rev: RevisionRow) => void;
|
onOpenPreview: (rev: RevisionRow) => void;
|
||||||
onRollbackRequest: (rev: RevisionRow) => void;
|
onRollbackRequest: (rev: RevisionRow) => void;
|
||||||
onDownloadDiagnosticLog: (rev: RevisionRow) => void;
|
onDownloadDiagnosticLog: (rev: RevisionRow) => void;
|
||||||
formatDate: (d?: string | null) => string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
@@ -37,9 +30,31 @@
|
|||||||
onReload,
|
onReload,
|
||||||
onOpenPreview,
|
onOpenPreview,
|
||||||
onRollbackRequest,
|
onRollbackRequest,
|
||||||
onDownloadDiagnosticLog,
|
onDownloadDiagnosticLog
|
||||||
formatDate
|
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
id: 'id',
|
||||||
|
label: 'ID',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (rev: RevisionRow) => rev.id
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'created',
|
||||||
|
label: 'Создана',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (rev: RevisionRow) => rev.created_at ?? ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prefixes',
|
||||||
|
label: 'Префиксов',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (rev: RevisionRow) => rev.materialized_prefix_count ?? 0
|
||||||
|
},
|
||||||
|
{ id: 'hash', label: 'Хэш' },
|
||||||
|
{ id: 'actions', label: '', class: 'w-32' }
|
||||||
|
] as const;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
@@ -61,55 +76,45 @@
|
|||||||
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
|
<RefreshCw class={revLoading ? 'animate-spin' : ''} />
|
||||||
</Button>
|
</Button>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="min-w-0 p-0">
|
<CardContent class="min-w-0 p-4 pt-0">
|
||||||
<div class="max-w-full overflow-x-auto overscroll-x-contain [scrollbar-gutter:stable]">
|
<AppDataTable
|
||||||
<Table class="min-w-[44rem]">
|
columns={[...columns]}
|
||||||
<TableHeader>
|
rows={revisions}
|
||||||
<TableRow>
|
rowKey={(rev) => rev.id}
|
||||||
<TableHead>ID</TableHead>
|
loading={revLoading}
|
||||||
<TableHead>Создана</TableHead>
|
emptyTitle="Нет ревизий"
|
||||||
<TableHead>Префиксов</TableHead>
|
emptyDescription="Ревизии появятся после обновления модулей."
|
||||||
<TableHead>Хэш</TableHead>
|
>
|
||||||
<TableHead class="w-32"></TableHead>
|
{#snippet cell({ row: rev, column })}
|
||||||
</TableRow>
|
{#if column.id === 'id'}
|
||||||
</TableHeader>
|
<span class="font-mono text-xs">{rev.id.slice(0, 8)}…</span>
|
||||||
<TableBody>
|
{:else if column.id === 'created'}
|
||||||
{#each revisions as rev (rev.id)}
|
<span class="text-sm">{formatDateTime(rev.created_at)}</span>
|
||||||
<TableRow>
|
{:else if column.id === 'prefixes'}
|
||||||
<TableCell class="font-mono text-xs">{rev.id.slice(0, 8)}…</TableCell>
|
{rev.materialized_prefix_count}
|
||||||
<TableCell class="text-sm">{formatDate(rev.created_at)}</TableCell>
|
{:else if column.id === 'hash'}
|
||||||
<TableCell>{rev.materialized_prefix_count}</TableCell>
|
<span class="font-mono text-xs text-muted-foreground"
|
||||||
<TableCell class="font-mono text-xs text-muted-foreground"
|
>{rev.content_hash.slice(0, 12)}…</span
|
||||||
>{rev.content_hash.slice(0, 12)}…</TableCell
|
>
|
||||||
>
|
{:else if column.id === 'actions'}
|
||||||
<TableCell>
|
<div class="flex gap-1">
|
||||||
<div class="flex gap-1">
|
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
|
||||||
<Button variant="ghost" size="icon-sm" onclick={() => onOpenPreview(rev)}>
|
<Eye class="size-3.5" />
|
||||||
<Eye class="size-3.5" />
|
</Button>
|
||||||
</Button>
|
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
|
||||||
<Button variant="ghost" size="icon-sm" onclick={() => onRollbackRequest(rev)}>
|
<Undo class="size-3.5" />
|
||||||
<Undo class="size-3.5" />
|
</Button>
|
||||||
</Button>
|
<Button
|
||||||
<Button
|
variant="ghost"
|
||||||
variant="ghost"
|
size="icon-sm"
|
||||||
size="icon-sm"
|
title="Скачать диагностический лог"
|
||||||
title="Скачать диагностический лог"
|
onclick={() => onDownloadDiagnosticLog(rev)}
|
||||||
onclick={() => onDownloadDiagnosticLog(rev)}
|
>
|
||||||
>
|
<Download class="size-3.5" />
|
||||||
<Download class="size-3.5" />
|
</Button>
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
{/if}
|
||||||
</TableCell>
|
{/snippet}
|
||||||
</TableRow>
|
</AppDataTable>
|
||||||
{:else}
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colspan={5} class="text-muted-foreground py-8 text-center">
|
|
||||||
{revLoading ? 'Загрузка…' : 'Нет ревизий'}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{/each}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -6,9 +6,9 @@
|
|||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
getPaginationRowModel
|
getPaginationRowModel
|
||||||
} from '@tanstack/table-core';
|
} from '@tanstack/table-core';
|
||||||
import { createSvelteTable, FlexRender } from '$lib/components/ui/data-table/index.js';
|
import { createSvelteTable, FlexRender } from '$lib/ui/core/data-table/index.js';
|
||||||
import * as Table from '$lib/components/ui/table/index.js';
|
import * as Table from '$lib/ui/core/table/index.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
rows: RowData[];
|
rows: RowData[];
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import type { ModuleRow } from '$lib/api/types.js';
|
||||||
|
|
||||||
|
/** Форматирование ISO-даты для таблиц модулей и расписания. */
|
||||||
|
export function formatDateTime(value: string | null | undefined): string {
|
||||||
|
if (typeof value !== 'string' || value.trim().length === 0) return '—';
|
||||||
|
const parsed = new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) return '—';
|
||||||
|
return parsed.toLocaleString('ru-RU');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подпись интервала refresh: секунды, cron или комбинация. */
|
||||||
|
export function moduleIntervalLabel(moduleRow: ModuleRow): string {
|
||||||
|
const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : '';
|
||||||
|
const raw = moduleRow.refresh_interval_sec as unknown;
|
||||||
|
const interval =
|
||||||
|
typeof raw === 'number'
|
||||||
|
? raw
|
||||||
|
: typeof raw === 'string' && raw.trim().length > 0
|
||||||
|
? Number(raw)
|
||||||
|
: null;
|
||||||
|
const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : '';
|
||||||
|
if (cron && intervalLabel) return `${intervalLabel} (${cron})`;
|
||||||
|
if (cron) return cron;
|
||||||
|
if (intervalLabel) return intervalLabel;
|
||||||
|
return '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moduleTypeBadgeVariant(
|
||||||
|
type: string
|
||||||
|
): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||||
|
switch (type) {
|
||||||
|
case 'AS_PREFIXES':
|
||||||
|
return 'default';
|
||||||
|
case 'CDN_CIDRS':
|
||||||
|
return 'secondary';
|
||||||
|
case 'DOMAINS':
|
||||||
|
return 'outline';
|
||||||
|
case 'IP_RANGES':
|
||||||
|
return 'outline';
|
||||||
|
default:
|
||||||
|
return 'outline';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
/** Pure-helpers для страницы /monitoring. */
|
||||||
|
|
||||||
|
export type OverallStatus = 'ok' | 'warn' | 'error' | 'unknown';
|
||||||
|
|
||||||
|
export type JobsKpi = { running: number; failed: number; total: number };
|
||||||
|
|
||||||
|
export type ReadyStatus = { status: string; checks?: Record<string, unknown> };
|
||||||
|
|
||||||
|
export type HealthStatus = { ok: boolean; status?: string; error?: string };
|
||||||
|
|
||||||
|
export type CheckBadge = {
|
||||||
|
label: string;
|
||||||
|
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||||
|
class?: string;
|
||||||
|
hint?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function toErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error && error.message.trim() !== '') return error.message;
|
||||||
|
return 'Ошибка запроса';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeJobsStatuses(items: Array<{ status?: string }>): JobsKpi {
|
||||||
|
let running = 0;
|
||||||
|
let failed = 0;
|
||||||
|
for (const row of items) {
|
||||||
|
const status = String(row.status ?? '').toLowerCase();
|
||||||
|
if (status === 'queued' || status === 'running' || status === 'cancel_requested') {
|
||||||
|
running += 1;
|
||||||
|
}
|
||||||
|
if (status === 'failed' || status === 'error' || status === 'canceled') {
|
||||||
|
failed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { running, failed, total: items.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveOverallStatus(input: {
|
||||||
|
health: HealthStatus | null;
|
||||||
|
ready: ReadyStatus | null;
|
||||||
|
jobs: JobsKpi | null;
|
||||||
|
birdConfigured: boolean;
|
||||||
|
birdHealthy: boolean | null;
|
||||||
|
}): OverallStatus {
|
||||||
|
const { health, ready, jobs, birdConfigured, birdHealthy } = input;
|
||||||
|
if (health === null && ready === null && jobs === null) return 'unknown';
|
||||||
|
if (!health?.ok) return 'error';
|
||||||
|
if (ready !== null && ready.status !== 'ready') return 'error';
|
||||||
|
if (jobs !== null && jobs.failed > 0) return 'warn';
|
||||||
|
if (birdConfigured && birdHealthy === false) return 'warn';
|
||||||
|
return 'ok';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overallStatusLabel(status: OverallStatus): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'ok':
|
||||||
|
return 'В норме';
|
||||||
|
case 'warn':
|
||||||
|
return 'Внимание';
|
||||||
|
case 'error':
|
||||||
|
return 'Ошибка';
|
||||||
|
default:
|
||||||
|
return 'Нет данных';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overallStatusHint(
|
||||||
|
status: OverallStatus,
|
||||||
|
input: {
|
||||||
|
healthOk: boolean | undefined;
|
||||||
|
jobsFailed: number;
|
||||||
|
}
|
||||||
|
): string {
|
||||||
|
if (status === 'unknown') return 'Нет данных. Запустите обновление.';
|
||||||
|
if (status === 'error') {
|
||||||
|
if (!input.healthOk) return 'Проверьте доступность API и логи сервиса.';
|
||||||
|
return 'Readiness не в норме: проверьте postgres/store/jobs.';
|
||||||
|
}
|
||||||
|
if (status === 'warn') {
|
||||||
|
if (input.jobsFailed > 0) return 'Есть ошибки в задачах: откройте операции и последние jobs.';
|
||||||
|
return 'Проверьте BGP-сессии и вывод birdc.';
|
||||||
|
}
|
||||||
|
return 'Критичных отклонений не обнаружено.';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overallBadgeVariant(status: OverallStatus): CheckBadge['variant'] {
|
||||||
|
if (status === 'ok') return 'default';
|
||||||
|
if (status === 'warn') return 'secondary';
|
||||||
|
if (status === 'error') return 'destructive';
|
||||||
|
return 'outline';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function overallBadgeClass(status: OverallStatus): string | undefined {
|
||||||
|
if (status === 'ok') return 'border-success/30 bg-success/15 text-success';
|
||||||
|
if (status === 'warn') return 'border-warning/30 bg-warning/15 text-warning';
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Нормализация значений ready.checks и health/readiness в badge. */
|
||||||
|
export function checkStatusBadge(value: unknown): CheckBadge {
|
||||||
|
const raw = String(value ?? '').trim();
|
||||||
|
const lower = raw.toLowerCase();
|
||||||
|
|
||||||
|
if (lower === 'ok' || lower === 'ready' || lower === 'true' || lower === 'up') {
|
||||||
|
return {
|
||||||
|
label: 'OK',
|
||||||
|
variant: 'default',
|
||||||
|
class: 'border-success/30 bg-success/15 text-success'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (lower === 'memory') {
|
||||||
|
return {
|
||||||
|
label: 'In-memory',
|
||||||
|
variant: 'secondary',
|
||||||
|
hint: 'Очередь задач в памяти процесса, не shared между воркерами.'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
lower === 'failed' ||
|
||||||
|
lower === 'false' ||
|
||||||
|
lower === 'error' ||
|
||||||
|
lower === 'down' ||
|
||||||
|
lower === 'unavailable'
|
||||||
|
) {
|
||||||
|
return { label: 'Ошибка', variant: 'destructive' };
|
||||||
|
}
|
||||||
|
if (raw === '') {
|
||||||
|
return { label: '—', variant: 'outline' };
|
||||||
|
}
|
||||||
|
return { label: raw, variant: 'outline' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Человекочитаемое имя проверки readiness. */
|
||||||
|
export function checkDisplayName(key: string): string {
|
||||||
|
switch (key) {
|
||||||
|
case 'postgres':
|
||||||
|
return 'PostgreSQL';
|
||||||
|
case 'store':
|
||||||
|
return 'Хранилище';
|
||||||
|
case 'jobs':
|
||||||
|
return 'Очередь задач';
|
||||||
|
default:
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function livenessBadge(health: HealthStatus | null): CheckBadge {
|
||||||
|
if (health === null) return { label: '—', variant: 'outline' };
|
||||||
|
if (health.ok) {
|
||||||
|
return {
|
||||||
|
label: 'В норме',
|
||||||
|
variant: 'default',
|
||||||
|
class: 'border-success/30 bg-success/15 text-success'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { label: 'Недоступен', variant: 'destructive' };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readinessBadge(ready: ReadyStatus | null): CheckBadge {
|
||||||
|
if (ready === null) return { label: '—', variant: 'outline' };
|
||||||
|
if (ready.status === 'ready') {
|
||||||
|
return {
|
||||||
|
label: 'Готов',
|
||||||
|
variant: 'default',
|
||||||
|
class: 'border-success/30 bg-success/15 text-success'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { label: ready.status || 'Не готов', variant: 'destructive' };
|
||||||
|
}
|
||||||
@@ -1,5 +1,3 @@
|
|||||||
/** Русские подписи для enum из API (задачи, модули, логи refresh). */
|
|
||||||
|
|
||||||
export function jobStatusRu(status: string): string {
|
export function jobStatusRu(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'queued':
|
case 'queued':
|
||||||
@@ -17,6 +15,18 @@ export function jobStatusRu(status: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Variant Badge для статуса задачи (shadcn). */
|
||||||
|
export function jobStatusBadgeVariant(
|
||||||
|
status: string
|
||||||
|
): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||||
|
const lower = status.toLowerCase();
|
||||||
|
if (lower === 'succeeded') return 'default';
|
||||||
|
if (lower === 'running' || lower === 'queued') return 'secondary';
|
||||||
|
if (lower === 'failed' || lower === 'error' || lower === 'canceled' || lower === 'cancelled')
|
||||||
|
return 'destructive';
|
||||||
|
return 'outline';
|
||||||
|
}
|
||||||
|
|
||||||
/** Подпись типа задачи для фильтров (значения API те же). */
|
/** Подпись типа задачи для фильтров (значения API те же). */
|
||||||
export function jobKindFilterRu(kind: string): string {
|
export function jobKindFilterRu(kind: string): string {
|
||||||
switch (kind) {
|
switch (kind) {
|
||||||
@@ -33,6 +43,32 @@ export function jobKindFilterRu(kind: string): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Включён / выключен модуль (поле enabled). */
|
||||||
|
export function moduleEnabledRu(enabled: boolean): string {
|
||||||
|
return enabled ? 'Вкл' : 'Выкл';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Variant Badge для enabled модуля. */
|
||||||
|
export function moduleEnabledBadgeVariant(
|
||||||
|
enabled: boolean
|
||||||
|
): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||||
|
return enabled ? 'default' : 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Политика DoH-резолвера для модулей DOMAINS. */
|
||||||
|
export function dohPolicyRu(policy: string | null | undefined): string {
|
||||||
|
switch (policy) {
|
||||||
|
case 'primary_only':
|
||||||
|
return 'Только первый';
|
||||||
|
case 'failover':
|
||||||
|
return 'Резервирование';
|
||||||
|
case 'union':
|
||||||
|
return 'Объединение';
|
||||||
|
default:
|
||||||
|
return 'Только первый';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function moduleTypeRu(type: string): string {
|
export function moduleTypeRu(type: string): string {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'AS_PREFIXES':
|
case 'AS_PREFIXES':
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ import Gauge from '@lucide/svelte/icons/gauge';
|
|||||||
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
import LayoutDashboard from '@lucide/svelte/icons/layout-dashboard';
|
||||||
import Network from '@lucide/svelte/icons/network';
|
import Network from '@lucide/svelte/icons/network';
|
||||||
import Settings from '@lucide/svelte/icons/settings';
|
import Settings from '@lucide/svelte/icons/settings';
|
||||||
import Zap from '@lucide/svelte/icons/zap';
|
|
||||||
|
|
||||||
export type NavItem = {
|
export type NavItem = {
|
||||||
href: string;
|
href: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -20,8 +18,7 @@ export const mainNav: NavItem[] = [
|
|||||||
{ href: '/modules', label: 'Модули', icon: Boxes },
|
{ href: '/modules', label: 'Модули', icon: Boxes },
|
||||||
{ href: '/directories', label: 'Справочники', icon: BookOpen },
|
{ href: '/directories', label: 'Справочники', icon: BookOpen },
|
||||||
{ href: '/network', label: 'Сеть', icon: Network },
|
{ href: '/network', label: 'Сеть', icon: Network },
|
||||||
{ href: '/revisions', label: 'Ревизии', icon: Activity },
|
{ href: '/operations', label: 'Ревизии', icon: Activity },
|
||||||
{ href: '/operations', label: 'Операции', icon: Zap },
|
|
||||||
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
|
{ href: '/schedule', label: 'Расписание', icon: CalendarClock },
|
||||||
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge }
|
{ href: '/monitoring', label: 'Мониторинг', icon: Gauge }
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|||||||
+153
-175
@@ -1,65 +1,105 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||||
import type { ModuleRow, ModulesResponse, ModuleCreate } from '$lib/api/types.js';
|
import type { ModuleRow, ModulesResponse } from '$lib/api/types.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '$lib/components/ui/card/index.js';
|
|
||||||
import { Input } from '$lib/components/ui/input/index.js';
|
|
||||||
import { Label } from '$lib/components/ui/label/index.js';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
formatDateTime,
|
||||||
DialogContent,
|
moduleIntervalLabel,
|
||||||
DialogHeader,
|
moduleTypeBadgeVariant
|
||||||
DialogTitle,
|
} from '$lib/modules/display.js';
|
||||||
DialogFooter,
|
import { moduleEnabledRu, moduleEnabledBadgeVariant, moduleTypeRu } from '$lib/ui-labels.js';
|
||||||
DialogDescription
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
} from '$lib/components/ui/dialog/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import {
|
import {
|
||||||
Select,
|
Card,
|
||||||
SelectContent,
|
CardContent,
|
||||||
SelectItem,
|
CardHeader,
|
||||||
SelectTrigger
|
CardTitle,
|
||||||
} from '$lib/components/ui/select/index.js';
|
CardDescription
|
||||||
import { Switch } from '$lib/components/ui/switch/index.js';
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
import { Checkbox } from '$lib/ui/core/checkbox/index.js';
|
||||||
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||||
|
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||||
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import { resolve } from '$app/paths';
|
import ModuleCreateDialog from '$lib/components/modules/ModuleCreateDialog.svelte';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
import Plus from '@lucide/svelte/icons/plus';
|
import Plus from '@lucide/svelte/icons/plus';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import ExternalLink from '@lucide/svelte/icons/external-link';
|
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||||
import Trash2 from '@lucide/svelte/icons/trash-2';
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
import { moduleTypeRu } from '$lib/ui-labels.js';
|
|
||||||
import Boxes from '@lucide/svelte/icons/boxes';
|
import Boxes from '@lucide/svelte/icons/boxes';
|
||||||
|
import Info from '@lucide/svelte/icons/info';
|
||||||
|
import CheckCircle2 from '@lucide/svelte/icons/check-circle-2';
|
||||||
|
import CircleOff from '@lucide/svelte/icons/circle-off';
|
||||||
|
|
||||||
let rows = $state<ModuleRow[]>([]);
|
let rows = $state<ModuleRow[]>([]);
|
||||||
let loading = $state(false);
|
let loading = $state(false);
|
||||||
|
let initialLoading = $state(true);
|
||||||
let dialogOpen = $state(false);
|
let dialogOpen = $state(false);
|
||||||
let saving = $state(false);
|
|
||||||
let selectedModuleIds = $state(new Set<string>());
|
|
||||||
let deletingBulkModules = $state(false);
|
let deletingBulkModules = $state(false);
|
||||||
|
let selectedModuleIds = $state(new Set<string>());
|
||||||
|
let lastUpdated = $state<Date | null>(null);
|
||||||
|
|
||||||
const selectedModulesCount = $derived(selectedModuleIds.size);
|
const selectedModulesCount = $derived(selectedModuleIds.size);
|
||||||
const allModulesSelected = $derived(rows.length > 0 && selectedModuleIds.size === rows.length);
|
const allModulesSelected = $derived(rows.length > 0 && selectedModuleIds.size === rows.length);
|
||||||
const someModulesSelected = $derived(selectedModuleIds.size > 0 && !allModulesSelected);
|
const enabledCount = $derived(rows.filter((m) => m.enabled).length);
|
||||||
|
const disabledCount = $derived(rows.filter((m) => !m.enabled).length);
|
||||||
|
|
||||||
let form = $state<ModuleCreate>({
|
const statAccents = [
|
||||||
type: 'AS_PREFIXES',
|
{
|
||||||
name: '',
|
border: 'border-l-chart-1',
|
||||||
enabled: true,
|
bg: 'bg-chart-1/5',
|
||||||
priority: 0
|
iconBg: 'bg-chart-1/15',
|
||||||
});
|
iconText: 'text-chart-1'
|
||||||
|
},
|
||||||
const moduleTypes = [
|
{
|
||||||
{ value: 'AS_PREFIXES', label: 'AS (номера)' },
|
border: 'border-l-chart-2',
|
||||||
{ value: 'CDN_CIDRS', label: 'CDN CIDRs' },
|
bg: 'bg-chart-2/5',
|
||||||
{ value: 'DOMAINS', label: 'Домены' },
|
iconBg: 'bg-chart-2/15',
|
||||||
{ value: 'IP_RANGES', label: 'IP Ranges' }
|
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;
|
] as const;
|
||||||
|
|
||||||
|
const kpiCards = $derived.by(() => [
|
||||||
|
{
|
||||||
|
id: 'total',
|
||||||
|
label: 'Всего модулей',
|
||||||
|
value: initialLoading ? '—' : String(rows.length),
|
||||||
|
description: 'AS, CDN, домены, IP',
|
||||||
|
icon: Boxes,
|
||||||
|
accent: statAccents[0],
|
||||||
|
badge: 'в системе'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'enabled',
|
||||||
|
label: 'Включено',
|
||||||
|
value: initialLoading ? '—' : String(enabledCount),
|
||||||
|
description: 'участвуют в ревизиях',
|
||||||
|
icon: CheckCircle2,
|
||||||
|
accent: statAccents[1],
|
||||||
|
badge: 'активных'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'disabled',
|
||||||
|
label: 'Выключено',
|
||||||
|
value: initialLoading ? '—' : String(disabledCount),
|
||||||
|
description: disabledCount > 0 ? 'не участвуют в сборке' : 'все модули включены',
|
||||||
|
icon: CircleOff,
|
||||||
|
accent: statAccents[2],
|
||||||
|
badge: disabledCount > 0 ? 'отключены' : 'нет отключённых'
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
const moduleColumns = [
|
const moduleColumns = [
|
||||||
{ id: 'select', label: '', class: 'w-10' },
|
{ id: 'select', label: '', class: 'w-10' },
|
||||||
{ id: 'name', label: 'Название', sortable: true, sortValue: (m: ModuleRow) => m.name },
|
{ id: 'name', label: 'Название', sortable: true, sortValue: (m: ModuleRow) => m.name },
|
||||||
@@ -82,78 +122,23 @@
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
if (!initialLoading) loading = true;
|
||||||
try {
|
try {
|
||||||
const m = await apiJSON<ModulesResponse>('/v1/modules?limit=200');
|
const m = await apiJSON<ModulesResponse>('/v1/modules?limit=200');
|
||||||
rows = m.items ?? [];
|
rows = m.items ?? [];
|
||||||
const validIds = new Set(rows.map((item) => item.id));
|
const validIds = new Set(rows.map((item) => item.id));
|
||||||
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
|
selectedModuleIds = new Set([...selectedModuleIds].filter((id) => validIds.has(id)));
|
||||||
|
lastUpdated = new Date();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
notifyApiError(e);
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
loading = false;
|
loading = false;
|
||||||
|
initialLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
|
|
||||||
async function create() {
|
|
||||||
if (!form.name.trim()) {
|
|
||||||
notify.error('Укажите название модуля');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
saving = true;
|
|
||||||
try {
|
|
||||||
await apiMutate('/v1/modules', 'POST', form);
|
|
||||||
notify.success('Модуль создан');
|
|
||||||
dialogOpen = false;
|
|
||||||
form = { type: 'AS_PREFIXES', name: '', enabled: true, priority: 0 };
|
|
||||||
await load();
|
|
||||||
} catch (e) {
|
|
||||||
notifyApiError(e);
|
|
||||||
} finally {
|
|
||||||
saving = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function typeBadgeVariant(type: string) {
|
|
||||||
switch (type) {
|
|
||||||
case 'AS_PREFIXES':
|
|
||||||
return 'default';
|
|
||||||
case 'CDN_CIDRS':
|
|
||||||
return 'secondary';
|
|
||||||
case 'DOMAINS':
|
|
||||||
return 'outline';
|
|
||||||
case 'IP_RANGES':
|
|
||||||
return 'outline';
|
|
||||||
default:
|
|
||||||
return 'outline';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function moduleIntervalLabel(moduleRow: ModuleRow): string {
|
|
||||||
const cron = typeof moduleRow.cron_expr === 'string' ? moduleRow.cron_expr.trim() : '';
|
|
||||||
const raw = moduleRow.refresh_interval_sec as unknown;
|
|
||||||
const interval =
|
|
||||||
typeof raw === 'number'
|
|
||||||
? raw
|
|
||||||
: typeof raw === 'string' && raw.trim().length > 0
|
|
||||||
? Number(raw)
|
|
||||||
: null;
|
|
||||||
const intervalLabel = interval !== null && Number.isFinite(interval) ? `${interval}с` : '';
|
|
||||||
if (cron && intervalLabel) return `${intervalLabel} (${cron})`;
|
|
||||||
if (cron) return cron;
|
|
||||||
if (intervalLabel) return intervalLabel;
|
|
||||||
return '—';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDateTime(value: string | null | undefined): string {
|
|
||||||
if (typeof value !== 'string' || value.trim().length === 0) return '—';
|
|
||||||
const parsed = new Date(value);
|
|
||||||
if (Number.isNaN(parsed.getTime())) return '—';
|
|
||||||
return parsed.toLocaleString('ru-RU');
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleModuleSelection(id: string) {
|
function toggleModuleSelection(id: string) {
|
||||||
const next = new Set(selectedModuleIds);
|
const next = new Set(selectedModuleIds);
|
||||||
if (next.has(id)) next.delete(id);
|
if (next.has(id)) next.delete(id);
|
||||||
@@ -202,7 +187,9 @@
|
|||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Модули префиксов"
|
title="Модули префиксов"
|
||||||
description="Управление модулями — AS, CDN, домены, IP-диапазоны."
|
description={lastUpdated
|
||||||
|
? `Управление модулями — AS, CDN, домены, IP-диапазоны. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||||
|
: 'Управление модулями — AS, CDN, домены, IP-диапазоны.'}
|
||||||
icon={Boxes}
|
icon={Boxes}
|
||||||
iconClass="bg-chart-1/15 text-chart-1"
|
iconClass="bg-chart-1/15 text-chart-1"
|
||||||
>
|
>
|
||||||
@@ -218,9 +205,55 @@
|
|||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
|
<Alert class="border-info/30 bg-info/5">
|
||||||
|
<Info class="text-info" />
|
||||||
|
<AlertTitle>О модулях префиксов</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Модули собирают префиксы из AS (RIPEstat), CDN URL, доменов (DoH) и статических CIDR.
|
||||||
|
Расписание и ручной refresh — на странице
|
||||||
|
<Button variant="link" class="h-auto p-0" href={resolve('/schedule')}>Расписание</Button>.
|
||||||
|
Результаты обновления и ревизии — в
|
||||||
|
<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">{card.badge}</Badge>
|
||||||
|
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
|
<CardHeader class="flex flex-wrap items-center justify-between gap-2 border-b py-3">
|
||||||
<CardTitle class="text-base">Список модулей</CardTitle>
|
<div>
|
||||||
|
<CardTitle class="text-base">Список модулей</CardTitle>
|
||||||
|
<CardDescription>Клик по названию открывает карточку модуля и его записи.</CardDescription>
|
||||||
|
</div>
|
||||||
{#if selectedModulesCount > 0}
|
{#if selectedModulesCount > 0}
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<span class="text-sm text-muted-foreground">Выбрано: {selectedModulesCount}</span>
|
<span class="text-sm text-muted-foreground">Выбрано: {selectedModulesCount}</span>
|
||||||
@@ -241,10 +274,22 @@
|
|||||||
columns={[...moduleColumns]}
|
columns={[...moduleColumns]}
|
||||||
{rows}
|
{rows}
|
||||||
rowKey={(m) => m.id}
|
rowKey={(m) => m.id}
|
||||||
{loading}
|
loading={initialLoading || loading}
|
||||||
emptyTitle="Нет модулей"
|
emptyTitle="Нет модулей"
|
||||||
emptyDescription="Создайте первый модуль."
|
emptyDescription="Создайте первый модуль."
|
||||||
>
|
>
|
||||||
|
{#snippet toolbar()}
|
||||||
|
{#if rows.length > 0}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
checked={allModulesSelected}
|
||||||
|
onCheckedChange={(v) => toggleAllModules(v === true)}
|
||||||
|
aria-label="Выбрать все модули"
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-muted-foreground">Выбрать все</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
{#snippet cell({ row: m, column })}
|
{#snippet cell({ row: m, column })}
|
||||||
{#if column.id === 'select'}
|
{#if column.id === 'select'}
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -253,9 +298,14 @@
|
|||||||
onCheckedChange={() => toggleModuleSelection(m.id)}
|
onCheckedChange={() => toggleModuleSelection(m.id)}
|
||||||
/>
|
/>
|
||||||
{:else if column.id === 'name'}
|
{:else if column.id === 'name'}
|
||||||
<span class="font-medium">{m.name}</span>
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="font-medium">{m.name}</span>
|
||||||
|
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||||
|
<ExternalLink class="size-3.5" aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{:else if column.id === 'type'}
|
{:else if column.id === 'type'}
|
||||||
<Badge variant={typeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
||||||
{:else if column.id === 'priority'}
|
{:else if column.id === 'priority'}
|
||||||
<span class="text-muted-foreground">{m.priority}</span>
|
<span class="text-muted-foreground">{m.priority}</span>
|
||||||
{:else if column.id === 'interval'}
|
{:else if column.id === 'interval'}
|
||||||
@@ -265,11 +315,9 @@
|
|||||||
>{formatDateTime(m.last_refreshed_at)}</span
|
>{formatDateTime(m.last_refreshed_at)}</span
|
||||||
>
|
>
|
||||||
{:else if column.id === 'status'}
|
{:else if column.id === 'status'}
|
||||||
{#if m.enabled}
|
<Badge variant={moduleEnabledBadgeVariant(!!m.enabled)} class="text-xs">
|
||||||
<Badge variant="default" class="text-xs">вкл</Badge>
|
{moduleEnabledRu(!!m.enabled)}
|
||||||
{:else}
|
</Badge>
|
||||||
<Badge variant="secondary" class="text-xs">выкл</Badge>
|
|
||||||
{/if}
|
|
||||||
{:else if column.id === 'actions'}
|
{:else if column.id === 'actions'}
|
||||||
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||||
<ExternalLink class="size-3.5" />
|
<ExternalLink class="size-3.5" />
|
||||||
@@ -281,74 +329,4 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Create Dialog -->
|
<ModuleCreateDialog bind:open={dialogOpen} onClose={() => {}} onCreated={load} />
|
||||||
<Dialog bind:open={dialogOpen}>
|
|
||||||
<DialogContent class="sm:max-w-md">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Новый модуль</DialogTitle>
|
|
||||||
<DialogDescription>Создание нового модуля префиксов.</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div class="space-y-4 py-2">
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label for="m-name">Название</Label>
|
|
||||||
<Input id="m-name" bind:value={form.name} placeholder="my-asn-module" />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label for="m-type">Тип</Label>
|
|
||||||
<Select
|
|
||||||
type="single"
|
|
||||||
value={form.type}
|
|
||||||
onValueChange={(v) => {
|
|
||||||
if (v) form.type = v as typeof form.type;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger id="m-type" class="w-full">
|
|
||||||
{moduleTypes.find((t) => t.value === form.type)?.label ?? 'Выберите тип'}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{#each moduleTypes as t (t.value)}
|
|
||||||
<SelectItem value={t.value}>{t.label}</SelectItem>
|
|
||||||
{/each}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label for="m-priority">Приоритет</Label>
|
|
||||||
<Input id="m-priority" type="number" bind:value={form.priority} />
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<Label for="m-interval">Интервал (сек)</Label>
|
|
||||||
<Input
|
|
||||||
id="m-interval"
|
|
||||||
type="number"
|
|
||||||
placeholder="3600"
|
|
||||||
bind:value={form.refresh_interval_sec}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="flex flex-row items-center justify-between gap-4 rounded-lg border border-border bg-muted/30 p-3"
|
|
||||||
>
|
|
||||||
<div class="grid min-w-0 flex-1 gap-1 pr-2">
|
|
||||||
<Label for="m-enabled" class="leading-snug text-foreground">Включён</Label>
|
|
||||||
<p class="text-xs leading-snug text-muted-foreground">
|
|
||||||
Модуль участвует в сборке ревизий, если включён.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="m-enabled"
|
|
||||||
class="shrink-0"
|
|
||||||
checked={form.enabled !== false}
|
|
||||||
onCheckedChange={(v) => {
|
|
||||||
form = { ...form, enabled: v };
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<DialogFooter>
|
|
||||||
<Button variant="outline" onclick={() => (dialogOpen = false)}>Отмена</Button>
|
|
||||||
<Button onclick={create} disabled={saving}>{saving ? 'Создание…' : 'Создать'}</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,54 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { apiJSON, apiFetch } from '$lib/api/client.js';
|
import { apiJSON, apiFetch } from '$lib/api/client.js';
|
||||||
import type { BirdStatus, JobsResponse } from '$lib/api/types.js';
|
import type { BirdStatus, JobRow, JobsResponse } from '$lib/api/types.js';
|
||||||
import {
|
import {
|
||||||
formatVersionHeadline,
|
formatVersionHeadline,
|
||||||
resolveVersionString,
|
resolveVersionString,
|
||||||
type VersionInfo
|
type VersionInfo
|
||||||
} from '$lib/api/version-info.js';
|
} from '$lib/api/version-info.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { jobStatusRu } from '$lib/ui-labels.js';
|
||||||
|
import {
|
||||||
|
checkDisplayName,
|
||||||
|
checkStatusBadge,
|
||||||
|
deriveOverallStatus,
|
||||||
|
livenessBadge,
|
||||||
|
overallBadgeClass,
|
||||||
|
overallBadgeVariant,
|
||||||
|
overallStatusHint,
|
||||||
|
overallStatusLabel,
|
||||||
|
readinessBadge,
|
||||||
|
summarizeJobsStatuses,
|
||||||
|
toErrorMessage,
|
||||||
|
type HealthStatus,
|
||||||
|
type JobsKpi,
|
||||||
|
type ReadyStatus
|
||||||
|
} from '$lib/monitoring/status.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 { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
|
import { Separator } from '$lib/ui/core/separator/index.js';
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow
|
||||||
|
} from '$lib/ui/core/table/index.js';
|
||||||
|
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||||
|
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
||||||
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
|
import { cn } from '$lib/utils.js';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import Gauge from '@lucide/svelte/icons/gauge';
|
import Gauge from '@lucide/svelte/icons/gauge';
|
||||||
import Activity from '@lucide/svelte/icons/activity';
|
import Activity from '@lucide/svelte/icons/activity';
|
||||||
@@ -23,80 +56,86 @@
|
|||||||
import Server from '@lucide/svelte/icons/server';
|
import Server from '@lucide/svelte/icons/server';
|
||||||
import Hash from '@lucide/svelte/icons/hash';
|
import Hash from '@lucide/svelte/icons/hash';
|
||||||
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
import CheckCircle from '@lucide/svelte/icons/check-circle';
|
||||||
|
import XCircle from '@lucide/svelte/icons/x-circle';
|
||||||
|
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||||
|
import HeartPulse from '@lucide/svelte/icons/heart-pulse';
|
||||||
|
import ShieldCheck from '@lucide/svelte/icons/shield-check';
|
||||||
|
import Database from '@lucide/svelte/icons/database';
|
||||||
|
import HardDrive from '@lucide/svelte/icons/hard-drive';
|
||||||
|
import ListTodo from '@lucide/svelte/icons/list-todo';
|
||||||
|
import type { Component } from 'svelte';
|
||||||
|
|
||||||
type ReadyStatus = { status: string; checks?: Record<string, unknown> };
|
let health = $state<HealthStatus | null>(null);
|
||||||
type JobsKpi = { running: number; failed: number; total: number };
|
|
||||||
|
|
||||||
let health = $state<{ ok: boolean; status?: string; error?: string } | null>(null);
|
|
||||||
let ready = $state<ReadyStatus | null>(null);
|
let ready = $state<ReadyStatus | null>(null);
|
||||||
let version = $state<VersionInfo | null>(null);
|
let version = $state<VersionInfo | null>(null);
|
||||||
let bird = $state<BirdStatus | null>(null);
|
let bird = $state<BirdStatus | null>(null);
|
||||||
let jobs = $state<JobsKpi | null>(null);
|
let jobs = $state<JobsKpi | null>(null);
|
||||||
|
let jobItems = $state<JobRow[]>([]);
|
||||||
let readyError = $state<string | null>(null);
|
let readyError = $state<string | null>(null);
|
||||||
let versionError = $state<string | null>(null);
|
let versionError = $state<string | null>(null);
|
||||||
let birdError = $state<string | null>(null);
|
let birdError = $state<string | null>(null);
|
||||||
let jobsError = $state<string | null>(null);
|
let jobsError = $state<string | null>(null);
|
||||||
let lastUpdated = $state<Date | null>(null);
|
let lastUpdated = $state<Date | null>(null);
|
||||||
let loading = $state(false);
|
let initialLoading = $state(true);
|
||||||
|
let refreshing = $state(false);
|
||||||
|
|
||||||
function toErrorMessage(error: unknown): string {
|
const statAccents = [
|
||||||
if (error instanceof Error && error.message.trim() !== '') return error.message;
|
{
|
||||||
return 'Ошибка запроса';
|
border: 'border-l-chart-1',
|
||||||
}
|
bg: 'bg-chart-1/5',
|
||||||
|
iconBg: 'bg-chart-1/15',
|
||||||
function summarizeJobsStatuses(items: Array<{ status?: string }>): JobsKpi {
|
iconText: 'text-chart-1'
|
||||||
let running = 0;
|
},
|
||||||
let failed = 0;
|
{
|
||||||
for (const row of items) {
|
border: 'border-l-chart-2',
|
||||||
const status = String(row.status ?? '').toLowerCase();
|
bg: 'bg-chart-2/5',
|
||||||
if (status === 'queued' || status === 'running' || status === 'cancel_requested') {
|
iconBg: 'bg-chart-2/15',
|
||||||
running += 1;
|
iconText: 'text-chart-2'
|
||||||
}
|
},
|
||||||
if (status === 'failed' || status === 'error' || status === 'canceled') {
|
{
|
||||||
failed += 1;
|
border: 'border-l-chart-4',
|
||||||
}
|
bg: 'bg-chart-4/5',
|
||||||
|
iconBg: 'bg-chart-4/15',
|
||||||
|
iconText: 'text-chart-4'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
border: 'border-l-info',
|
||||||
|
bg: 'bg-info/10',
|
||||||
|
iconBg: 'bg-info/15',
|
||||||
|
iconText: 'text-info'
|
||||||
}
|
}
|
||||||
return { running, failed, total: items.length };
|
] as const;
|
||||||
}
|
|
||||||
|
|
||||||
function overallBadgeVariant(status: 'ok' | 'warn' | 'error' | 'unknown') {
|
const overallStatus = $derived.by(() =>
|
||||||
if (status === 'ok') return 'default';
|
deriveOverallStatus({
|
||||||
if (status === 'warn') return 'secondary';
|
health,
|
||||||
if (status === 'error') return 'destructive';
|
ready,
|
||||||
return 'outline';
|
jobs,
|
||||||
}
|
birdConfigured: bird?.birdc_configured ?? false,
|
||||||
|
birdHealthy: bird?.healthy ?? null
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
const overallStatus = $derived.by(() => {
|
const overallHint = $derived.by(() =>
|
||||||
if (health === null && ready === null && bird === null && jobs === null) return 'unknown';
|
overallStatusHint(overallStatus, {
|
||||||
if (!health?.ok) return 'error';
|
healthOk: health?.ok,
|
||||||
if (ready !== null && ready.status !== 'ready') return 'error';
|
jobsFailed: jobs?.failed ?? 0
|
||||||
if (jobs !== null && jobs.failed > 0) return 'warn';
|
})
|
||||||
if (bird !== null && bird.birdc_configured && bird.healthy === false) return 'warn';
|
);
|
||||||
return 'ok';
|
|
||||||
});
|
|
||||||
|
|
||||||
const overallHint = $derived.by(() => {
|
|
||||||
if (overallStatus === 'unknown') return 'Нет данных. Запустите обновление.';
|
|
||||||
if (overallStatus === 'error') {
|
|
||||||
if (!health?.ok) return 'Проверьте доступность API и логи сервиса.';
|
|
||||||
return 'Readiness не в норме: проверьте postgres/store/jobs.';
|
|
||||||
}
|
|
||||||
if (overallStatus === 'warn') {
|
|
||||||
if (jobs && jobs.failed > 0)
|
|
||||||
return 'Есть ошибки в задачах: откройте операции и последние jobs.';
|
|
||||||
return 'Проверьте BGP-сессии и вывод birdc.';
|
|
||||||
}
|
|
||||||
return 'Критичных отклонений не обнаружено.';
|
|
||||||
});
|
|
||||||
|
|
||||||
const bgpText = $derived.by(() => {
|
const bgpText = $derived.by(() => {
|
||||||
if (!bird) return '—';
|
if (!bird) return '—';
|
||||||
if (!bird.birdc_configured) return 'birdc не настроен';
|
if (!bird.birdc_configured) return '—';
|
||||||
if (bird.error) return 'ошибка birdc';
|
if (bird.error) return '—';
|
||||||
return `${bird.bgp_established}/${bird.bgp_sessions_total}`;
|
return `${bird.bgp_established}/${bird.bgp_sessions_total}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const bgpRatio = $derived.by(() => {
|
||||||
|
if (!bird?.birdc_configured || bird.bgp_sessions_total <= 0) return null;
|
||||||
|
return Math.round((bird.bgp_established / bird.bgp_sessions_total) * 100);
|
||||||
|
});
|
||||||
|
|
||||||
const versionText = $derived(formatVersionHeadline(version));
|
const versionText = $derived(formatVersionHeadline(version));
|
||||||
|
|
||||||
const versionFooter = $derived.by(() => {
|
const versionFooter = $derived.by(() => {
|
||||||
@@ -109,46 +148,156 @@
|
|||||||
return parts.join(' · ');
|
return parts.join(' · ');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const failedJobs = $derived.by(() =>
|
||||||
|
jobItems
|
||||||
|
.filter((j) => {
|
||||||
|
const s = String(j.status ?? '').toLowerCase();
|
||||||
|
return s === 'failed' || s === 'error' || s === 'canceled';
|
||||||
|
})
|
||||||
|
.slice(0, 5)
|
||||||
|
);
|
||||||
|
|
||||||
|
const checkIconByKey: Record<string, Component> = {
|
||||||
|
postgres: Database,
|
||||||
|
store: HardDrive,
|
||||||
|
jobs: ListTodo
|
||||||
|
};
|
||||||
|
|
||||||
|
const kpiCards = $derived.by(() => [
|
||||||
|
{
|
||||||
|
id: 'overall',
|
||||||
|
label: 'Общий статус',
|
||||||
|
value: initialLoading ? '—' : overallStatusLabel(overallStatus),
|
||||||
|
description: overallHint,
|
||||||
|
icon: Server,
|
||||||
|
accent: statAccents[0],
|
||||||
|
badge: overallStatusLabel(overallStatus),
|
||||||
|
badgeVariant: overallBadgeVariant(overallStatus),
|
||||||
|
badgeClass: overallBadgeClass(overallStatus),
|
||||||
|
error: null as string | null,
|
||||||
|
href: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bgp',
|
||||||
|
label: 'BGP сессии',
|
||||||
|
value: initialLoading ? '—' : bgpText,
|
||||||
|
description: bird?.birdc_configured
|
||||||
|
? 'Established / total на API-хосте'
|
||||||
|
: (bird?.message ?? 'birdc не настроен на API-хосте'),
|
||||||
|
icon: Bird,
|
||||||
|
accent: statAccents[1],
|
||||||
|
badge: !bird ? '—' : !bird.birdc_configured ? 'N/A' : bird.healthy ? 'В норме' : 'Деградация',
|
||||||
|
badgeVariant: !bird?.birdc_configured
|
||||||
|
? ('outline' as const)
|
||||||
|
: bird?.healthy
|
||||||
|
? ('default' as const)
|
||||||
|
: ('secondary' as const),
|
||||||
|
badgeClass:
|
||||||
|
bird?.birdc_configured && bird?.healthy
|
||||||
|
? 'border-success/30 bg-success/15 text-success'
|
||||||
|
: undefined,
|
||||||
|
error: birdError ?? bird?.error ?? null,
|
||||||
|
href: '/network' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jobs',
|
||||||
|
label: 'Задачи',
|
||||||
|
value: initialLoading ? '—' : String(jobs?.running ?? '—'),
|
||||||
|
description: `Активных из ${jobs?.total ?? '—'} последних`,
|
||||||
|
icon: Activity,
|
||||||
|
accent: statAccents[2],
|
||||||
|
badge: jobs && jobs.failed > 0 ? `ошибок ${jobs.failed}` : 'без ошибок',
|
||||||
|
badgeVariant: jobs && jobs.failed > 0 ? ('secondary' as const) : ('default' as const),
|
||||||
|
badgeClass:
|
||||||
|
jobs && jobs.failed === 0 ? 'border-success/30 bg-success/15 text-success' : undefined,
|
||||||
|
error: jobsError,
|
||||||
|
href: jobs && jobs.failed > 0 ? ('/operations' as const) : undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'version',
|
||||||
|
label: 'Версия',
|
||||||
|
value: initialLoading ? '—' : versionText,
|
||||||
|
description: versionFooter || 'GET /v1/version',
|
||||||
|
icon: Hash,
|
||||||
|
accent: statAccents[3],
|
||||||
|
badge: version ? 'Загружена' : '—',
|
||||||
|
badgeVariant: version ? ('outline' as const) : ('secondary' as const),
|
||||||
|
badgeClass: undefined,
|
||||||
|
error: versionError,
|
||||||
|
href: undefined
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
async function fetchHealth(): Promise<HealthStatus> {
|
||||||
|
const h = await apiFetch('/v1/health');
|
||||||
|
const hj = (await h.json().catch(() => ({}))) as { status?: string };
|
||||||
|
return { ok: h.ok, status: hj?.status, error: h.ok ? undefined : `HTTP ${h.status}` };
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
if (initialLoading) {
|
||||||
|
/* keep skeleton */
|
||||||
|
} else {
|
||||||
|
refreshing = true;
|
||||||
|
}
|
||||||
readyError = null;
|
readyError = null;
|
||||||
versionError = null;
|
versionError = null;
|
||||||
birdError = null;
|
birdError = null;
|
||||||
jobsError = null;
|
jobsError = null;
|
||||||
try {
|
|
||||||
const h = await apiFetch('/v1/health');
|
const [healthRes, readyRes, versionRes, birdRes, jobsRes] = await Promise.allSettled([
|
||||||
const hj = await h.json().catch(() => ({}));
|
fetchHealth(),
|
||||||
health = { ok: h.ok, status: hj?.status, error: h.ok ? undefined : `HTTP ${h.status}` };
|
apiJSON<ReadyStatus>('/v1/ready'),
|
||||||
} catch (error) {
|
apiJSON<VersionInfo>('/v1/version'),
|
||||||
health = { ok: false, error: toErrorMessage(error) };
|
apiJSON<BirdStatus>('/v1/bird/status'),
|
||||||
|
apiJSON<JobsResponse>('/v1/jobs?limit=100')
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (healthRes.status === 'fulfilled') {
|
||||||
|
health = healthRes.value;
|
||||||
|
} else {
|
||||||
|
health = { ok: false, error: toErrorMessage(healthRes.reason) };
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
ready = await apiJSON<ReadyStatus>('/v1/ready');
|
if (readyRes.status === 'fulfilled') {
|
||||||
} catch (error) {
|
ready = readyRes.value;
|
||||||
|
} else {
|
||||||
ready = null;
|
ready = null;
|
||||||
readyError = toErrorMessage(error);
|
readyError = toErrorMessage(readyRes.reason);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
version = await apiJSON<VersionInfo>('/v1/version');
|
if (versionRes.status === 'fulfilled') {
|
||||||
} catch (error) {
|
version = versionRes.value;
|
||||||
|
} else {
|
||||||
version = null;
|
version = null;
|
||||||
versionError = toErrorMessage(error);
|
versionError = toErrorMessage(versionRes.reason);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
bird = await apiJSON<BirdStatus>('/v1/bird/status');
|
if (birdRes.status === 'fulfilled') {
|
||||||
} catch (error) {
|
bird = birdRes.value;
|
||||||
|
} else {
|
||||||
bird = null;
|
bird = null;
|
||||||
birdError = toErrorMessage(error);
|
birdError = toErrorMessage(birdRes.reason);
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const jobsPage = await apiJSON<JobsResponse>('/v1/jobs?limit=100');
|
if (jobsRes.status === 'fulfilled') {
|
||||||
jobs = summarizeJobsStatuses(jobsPage.items ?? []);
|
const items = jobsRes.value.items ?? [];
|
||||||
} catch (error) {
|
jobItems = items;
|
||||||
|
jobs = summarizeJobsStatuses(items);
|
||||||
|
} else {
|
||||||
|
jobItems = [];
|
||||||
jobs = null;
|
jobs = null;
|
||||||
jobsError = toErrorMessage(error);
|
jobsError = toErrorMessage(jobsRes.reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
lastUpdated = new Date();
|
lastUpdated = new Date();
|
||||||
loading = false;
|
initialLoading = false;
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncateError(error: string | null | undefined, max = 120): string {
|
||||||
|
if (!error) return '';
|
||||||
|
return error.length > max ? `${error.slice(0, max)}…` : error;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
@@ -164,189 +313,394 @@
|
|||||||
iconClass="bg-info/15 text-info"
|
iconClass="bg-info/15 text-info"
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
||||||
<RefreshCw class={loading ? 'animate-spin' : ''} />
|
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
{#if !initialLoading}
|
||||||
<Card class="border-l-4 border-l-chart-1 bg-chart-1/5 shadow-sm">
|
{#if overallStatus === 'ok'}
|
||||||
<CardHeader>
|
<Alert class="border-success/30 bg-success/5">
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
<CheckCircle class="text-success" />
|
||||||
<Server class="size-4" />
|
<AlertTitle>Система в норме</AlertTitle>
|
||||||
Общий статус
|
<AlertDescription>{overallHint}</AlertDescription>
|
||||||
</CardTitle>
|
</Alert>
|
||||||
<CardDescription>Liveness + readiness + jobs/BGP</CardDescription>
|
{:else if overallStatus === 'warn'}
|
||||||
</CardHeader>
|
<Alert class="border-warning/30 bg-warning/5">
|
||||||
<CardContent>
|
<AlertTriangle class="text-warning" />
|
||||||
<div class="flex items-center justify-between gap-2">
|
<AlertTitle>Требуется внимание</AlertTitle>
|
||||||
<p class="text-2xl font-semibold uppercase">{overallStatus}</p>
|
<AlertDescription>{overallHint}</AlertDescription>
|
||||||
<Badge variant={overallBadgeVariant(overallStatus)}>{overallStatus}</Badge>
|
</Alert>
|
||||||
</div>
|
{:else if overallStatus === 'error'}
|
||||||
<p class="mt-2 text-xs text-muted-foreground">{overallHint}</p>
|
<Alert variant="destructive">
|
||||||
</CardContent>
|
<XCircle />
|
||||||
</Card>
|
<AlertTitle>Обнаружена проблема</AlertTitle>
|
||||||
|
<AlertDescription>{overallHint}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
|
||||||
<Card class="border-l-4 border-l-chart-2 bg-chart-2/5 shadow-sm">
|
<div class="grid gap-4 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
<CardHeader>
|
{#if initialLoading}
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
{#each Array(4) as _, i (i)}
|
||||||
<Bird class="size-4" />
|
<CardSkeleton />
|
||||||
BGP сессии
|
{/each}
|
||||||
</CardTitle>
|
{:else}
|
||||||
<CardDescription>GET /v1/bird/status</CardDescription>
|
{#each kpiCards as card (card.id)}
|
||||||
</CardHeader>
|
{@const Icon = card.icon}
|
||||||
<CardContent>
|
{@const a = card.accent}
|
||||||
<div class="flex items-center justify-between gap-2">
|
<Card
|
||||||
<p class="text-2xl font-semibold">{bgpText}</p>
|
class={cn(
|
||||||
{#if bird?.birdc_configured}
|
'overflow-hidden border-l-4 shadow-sm transition-colors',
|
||||||
<Badge variant={bird.healthy ? 'default' : 'secondary'}
|
a.border,
|
||||||
>{bird.healthy ? 'healthy' : 'degraded'}</Badge
|
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={cn(
|
||||||
|
'font-bold tabular-nums',
|
||||||
|
card.id === 'version' ? 'font-mono text-xl' : 'text-3xl'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{:else}
|
{card.value}
|
||||||
<Badge variant="outline">n/a</Badge>
|
</CardTitle>
|
||||||
{/if}
|
</CardHeader>
|
||||||
</div>
|
<CardContent class="space-y-2">
|
||||||
{#if birdError}
|
<div class="flex items-center justify-between gap-2">
|
||||||
<p class="mt-2 text-xs text-destructive">{birdError}</p>
|
<Badge variant={card.badgeVariant} class={card.badgeClass}>{card.badge}</Badge>
|
||||||
{:else if bird && !bird.birdc_configured}
|
</div>
|
||||||
<p class="mt-2 text-xs text-muted-foreground">
|
{#if card.error}
|
||||||
{bird.message ?? 'birdc не настроен на API-хосте.'}
|
<p class="text-xs text-destructive">{card.error}</p>
|
||||||
</p>
|
{:else}
|
||||||
{:else if bird?.error}
|
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||||
<p class="mt-2 text-xs text-destructive">{bird.error}</p>
|
{/if}
|
||||||
{:else}
|
</CardContent>
|
||||||
<p class="mt-2 text-xs text-muted-foreground">Established / total на API-хосте.</p>
|
</Card>
|
||||||
{/if}
|
{/each}
|
||||||
</CardContent>
|
{/if}
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="border-l-4 border-l-chart-4 bg-chart-4/5 shadow-sm">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
|
||||||
<Activity class="size-4" />
|
|
||||||
Задачи
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>GET /v1/jobs?limit=100</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<p class="text-2xl font-semibold">{jobs ? jobs.running : '—'}</p>
|
|
||||||
<Badge variant={jobs && jobs.failed > 0 ? 'secondary' : 'default'}>
|
|
||||||
{jobs && jobs.failed > 0 ? `ошибок ${jobs.failed}` : 'без ошибок'}
|
|
||||||
</Badge>
|
|
||||||
</div>
|
|
||||||
{#if jobsError}
|
|
||||||
<p class="mt-2 text-xs text-destructive">{jobsError}</p>
|
|
||||||
{:else}
|
|
||||||
<p class="mt-2 text-xs text-muted-foreground">
|
|
||||||
Активных: {jobs?.running ?? '—'} из {jobs?.total ?? '—'} последних.
|
|
||||||
</p>
|
|
||||||
{/if}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card class="border-l-4 border-l-info bg-info/10 shadow-sm">
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
|
||||||
<Hash class="size-4" />
|
|
||||||
Версия
|
|
||||||
</CardTitle>
|
|
||||||
<CardDescription>GET /v1/version</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div class="flex items-center justify-between gap-2">
|
|
||||||
<p class="font-mono text-2xl font-semibold">{versionText}</p>
|
|
||||||
<Badge variant={version ? 'outline' : 'secondary'}>{version ? 'loaded' : '—'}</Badge>
|
|
||||||
</div>
|
|
||||||
{#if versionError}
|
|
||||||
<p class="mt-2 text-xs text-destructive">{versionError}</p>
|
|
||||||
{:else if versionFooter}
|
|
||||||
<p class="mt-2 truncate text-xs text-muted-foreground">{versionFooter}</p>
|
|
||||||
{/if}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-4 lg:grid-cols-2">
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<Card>
|
{#if initialLoading}
|
||||||
<CardHeader>
|
<CardSkeleton />
|
||||||
<CardTitle class="text-base">Доступность и готовность</CardTitle>
|
<CardSkeleton />
|
||||||
<CardDescription>GET /v1/health и GET /v1/ready</CardDescription>
|
{:else}
|
||||||
</CardHeader>
|
<Card>
|
||||||
<CardContent class="space-y-3">
|
<CardHeader>
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<CardTitle class="text-base">Доступность и готовность</CardTitle>
|
||||||
<span class="text-muted-foreground">Liveness:</span>
|
<CardDescription>GET /v1/health · GET /v1/ready</CardDescription>
|
||||||
{#if health === null}
|
</CardHeader>
|
||||||
<Badge variant="outline">—</Badge>
|
<CardContent class="space-y-4">
|
||||||
{:else if health.ok}
|
{#if health?.error || readyError}
|
||||||
<Badge variant="default">ok</Badge>
|
<Alert variant="destructive">
|
||||||
{:else}
|
<XCircle />
|
||||||
<Badge variant="destructive">failed</Badge>
|
<AlertTitle>Ошибка проверки</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
{#if health?.error}{health.error}{/if}
|
||||||
|
{#if health?.error && readyError}<br />{/if}
|
||||||
|
{#if readyError}{readyError}{/if}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
{/if}
|
{/if}
|
||||||
{#if health?.status}
|
|
||||||
<span class="text-muted-foreground">{health.status}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2 text-sm">
|
<Table>
|
||||||
<span class="text-muted-foreground">Readiness:</span>
|
<TableHeader>
|
||||||
{#if ready === null}
|
<TableRow>
|
||||||
<Badge variant="outline">—</Badge>
|
<TableHead class="w-[55%]">Проверка</TableHead>
|
||||||
{:else}
|
<TableHead>Статус</TableHead>
|
||||||
<Badge variant={ready.status === 'ready' ? 'default' : 'destructive'}
|
</TableRow>
|
||||||
>{ready.status}</Badge
|
</TableHeader>
|
||||||
>
|
<TableBody>
|
||||||
{/if}
|
{@const liveBadge = livenessBadge(health)}
|
||||||
</div>
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<HeartPulse class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">Liveness</p>
|
||||||
|
<p class="text-xs text-muted-foreground">/v1/health</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={liveBadge.variant} class={liveBadge.class}
|
||||||
|
>{liveBadge.label}</Badge
|
||||||
|
>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{@const readyBadge = readinessBadge(ready)}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<ShieldCheck class="size-4 shrink-0 text-muted-foreground" aria-hidden="true" />
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">Readiness</p>
|
||||||
|
<p class="text-xs text-muted-foreground">/v1/ready</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge variant={readyBadge.variant} class={readyBadge.class}
|
||||||
|
>{readyBadge.label}</Badge
|
||||||
|
>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{#if ready?.checks && Object.keys(ready.checks).length > 0}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colspan={2} class="bg-muted/30 py-2">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground">Зависимости</p>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{#each Object.entries(ready.checks) as [key, value] (key)}
|
||||||
|
{@const badge = checkStatusBadge(value)}
|
||||||
|
{@const CheckIcon = checkIconByKey[key] ?? ListTodo}
|
||||||
|
<TableRow>
|
||||||
|
<TableCell>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<CheckIcon
|
||||||
|
class="size-4 shrink-0 text-muted-foreground"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium">{checkDisplayName(key)}</p>
|
||||||
|
<p class="text-xs text-muted-foreground">{key}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<div class="space-y-1">
|
||||||
|
<Badge variant={badge.variant} class={badge.class}>{badge.label}</Badge>
|
||||||
|
{#if badge.hint}
|
||||||
|
<p class="text-xs text-muted-foreground">{badge.hint}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
|
||||||
{#if health?.error}
|
<p class="text-xs text-muted-foreground">
|
||||||
<p class="text-xs text-destructive">{health.error}</p>
|
HTTP 503 на readiness означает недоступность одной из зависимостей в checks.
|
||||||
{/if}
|
</p>
|
||||||
{#if readyError}
|
</CardContent>
|
||||||
<p class="text-xs text-destructive">{readyError}</p>
|
</Card>
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if ready?.checks}
|
<Card>
|
||||||
<div class="space-y-1">
|
<CardHeader>
|
||||||
{#each Object.entries(ready.checks) as [k, v] (k)}
|
<div class="flex items-start justify-between gap-2">
|
||||||
<div class="grid grid-cols-[auto_1fr] items-center gap-2 text-xs">
|
<div>
|
||||||
<span class="text-muted-foreground">{k}</span>
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
<Badge variant="outline" class="justify-start font-normal">{String(v)}</Badge>
|
<Bird class="size-4" />
|
||||||
</div>
|
BGP на API-хосте
|
||||||
{/each}
|
</CardTitle>
|
||||||
|
<CardDescription>GET /v1/bird/status</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" href={resolve('/network')}>Пиры и спикеры</Button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
</CardHeader>
|
||||||
</CardContent>
|
<CardContent class="space-y-4">
|
||||||
</Card>
|
{#if birdError}
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<XCircle />
|
||||||
|
<AlertTitle>Ошибка birdc</AlertTitle>
|
||||||
|
<AlertDescription>{birdError}</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
{:else if bird && !bird.birdc_configured}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
{bird.message ?? 'birdc не настроен на API-хосте (EVOBGP_BIRDC_SOCKET).'}
|
||||||
|
</p>
|
||||||
|
{:else if bird}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<div class="flex items-center justify-between text-sm">
|
||||||
|
<span class="text-muted-foreground">Established / total</span>
|
||||||
|
<span class="font-medium tabular-nums">
|
||||||
|
{bird.bgp_established} / {bird.bgp_sessions_total}
|
||||||
|
{#if bgpRatio !== null}
|
||||||
|
<span class="text-muted-foreground">({bgpRatio}%)</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if bgpRatio !== null}
|
||||||
|
<div class="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
class={cn(
|
||||||
|
'h-full rounded-full transition-all',
|
||||||
|
bgpRatio >= 100
|
||||||
|
? 'bg-success'
|
||||||
|
: bgpRatio >= 50
|
||||||
|
? 'bg-warning'
|
||||||
|
: 'bg-destructive'
|
||||||
|
)}
|
||||||
|
style="width: {bgpRatio}%"
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{#if bird.error}
|
||||||
|
<p class="text-xs text-destructive">{bird.error}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if bird.protocols_excerpt}
|
||||||
|
<Separator />
|
||||||
|
<div class="space-y-2">
|
||||||
|
<p class="text-sm font-medium">Вывод birdc (protocols)</p>
|
||||||
|
<ScrollPreBlock variant="preserve" text={bird.protocols_excerpt} class="max-h-48" />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Card>
|
<div class="grid gap-4 lg:grid-cols-2">
|
||||||
<CardHeader>
|
{#if initialLoading}
|
||||||
<CardTitle class="flex items-center gap-2 text-base">
|
<CardSkeleton />
|
||||||
<AlertTriangle class="size-4 text-muted-foreground" />
|
<CardSkeleton />
|
||||||
Что проверять при деградации
|
{:else}
|
||||||
</CardTitle>
|
<Card>
|
||||||
<CardDescription>Короткая шпаргалка для triage</CardDescription>
|
<CardHeader>
|
||||||
</CardHeader>
|
<div class="flex items-start justify-between gap-2">
|
||||||
<CardContent class="space-y-2 text-sm">
|
<div>
|
||||||
<p>
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
<span class="text-muted-foreground">Если </span><code>health=failed</code> — проверьте доступность
|
<Activity class="size-4" />
|
||||||
процесса API и его логи.
|
Задачи
|
||||||
</p>
|
</CardTitle>
|
||||||
<p>
|
<CardDescription>Последние 100 задач · GET /v1/jobs</CardDescription>
|
||||||
<span class="text-muted-foreground">Если </span><code>ready!=ready</code> — сначала `postgres`,
|
</div>
|
||||||
затем `store/jobs` в checks.
|
<Button variant="outline" size="sm" href={resolve('/operations')}>Все операции</Button>
|
||||||
</p>
|
</div>
|
||||||
<p>
|
</CardHeader>
|
||||||
<span class="text-muted-foreground">Если low BGP ratio</span> — проверьте `bird/status`, затем
|
<CardContent class="space-y-4">
|
||||||
BGP peer состояния.
|
{#if jobsError}
|
||||||
</p>
|
<Alert variant="destructive">
|
||||||
<p>
|
<XCircle />
|
||||||
<span class="text-muted-foreground">Если ошибки jobs</span> — откройте операции и проверьте
|
<AlertTitle>Не удалось загрузить задачи</AlertTitle>
|
||||||
последние неуспешные задачи.
|
<AlertDescription>{jobsError}</AlertDescription>
|
||||||
</p>
|
</Alert>
|
||||||
</CardContent>
|
{:else if jobs}
|
||||||
</Card>
|
<div class="flex flex-wrap gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">Активных</p>
|
||||||
|
<p class="text-2xl font-bold tabular-nums">{jobs.running}</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">С ошибками</p>
|
||||||
|
<p
|
||||||
|
class={cn(
|
||||||
|
'text-2xl font-bold tabular-nums',
|
||||||
|
jobs.failed > 0 ? 'text-warning' : 'text-success'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{jobs.failed}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p class="text-muted-foreground">В выборке</p>
|
||||||
|
<p class="text-2xl font-bold tabular-nums">{jobs.total}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{#if failedJobs.length > 0}
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-sm font-medium">Последние ошибки</p>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
{#each failedJobs as job (job.job_id)}
|
||||||
|
<li class="rounded-lg border px-3 py-2 text-sm">
|
||||||
|
<div class="flex items-start justify-between gap-2">
|
||||||
|
<p class="font-medium">{jobKindTitle(job)}</p>
|
||||||
|
<Badge variant="destructive">{jobStatusRu(job.status)}</Badge>
|
||||||
|
</div>
|
||||||
|
{#if job.error}
|
||||||
|
<p class="mt-1 text-xs text-muted-foreground">
|
||||||
|
{truncateError(job.error)}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-muted-foreground">
|
||||||
|
Критичных сбоев в последних 100 задачах нет.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="flex items-center gap-2 text-base">
|
||||||
|
<AlertTriangle class="size-4 text-muted-foreground" />
|
||||||
|
Что проверять при деградации
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Короткая шпаргалка для triage</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-3">
|
||||||
|
<Alert>
|
||||||
|
<HeartPulse class="size-4" />
|
||||||
|
<AlertTitle>API недоступен</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Если <code class="text-xs">/v1/health</code> возвращает ошибку — проверьте процесс API и
|
||||||
|
его логи.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Alert>
|
||||||
|
<Database class="size-4" />
|
||||||
|
<AlertTitle>Readiness не «Готов»</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Сначала <code class="text-xs">postgres</code>, затем
|
||||||
|
<code class="text-xs">store</code> и <code class="text-xs">jobs</code> в checks.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Alert>
|
||||||
|
<Bird class="size-4" />
|
||||||
|
<AlertTitle>Низкий ratio BGP</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Проверьте <code class="text-xs">/v1/bird/status</code>, затем состояние пиров в
|
||||||
|
<Button variant="link" class="h-auto p-0" href={resolve('/network')}>Сети</Button>.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
<Alert>
|
||||||
|
<ListTodo class="size-4" />
|
||||||
|
<AlertTitle>Ошибки задач</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Откройте
|
||||||
|
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}
|
||||||
|
>Операции</Button
|
||||||
|
>
|
||||||
|
и проверьте последние неуспешные jobs.
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+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>
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { page } from '$app/state';
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
|
||||||
import {
|
import {
|
||||||
apiFetch,
|
apiFetch,
|
||||||
@@ -23,28 +26,28 @@
|
|||||||
ModulesResponse
|
ModulesResponse
|
||||||
} from '$lib/api/types.js';
|
} from '$lib/api/types.js';
|
||||||
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
||||||
import { jobStatusRu } from '$lib/ui-labels.js';
|
import { jobStatusRu, jobStatusBadgeVariant } from '$lib/ui-labels.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs/index.js';
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
import {
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||||
AlertDialog,
|
|
||||||
AlertDialogContent,
|
|
||||||
AlertDialogHeader,
|
|
||||||
AlertDialogTitle,
|
|
||||||
AlertDialogDescription,
|
|
||||||
AlertDialogFooter,
|
|
||||||
AlertDialogCancel,
|
|
||||||
AlertDialogAction
|
|
||||||
} from '$lib/components/ui/alert-dialog/index.js';
|
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogDescription
|
DialogDescription
|
||||||
} from '$lib/components/ui/dialog/index.js';
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '$lib/ui/core/select/index.js';
|
||||||
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import { Skeleton } from '$lib/ui/core/skeleton/index.js';
|
||||||
import OperationsQuickActions from '$lib/components/operations/OperationsQuickActions.svelte';
|
import OperationsQuickActions from '$lib/components/operations/OperationsQuickActions.svelte';
|
||||||
import OperationsRevisionsTab from '$lib/components/operations/OperationsRevisionsTab.svelte';
|
import OperationsRevisionsTab from '$lib/components/operations/OperationsRevisionsTab.svelte';
|
||||||
import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte';
|
import OperationsDiffTab from '$lib/components/operations/OperationsDiffTab.svelte';
|
||||||
@@ -56,6 +59,10 @@
|
|||||||
ReportRow
|
ReportRow
|
||||||
} from '$lib/components/operations/types.js';
|
} from '$lib/components/operations/types.js';
|
||||||
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
||||||
|
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||||
|
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||||
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
import {
|
import {
|
||||||
dialogBodyDocument,
|
dialogBodyDocument,
|
||||||
dialogBodyPanel,
|
dialogBodyPanel,
|
||||||
@@ -65,14 +72,23 @@
|
|||||||
dialogHeaderPanel
|
dialogHeaderPanel
|
||||||
} from '$lib/dialog-layout.js';
|
} from '$lib/dialog-layout.js';
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from '$lib/utils.js';
|
||||||
import { toast } from 'svelte-sonner';
|
import Activity from '@lucide/svelte/icons/activity';
|
||||||
import Zap from '@lucide/svelte/icons/zap';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
|
import Info from '@lucide/svelte/icons/info';
|
||||||
|
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||||
|
|
||||||
|
type OpsTab = 'revisions' | 'diff' | 'jobs';
|
||||||
|
|
||||||
|
function parseOpsTab(value: string | null): OpsTab {
|
||||||
|
if (value === 'diff' || value === 'jobs') return value;
|
||||||
|
return 'revisions';
|
||||||
|
}
|
||||||
|
|
||||||
// Revisions
|
// Revisions
|
||||||
let revisions = $state<RevisionRow[]>([]);
|
let revisions = $state<RevisionRow[]>([]);
|
||||||
let revLoading = $state(false);
|
let revLoading = $state(false);
|
||||||
let rollbackTarget = $state<RevisionRow | null>(null);
|
|
||||||
let rollingBack = $state(false);
|
let rollingBack = $state(false);
|
||||||
|
|
||||||
// Preview/Prefixes
|
// Preview/Prefixes
|
||||||
@@ -115,8 +131,6 @@
|
|||||||
let jobFilterModule = $state('');
|
let jobFilterModule = $state('');
|
||||||
let jobActiveOnly = $state(false);
|
let jobActiveOnly = $state(false);
|
||||||
let moduleNameById = $state(new Map<string, string>());
|
let moduleNameById = $state(new Map<string, string>());
|
||||||
let cancelTarget = $state<JobRow | null>(null);
|
|
||||||
let cancelling = $state(false);
|
|
||||||
let jobDetailDialog = $state(false);
|
let jobDetailDialog = $state(false);
|
||||||
let jobDetail = $state<JobRow | null>(null);
|
let jobDetail = $state<JobRow | null>(null);
|
||||||
let expandedJobIds = new SvelteSet<string>();
|
let expandedJobIds = new SvelteSet<string>();
|
||||||
@@ -128,14 +142,18 @@
|
|||||||
// Global apply / bird reload
|
// Global apply / bird reload
|
||||||
let applying = $state(false);
|
let applying = $state(false);
|
||||||
let reloading = $state(false);
|
let reloading = $state(false);
|
||||||
let applyConfirm = $state(false);
|
|
||||||
let reloadConfirm = $state(false);
|
|
||||||
|
|
||||||
// BIRD runtime status (birdc на хосте API, если настроен сокет)
|
// BIRD runtime status (birdc на хосте API, если настроен сокет)
|
||||||
let birdStatus = $state<BirdStatus | null>(null);
|
let birdStatus = $state<BirdStatus | null>(null);
|
||||||
let birdLoading = $state(false);
|
let birdLoading = $state(false);
|
||||||
let birdProtocolsOpen = $state(false);
|
let birdProtocolsOpen = $state(false);
|
||||||
|
|
||||||
|
let activeTab = $state<OpsTab>('revisions');
|
||||||
|
let initialLoading = $state(true);
|
||||||
|
let refreshing = $state(false);
|
||||||
|
let lastUpdated = $state<Date | null>(null);
|
||||||
|
let tabSyncReady = $state(false);
|
||||||
|
|
||||||
function summarizeJobBirdMeta(job: JobRow): string {
|
function summarizeJobBirdMeta(job: JobRow): string {
|
||||||
const check = job.meta?.bird_post_apply_check;
|
const check = job.meta?.bird_post_apply_check;
|
||||||
if (check === 'skipped_no_birdc_socket') {
|
if (check === 'skipped_no_birdc_socket') {
|
||||||
@@ -156,7 +174,7 @@
|
|||||||
birdStatus = await apiJSON<BirdStatus>('/v1/bird/status');
|
birdStatus = await apiJSON<BirdStatus>('/v1/bird/status');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
birdStatus = null;
|
birdStatus = null;
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
birdLoading = false;
|
birdLoading = false;
|
||||||
}
|
}
|
||||||
@@ -168,7 +186,7 @@
|
|||||||
const r = await apiJSON<RevisionsResponse>('/v1/revisions?limit=100');
|
const r = await apiJSON<RevisionsResponse>('/v1/revisions?limit=100');
|
||||||
revisions = r.items;
|
revisions = r.items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
revLoading = false;
|
revLoading = false;
|
||||||
}
|
}
|
||||||
@@ -211,7 +229,7 @@
|
|||||||
link.remove();
|
link.remove();
|
||||||
URL.revokeObjectURL(objectUrl);
|
URL.revokeObjectURL(objectUrl);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,7 +243,7 @@
|
|||||||
const j = await apiJSON<JobsResponse>(`/v1/jobs?${params.toString()}`);
|
const j = await apiJSON<JobsResponse>(`/v1/jobs?${params.toString()}`);
|
||||||
jobs = j.items;
|
jobs = j.items;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
jobsLoading = false;
|
jobsLoading = false;
|
||||||
}
|
}
|
||||||
@@ -314,17 +332,149 @@
|
|||||||
}
|
}
|
||||||
moduleNameById = m;
|
moduleNameById = m;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
loadRevisions();
|
activeTab = parseOpsTab(page.url.searchParams.get('tab'));
|
||||||
loadJobs();
|
tabSyncReady = true;
|
||||||
loadBirdStatus();
|
void refreshAll(true);
|
||||||
loadModules();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const statAccents = [
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-1',
|
||||||
|
bg: 'bg-chart-1/5',
|
||||||
|
iconBg: 'bg-chart-1/15',
|
||||||
|
iconText: 'text-chart-1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 runningJobsCount = $derived(
|
||||||
|
jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedJobsCount = $derived(
|
||||||
|
jobs.filter((j) => {
|
||||||
|
const s = String(j.status ?? '').toLowerCase();
|
||||||
|
return s === 'failed' || s === 'error' || s === 'canceled' || s === 'cancelled';
|
||||||
|
}).length
|
||||||
|
);
|
||||||
|
|
||||||
|
const kpiCards = $derived.by(() => [
|
||||||
|
{
|
||||||
|
id: 'revisions',
|
||||||
|
label: 'Ревизий',
|
||||||
|
value: initialLoading ? '—' : String(revisions.length),
|
||||||
|
description: 'в последней выборке',
|
||||||
|
icon: Activity,
|
||||||
|
accent: statAccents[0],
|
||||||
|
badge: 'история конфигов'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'running',
|
||||||
|
label: 'Активных задач',
|
||||||
|
value: initialLoading ? '—' : String(runningJobsCount),
|
||||||
|
description: 'queued и running',
|
||||||
|
icon: Clock,
|
||||||
|
accent: statAccents[1],
|
||||||
|
badge: 'в работе'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'failed',
|
||||||
|
label: 'Задач с ошибкой',
|
||||||
|
value: initialLoading ? '—' : String(failedJobsCount),
|
||||||
|
description: failedJobsCount > 0 ? 'требуют внимания' : 'критичных сбоев нет',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
accent: statAccents[2],
|
||||||
|
badge: failedJobsCount > 0 ? 'есть ошибки' : 'без ошибок',
|
||||||
|
badgeClass:
|
||||||
|
failedJobsCount === 0 ? 'border-success/30 bg-success/15 text-success' : undefined,
|
||||||
|
href: failedJobsCount > 0 ? ('/schedule' as const) : undefined
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
function syncTabToUrl(tab: OpsTab) {
|
||||||
|
if (!tabSyncReady) return;
|
||||||
|
const url = new URL(page.url);
|
||||||
|
if (tab === 'revisions') url.searchParams.delete('tab');
|
||||||
|
else url.searchParams.set('tab', tab);
|
||||||
|
const next = `${url.pathname}${url.search}${url.hash}`;
|
||||||
|
if (next !== `${page.url.pathname}${page.url.search}${page.url.hash}`) {
|
||||||
|
void goto(next, { replaceState: true, keepFocus: true, noScroll: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!tabSyncReady) return;
|
||||||
|
syncTabToUrl(activeTab);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function refreshAll(isInitial = false) {
|
||||||
|
if (isInitial) initialLoading = true;
|
||||||
|
else refreshing = true;
|
||||||
|
await Promise.all([loadRevisions(), loadJobs(), loadBirdStatus(), loadModules()]);
|
||||||
|
lastUpdated = new Date();
|
||||||
|
initialLoading = false;
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestApply() {
|
||||||
|
void confirm({
|
||||||
|
title: 'Применить конфигурацию на всех спикерах?',
|
||||||
|
description:
|
||||||
|
'Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator.',
|
||||||
|
confirmLabel: 'Применить',
|
||||||
|
onConfirm: doApply
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestReload() {
|
||||||
|
void confirm({
|
||||||
|
title: 'Перезагрузить BIRD?',
|
||||||
|
description: 'BIRD перезагрузит конфигурацию. Требуется роль operator.',
|
||||||
|
confirmLabel: 'Перезагрузить',
|
||||||
|
onConfirm: doBirdReload
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestRollback(rev: RevisionRow) {
|
||||||
|
void confirm({
|
||||||
|
title: `Откатиться к ревизии ${rev.id.slice(0, 8)}…?`,
|
||||||
|
description: 'Будет создана новая ревизия на основе выбранной. Требуется роль operator.',
|
||||||
|
confirmLabel: 'Откатить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await rollback(rev);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCancelJob(job: JobRow) {
|
||||||
|
void confirm({
|
||||||
|
title: 'Отменить задачу?',
|
||||||
|
description: `Задача: ${jobKindTitle(job, moduleNameById)} (${job.job_id.slice(0, 8)}…)`,
|
||||||
|
confirmLabel: 'Отменить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
await cancelJob(job);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function openPreview(rev: RevisionRow) {
|
async function openPreview(rev: RevisionRow) {
|
||||||
previewRevision = rev;
|
previewRevision = rev;
|
||||||
previewLoading = true;
|
previewLoading = true;
|
||||||
@@ -343,7 +493,7 @@
|
|||||||
const frags = asPreviewFragments(prev);
|
const frags = asPreviewFragments(prev);
|
||||||
birdPreviewPath = defaultBirdPreviewPath(frags);
|
birdPreviewPath = defaultBirdPreviewPath(frags);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
previewLoading = false;
|
previewLoading = false;
|
||||||
}
|
}
|
||||||
@@ -351,29 +501,29 @@
|
|||||||
|
|
||||||
async function loadDiff() {
|
async function loadDiff() {
|
||||||
if (!diffRevA || !diffRevB) {
|
if (!diffRevA || !diffRevB) {
|
||||||
toast.error('Выберите две ревизии');
|
notify.error('Выберите две ревизии');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
diffLoading = true;
|
diffLoading = true;
|
||||||
try {
|
try {
|
||||||
diffData = await apiJSON<RevisionDiff>(`/v1/revisions/${diffRevA}/diff/${diffRevB}`);
|
diffData = await apiJSON<RevisionDiff>(`/v1/revisions/${diffRevA}/diff/${diffRevB}`);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
diffLoading = false;
|
diffLoading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function rollback() {
|
async function rollback(rev: RevisionRow) {
|
||||||
if (!rollbackTarget) return;
|
|
||||||
rollingBack = true;
|
rollingBack = true;
|
||||||
try {
|
try {
|
||||||
await apiMutate(`/v1/revisions/${rollbackTarget.id}/rollback`, 'POST', {});
|
await apiMutate(`/v1/revisions/${rev.id}/rollback`, 'POST', {});
|
||||||
toast.success('Откат выполнен');
|
notify.success('Откат выполнен');
|
||||||
rollbackTarget = null;
|
|
||||||
await loadRevisions();
|
await loadRevisions();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
|
throw e;
|
||||||
|
} finally {
|
||||||
rollingBack = false;
|
rollingBack = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -383,23 +533,22 @@
|
|||||||
try {
|
try {
|
||||||
const revId = revisions[0]?.id;
|
const revId = revisions[0]?.id;
|
||||||
if (!revId) {
|
if (!revId) {
|
||||||
toast.error('Нет ревизий — сначала обновите модуль или дождитесь задачи render');
|
notify.error('Нет ревизий — сначала обновите модуль или дождитесь задачи render');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const res = await apiMutate<{ job_id: string; status?: string }>('/v1/apply', 'POST', {
|
const res = await apiMutate<{ job_id: string; status?: string }>('/v1/apply', 'POST', {
|
||||||
revision_id: revId
|
revision_id: revId
|
||||||
});
|
});
|
||||||
applyConfirm = false;
|
|
||||||
if (!res?.job_id) {
|
if (!res?.job_id) {
|
||||||
toast.error('Ответ API без job_id');
|
notify.error('Ответ API без job_id');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const job = await waitForJob(res.job_id, { timeoutMs: 180000 });
|
const job = await waitForJob(res.job_id, { timeoutMs: 180000 });
|
||||||
const extra = summarizeJobBirdMeta(job);
|
const extra = summarizeJobBirdMeta(job);
|
||||||
if (job.status === 'succeeded') {
|
if (job.status === 'succeeded') {
|
||||||
toast.success(extra ? `Применение успешно. ${extra}` : 'Конфигурация успешно применена');
|
notify.success(extra ? `Применение успешно. ${extra}` : 'Конфигурация успешно применена');
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
notify.error(
|
||||||
job.error
|
job.error
|
||||||
? `${jobStatusRu(job.status)}: ${job.error}`
|
? `${jobStatusRu(job.status)}: ${job.error}`
|
||||||
: `Задача завершилась со статусом ${jobStatusRu(job.status)}`
|
: `Задача завершилась со статусом ${jobStatusRu(job.status)}`
|
||||||
@@ -408,7 +557,7 @@
|
|||||||
await loadJobs();
|
await loadJobs();
|
||||||
await loadBirdStatus();
|
await loadBirdStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
applying = false;
|
applying = false;
|
||||||
}
|
}
|
||||||
@@ -418,19 +567,18 @@
|
|||||||
reloading = true;
|
reloading = true;
|
||||||
try {
|
try {
|
||||||
const res = await apiMutate<{ job_id: string }>('/v1/bird/reload', 'POST', {});
|
const res = await apiMutate<{ job_id: string }>('/v1/bird/reload', 'POST', {});
|
||||||
reloadConfirm = false;
|
|
||||||
if (!res?.job_id) {
|
if (!res?.job_id) {
|
||||||
toast.error('Ответ API без job_id');
|
notify.error('Ответ API без job_id');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const job = await waitForJob(res.job_id, { timeoutMs: 120000 });
|
const job = await waitForJob(res.job_id, { timeoutMs: 120000 });
|
||||||
const extra = summarizeJobBirdMeta(job);
|
const extra = summarizeJobBirdMeta(job);
|
||||||
if (job.status === 'succeeded') {
|
if (job.status === 'succeeded') {
|
||||||
toast.success(
|
notify.success(
|
||||||
extra ? `Перезагрузка успешна. ${extra}` : 'Команда birdc configure выполнена'
|
extra ? `Перезагрузка успешна. ${extra}` : 'Команда birdc configure выполнена'
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
notify.error(
|
||||||
job.error
|
job.error
|
||||||
? `${jobStatusRu(job.status)}: ${job.error}`
|
? `${jobStatusRu(job.status)}: ${job.error}`
|
||||||
: `Задача завершилась со статусом ${jobStatusRu(job.status)}`
|
: `Задача завершилась со статусом ${jobStatusRu(job.status)}`
|
||||||
@@ -439,24 +587,20 @@
|
|||||||
await loadJobs();
|
await loadJobs();
|
||||||
await loadBirdStatus();
|
await loadBirdStatus();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
reloading = false;
|
reloading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function cancelJob() {
|
async function cancelJob(job: JobRow) {
|
||||||
if (!cancelTarget) return;
|
|
||||||
cancelling = true;
|
|
||||||
try {
|
try {
|
||||||
await apiMutate(`/v1/jobs/${cancelTarget.job_id}/cancel`, 'POST', {});
|
await apiMutate(`/v1/jobs/${job.job_id}/cancel`, 'POST', {});
|
||||||
toast.success('Задача отменена');
|
notify.success('Задача отменена');
|
||||||
cancelTarget = null;
|
|
||||||
await loadJobs();
|
await loadJobs();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
throw e;
|
||||||
cancelling = false;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -487,7 +631,7 @@
|
|||||||
jobDetailsById.set(jobId, freshJob);
|
jobDetailsById.set(jobId, freshJob);
|
||||||
await ensureJobReport(freshJob);
|
await ensureJobReport(freshJob);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
jobDetailsLoading.delete(jobId);
|
jobDetailsLoading.delete(jobId);
|
||||||
}
|
}
|
||||||
@@ -625,7 +769,7 @@
|
|||||||
ipRanges
|
ipRanges
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
notifyApiError(e);
|
||||||
} finally {
|
} finally {
|
||||||
jobReportsLoading.delete(jobId);
|
jobReportsLoading.delete(jobId);
|
||||||
}
|
}
|
||||||
@@ -703,18 +847,6 @@
|
|||||||
return safeEntries.reduce((acc, entry) => acc + entry.prefix_count, 0);
|
return safeEntries.reduce((acc, entry) => acc + entry.prefix_count, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
function jobStatusVariant(status: string): 'default' | 'secondary' | 'outline' | 'destructive' {
|
|
||||||
if (status === 'succeeded') return 'default';
|
|
||||||
if (status === 'running') return 'secondary';
|
|
||||||
if (status === 'failed') return 'destructive';
|
|
||||||
return 'outline';
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDate(d?: string | null) {
|
|
||||||
if (!d) return '—';
|
|
||||||
return new Date(d).toLocaleString('ru');
|
|
||||||
}
|
|
||||||
|
|
||||||
function birdHealthyBadgeVariant(
|
function birdHealthyBadgeVariant(
|
||||||
h: boolean | null | undefined
|
h: boolean | null | undefined
|
||||||
): 'default' | 'secondary' | 'outline' | 'destructive' {
|
): 'default' | 'secondary' | 'outline' | 'destructive' {
|
||||||
@@ -724,7 +856,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function birdHealthyShortLabel(h: boolean | null | undefined): string {
|
function birdHealthyShortLabel(h: boolean | null | undefined): string {
|
||||||
if (h === true) return 'ОК';
|
if (h === true) return 'В норме';
|
||||||
if (h === false) return 'Проблема';
|
if (h === false) return 'Проблема';
|
||||||
return 'Н/Д';
|
return 'Н/Д';
|
||||||
}
|
}
|
||||||
@@ -732,31 +864,100 @@
|
|||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Операции"
|
title="Ревизии и операции"
|
||||||
description="Деплой конфигурации, управление ревизиями и задачами."
|
description={lastUpdated
|
||||||
icon={Zap}
|
? `Деплой, сравнение конфигураций и задачи. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||||
|
: 'Деплой конфигурации, управление ревизиями и задачами.'}
|
||||||
|
icon={Activity}
|
||||||
iconClass="bg-chart-2/15 text-chart-2"
|
iconClass="bg-chart-2/15 text-chart-2"
|
||||||
/>
|
>
|
||||||
|
{#snippet actions()}
|
||||||
|
<Button variant="outline" size="sm" onclick={() => refreshAll()} 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>
|
||||||
|
<strong>Ревизии</strong> — история конфигов и откат; <strong>Сравнение</strong> — diff
|
||||||
|
префиксов;
|
||||||
|
<strong>Задачи</strong> — ingest, apply, rollback. Apply и Reload требуют operator. Сводный
|
||||||
|
мониторинг BGP — на
|
||||||
|
<Button variant="link" class="h-auto p-0" href={resolve('/monitoring')}>Мониторинг</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" class={card.badgeClass}>{card.badge}</Badge>
|
||||||
|
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
<OperationsQuickActions
|
<OperationsQuickActions
|
||||||
{applying}
|
{applying}
|
||||||
{reloading}
|
{reloading}
|
||||||
{birdLoading}
|
{birdLoading}
|
||||||
{birdStatus}
|
{birdStatus}
|
||||||
onApply={() => (applyConfirm = true)}
|
onApply={requestApply}
|
||||||
onReload={() => (reloadConfirm = true)}
|
onReload={requestReload}
|
||||||
onRefreshBirdStatus={loadBirdStatus}
|
onRefreshBirdStatus={loadBirdStatus}
|
||||||
onOpenBirdProtocols={() => (birdProtocolsOpen = true)}
|
onOpenBirdProtocols={() => (birdProtocolsOpen = true)}
|
||||||
{birdHealthyBadgeVariant}
|
{birdHealthyBadgeVariant}
|
||||||
{birdHealthyShortLabel}
|
{birdHealthyShortLabel}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Tabs value="revisions">
|
<Tabs bind:value={activeTab}>
|
||||||
<div class="overflow-x-auto pb-1 [scrollbar-gutter:stable]">
|
<div class="overflow-x-auto pb-1 [scrollbar-gutter:stable]">
|
||||||
<TabsList class="inline-flex min-w-max">
|
<TabsList class="inline-flex min-w-max">
|
||||||
<TabsTrigger value="revisions">Ревизии</TabsTrigger>
|
<TabsTrigger value="revisions">Ревизии ({revisions.length})</TabsTrigger>
|
||||||
<TabsTrigger value="diff">Сравнение</TabsTrigger>
|
<TabsTrigger value="diff">Сравнение</TabsTrigger>
|
||||||
<TabsTrigger value="jobs">Задачи</TabsTrigger>
|
<TabsTrigger value="jobs">Задачи ({jobs.length})</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -766,9 +967,8 @@
|
|||||||
{revLoading}
|
{revLoading}
|
||||||
onReload={loadRevisions}
|
onReload={loadRevisions}
|
||||||
onOpenPreview={openPreview}
|
onOpenPreview={openPreview}
|
||||||
onRollbackRequest={(rev) => (rollbackTarget = rev)}
|
onRollbackRequest={requestRollback}
|
||||||
onDownloadDiagnosticLog={downloadRevisionDiagnosticLog}
|
onDownloadDiagnosticLog={downloadRevisionDiagnosticLog}
|
||||||
{formatDate}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -782,7 +982,6 @@
|
|||||||
onDiffRevAChange={(value) => (diffRevA = value)}
|
onDiffRevAChange={(value) => (diffRevA = value)}
|
||||||
onDiffRevBChange={(value) => (diffRevB = value)}
|
onDiffRevBChange={(value) => (diffRevB = value)}
|
||||||
onLoadDiff={loadDiff}
|
onLoadDiff={loadDiff}
|
||||||
{formatDate}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
@@ -815,103 +1014,17 @@
|
|||||||
{jobReportsLoading}
|
{jobReportsLoading}
|
||||||
onReloadJobs={loadJobs}
|
onReloadJobs={loadJobs}
|
||||||
onOpenJobDetail={openJobDetail}
|
onOpenJobDetail={openJobDetail}
|
||||||
onRequestCancelJob={(job) => (cancelTarget = job)}
|
onRequestCancelJob={requestCancelJob}
|
||||||
onToggleJobExpanded={toggleJobExpanded}
|
onToggleJobExpanded={toggleJobExpanded}
|
||||||
{isJobExpanded}
|
{isJobExpanded}
|
||||||
{getJobLogEntries}
|
{getJobLogEntries}
|
||||||
{getJobLogTotal}
|
{getJobLogTotal}
|
||||||
{jobStatusVariant}
|
jobStatusVariant={jobStatusBadgeVariant}
|
||||||
{formatDate}
|
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Apply confirm -->
|
|
||||||
<AlertDialog bind:open={applyConfirm}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Применить конфигурацию на всех спикерах?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription
|
|
||||||
>Текущая конфигурация будет применена на всех BIRD-агентах. Требуется роль operator.</AlertDialogDescription
|
|
||||||
>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onclick={doApply} disabled={applying}
|
|
||||||
>{applying ? 'Применение…' : 'Применить'}</AlertDialogAction
|
|
||||||
>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
<!-- BIRD reload confirm -->
|
|
||||||
<AlertDialog bind:open={reloadConfirm}>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Перезагрузить BIRD?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription
|
|
||||||
>BIRD перезагрузит конфигурацию. Требуется роль operator.</AlertDialogDescription
|
|
||||||
>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel>Отмена</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onclick={doBirdReload} disabled={reloading}
|
|
||||||
>{reloading ? 'Перезагрузка…' : 'Перезагрузить'}</AlertDialogAction
|
|
||||||
>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
<!-- Rollback confirm -->
|
|
||||||
<AlertDialog
|
|
||||||
open={!!rollbackTarget}
|
|
||||||
onOpenChange={(v) => {
|
|
||||||
if (!v) rollbackTarget = null;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Откатиться к ревизии {rollbackTarget?.id.slice(0, 8)}…?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription
|
|
||||||
>Будет создана новая ревизия на основе выбранной. Требуется роль operator.</AlertDialogDescription
|
|
||||||
>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel onclick={() => (rollbackTarget = null)}>Отмена</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onclick={rollback} disabled={rollingBack}
|
|
||||||
>{rollingBack ? 'Откат…' : 'Откатить'}</AlertDialogAction
|
|
||||||
>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
<!-- Cancel job confirm -->
|
|
||||||
<AlertDialog
|
|
||||||
open={!!cancelTarget}
|
|
||||||
onOpenChange={(v) => {
|
|
||||||
if (!v) cancelTarget = null;
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<AlertDialogContent>
|
|
||||||
<AlertDialogHeader>
|
|
||||||
<AlertDialogTitle>Отменить задачу?</AlertDialogTitle>
|
|
||||||
<AlertDialogDescription>
|
|
||||||
Задача: {cancelTarget ? jobKindTitle(cancelTarget, moduleNameById) : ''} ({cancelTarget?.job_id?.slice(
|
|
||||||
0,
|
|
||||||
8
|
|
||||||
)}…)
|
|
||||||
</AlertDialogDescription>
|
|
||||||
</AlertDialogHeader>
|
|
||||||
<AlertDialogFooter>
|
|
||||||
<AlertDialogCancel onclick={() => (cancelTarget = null)}>Нет</AlertDialogCancel>
|
|
||||||
<AlertDialogAction onclick={cancelJob} disabled={cancelling}
|
|
||||||
>{cancelling ? 'Отмена…' : 'Отменить'}</AlertDialogAction
|
|
||||||
>
|
|
||||||
</AlertDialogFooter>
|
|
||||||
</AlertDialogContent>
|
|
||||||
</AlertDialog>
|
|
||||||
|
|
||||||
<!-- Preview dialog -->
|
<!-- Preview dialog -->
|
||||||
<Dialog bind:open={previewDialog}>
|
<Dialog bind:open={previewDialog}>
|
||||||
<DialogContent class={dialogContentDocument}>
|
<DialogContent class={dialogContentDocument}>
|
||||||
@@ -922,7 +1035,10 @@
|
|||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{#if previewLoading}
|
{#if previewLoading}
|
||||||
<div class="px-6 py-10 text-center text-sm text-muted-foreground">Загрузка…</div>
|
<div class="space-y-3 px-6 py-6">
|
||||||
|
<Skeleton class="h-9 w-full max-w-md" />
|
||||||
|
<Skeleton class="h-48 w-full" />
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<div class={cn(dialogBodyDocument, 'min-h-[min(44vh,400px)]')}>
|
<div class={cn(dialogBodyDocument, 'min-h-[min(44vh,400px)]')}>
|
||||||
<Tabs bind:value={previewSubTab} class="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
<Tabs bind:value={previewSubTab} class="flex min-h-0 min-w-0 flex-1 flex-col gap-3">
|
||||||
@@ -941,16 +1057,23 @@
|
|||||||
</p>
|
</p>
|
||||||
{:else}
|
{:else}
|
||||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||||
<label for="bird-frag" class="text-sm text-muted-foreground">Файл</label>
|
<span class="text-sm text-muted-foreground">Файл</span>
|
||||||
<select
|
<Select
|
||||||
id="bird-frag"
|
type="single"
|
||||||
class="max-w-xl min-w-0 flex-1 rounded-md border border-input bg-background px-2 py-1.5 text-sm ring-offset-background focus-visible:ring-2 focus-visible:ring-ring focus-visible:outline-none"
|
value={birdPreviewPath}
|
||||||
bind:value={birdPreviewPath}
|
onValueChange={(v) => {
|
||||||
|
if (v) birdPreviewPath = v;
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{#each Object.keys(frags).sort() as path (path)}
|
<SelectTrigger class="max-w-xl min-w-0 flex-1">
|
||||||
<option value={path}>{path}</option>
|
{birdPreviewPath || 'Выберите файл'}
|
||||||
{/each}
|
</SelectTrigger>
|
||||||
</select>
|
<SelectContent>
|
||||||
|
{#each Object.keys(frags).sort() as path (path)}
|
||||||
|
<SelectItem value={path}>{path}</SelectItem>
|
||||||
|
{/each}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<p class="shrink-0 text-xs text-muted-foreground">
|
<p class="shrink-0 text-xs text-muted-foreground">
|
||||||
Совет: откройте <code class="rounded bg-muted px-1">_bird_full_expanded.conf</code>
|
Совет: откройте <code class="rounded bg-muted px-1">_bird_full_expanded.conf</code>
|
||||||
@@ -1021,18 +1144,18 @@
|
|||||||
>{jobDetail.job_id}</span
|
>{jobDetail.job_id}</span
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground">Статус</span><span
|
<span class="text-muted-foreground">Статус</span><span
|
||||||
><Badge variant={jobStatusVariant(jobDetail.status)}
|
><Badge variant={jobStatusBadgeVariant(jobDetail.status)}
|
||||||
>{jobStatusRu(jobDetail.status)}</Badge
|
>{jobStatusRu(jobDetail.status)}</Badge
|
||||||
></span
|
></span
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground">Создана</span><span
|
<span class="text-muted-foreground">Создана</span><span
|
||||||
>{formatDate(jobDetail.created_at)}</span
|
>{formatDateTime(jobDetail.created_at)}</span
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground">Начата</span><span
|
<span class="text-muted-foreground">Начата</span><span
|
||||||
>{formatDate(jobDetail.started_at)}</span
|
>{formatDateTime(jobDetail.started_at)}</span
|
||||||
>
|
>
|
||||||
<span class="text-muted-foreground">Завершена</span><span
|
<span class="text-muted-foreground">Завершена</span><span
|
||||||
>{formatDate(jobDetail.finished_at)}</span
|
>{formatDateTime(jobDetail.finished_at)}</span
|
||||||
>
|
>
|
||||||
{#if jobDetail.error}
|
{#if jobDetail.error}
|
||||||
<span class="text-muted-foreground">Ошибка</span><span
|
<span class="text-muted-foreground">Ошибка</span><span
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { resolve } from '$app/paths';
|
import { resolve } from '$app/paths';
|
||||||
goto(resolve('/operations'));
|
|
||||||
|
goto(`${resolve('/operations')}?tab=revisions`, { replaceState: true });
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,258 +1,398 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import { apiFetch, apiJSON } from '$lib/api/client.js';
|
import { resolve } from '$app/paths';
|
||||||
|
import { apiJSON, apiMutate } from '$lib/api/client.js';
|
||||||
import type { JobRow, JobsResponse, ModuleRow, ModulesResponse } from '$lib/api/types.js';
|
import type { JobRow, JobsResponse, ModuleRow, ModulesResponse } from '$lib/api/types.js';
|
||||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
import {
|
||||||
import { Button } from '$lib/components/ui/button/index.js';
|
formatDateTime,
|
||||||
|
moduleIntervalLabel,
|
||||||
|
moduleTypeBadgeVariant
|
||||||
|
} from '$lib/modules/display.js';
|
||||||
|
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
||||||
|
import { jobStatusRu, jobStatusBadgeVariant, moduleTypeRu } from '$lib/ui-labels.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 {
|
import { Alert, AlertDescription, AlertTitle } from '$lib/ui/core/alert/index.js';
|
||||||
Table,
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||||
TableBody,
|
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||||
TableCell,
|
import CardSkeleton from '$lib/ui/patterns/feedback/card-skeleton.svelte';
|
||||||
TableHead,
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
TableHeader,
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
TableRow
|
import { cn } from '$lib/utils.js';
|
||||||
} from '$lib/components/ui/table/index.js';
|
|
||||||
import { toast } from 'svelte-sonner';
|
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
|
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
import ListTodo from '@lucide/svelte/icons/list-todo';
|
||||||
import { jobStatusRu, moduleTypeRu } from '$lib/ui-labels.js';
|
import Clock from '@lucide/svelte/icons/clock';
|
||||||
import { jobKindTitle } from '$lib/operations/job-kind-label.js';
|
import AlertTriangle from '@lucide/svelte/icons/alert-triangle';
|
||||||
|
import ArrowRight from '@lucide/svelte/icons/arrow-right';
|
||||||
|
import ExternalLink from '@lucide/svelte/icons/external-link';
|
||||||
|
import Info from '@lucide/svelte/icons/info';
|
||||||
|
|
||||||
|
type JobsTab = 'all' | 'refresh' | 'failed';
|
||||||
|
|
||||||
let modules = $state<ModuleRow[]>([]);
|
let modules = $state<ModuleRow[]>([]);
|
||||||
let jobs = $state<JobRow[]>([]);
|
let jobs = $state<JobRow[]>([]);
|
||||||
let refreshing = $state<Record<string, boolean>>({});
|
let moduleRefreshing = $state<Record<string, boolean>>({});
|
||||||
let loading = $state(false);
|
let loadError = $state<string | null>(null);
|
||||||
|
let lastUpdated = $state<Date | null>(null);
|
||||||
|
let initialLoading = $state(true);
|
||||||
|
let refreshing = $state(false);
|
||||||
|
let jobsTab = $state<JobsTab>('all');
|
||||||
|
|
||||||
|
const statAccents = [
|
||||||
|
{
|
||||||
|
border: 'border-l-chart-1',
|
||||||
|
bg: 'bg-chart-1/5',
|
||||||
|
iconBg: 'bg-chart-1/15',
|
||||||
|
iconText: 'text-chart-1'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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 moduleColumns = [
|
||||||
|
{ id: 'name', label: 'Модуль', sortable: true, sortValue: (m: ModuleRow) => m.name },
|
||||||
|
{ id: 'type', label: 'Тип', sortable: true, sortValue: (m: ModuleRow) => m.type },
|
||||||
|
{ id: 'schedule', label: 'Расписание' },
|
||||||
|
{
|
||||||
|
id: 'refreshed',
|
||||||
|
label: 'Последнее обновление',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (m: ModuleRow) => m.last_refreshed_at ?? ''
|
||||||
|
},
|
||||||
|
{ id: 'status', label: 'Статус' },
|
||||||
|
{ id: 'actions', label: '', class: 'w-32 text-right' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const jobColumns = [
|
||||||
|
{ id: 'kind', label: 'Вид' },
|
||||||
|
{ id: 'status', label: 'Статус', sortable: true, sortValue: (j: JobRow) => j.status },
|
||||||
|
{
|
||||||
|
id: 'created',
|
||||||
|
label: 'Создана',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (j: JobRow) => j.created_at ?? ''
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'finished',
|
||||||
|
label: 'Завершена',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (j: JobRow) => j.finished_at ?? ''
|
||||||
|
},
|
||||||
|
{ id: 'error', label: 'Ошибка', class: 'max-w-xs' }
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const moduleNameById = $derived(new Map(modules.map((m) => [m.id, m.name])));
|
||||||
|
|
||||||
|
const runningJobsCount = $derived(
|
||||||
|
jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedJobsCount = $derived(
|
||||||
|
jobs.filter((j) => {
|
||||||
|
const s = String(j.status ?? '').toLowerCase();
|
||||||
|
return s === 'failed' || s === 'error' || s === 'canceled';
|
||||||
|
}).length
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredJobs = $derived.by(() => {
|
||||||
|
if (jobsTab === 'refresh') return jobs.filter((j) => j.kind === 'module_refresh');
|
||||||
|
if (jobsTab === 'failed') {
|
||||||
|
return jobs.filter((j) => {
|
||||||
|
const s = String(j.status ?? '').toLowerCase();
|
||||||
|
return s === 'failed' || s === 'error' || s === 'canceled';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return jobs;
|
||||||
|
});
|
||||||
|
|
||||||
|
const kpiCards = $derived.by(() => [
|
||||||
|
{
|
||||||
|
id: 'total',
|
||||||
|
label: 'Всего задач',
|
||||||
|
value: initialLoading ? '—' : String(jobs.length),
|
||||||
|
description: 'в последней выборке',
|
||||||
|
icon: ListTodo,
|
||||||
|
accent: statAccents[0],
|
||||||
|
badge: 'в выборке',
|
||||||
|
href: '/operations' as const
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'running',
|
||||||
|
label: 'В работе',
|
||||||
|
value: initialLoading ? '—' : String(runningJobsCount),
|
||||||
|
description: 'queued и running',
|
||||||
|
icon: Clock,
|
||||||
|
accent: statAccents[1],
|
||||||
|
badge: 'активных',
|
||||||
|
href: undefined
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'failed',
|
||||||
|
label: 'С ошибкой',
|
||||||
|
value: initialLoading ? '—' : String(failedJobsCount),
|
||||||
|
description: failedJobsCount > 0 ? 'требуют внимания' : 'критичных сбоев нет',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
accent: statAccents[2],
|
||||||
|
badge: failedJobsCount > 0 ? 'есть ошибки' : 'без ошибок',
|
||||||
|
badgeClass:
|
||||||
|
failedJobsCount === 0 ? 'border-success/30 bg-success/15 text-success' : undefined,
|
||||||
|
href: failedJobsCount > 0 ? ('/operations' as const) : undefined
|
||||||
|
}
|
||||||
|
]);
|
||||||
|
|
||||||
|
function truncateError(error: string | null | undefined, max = 120): string {
|
||||||
|
if (!error) return '';
|
||||||
|
return error.length > max ? `${error.slice(0, max)}…` : error;
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
loading = true;
|
if (!initialLoading) refreshing = true;
|
||||||
|
loadError = null;
|
||||||
try {
|
try {
|
||||||
const [m, j] = await Promise.all([
|
const [m, j] = await Promise.all([
|
||||||
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
apiJSON<ModulesResponse>('/v1/modules?limit=200'),
|
||||||
apiJSON<JobsResponse>('/v1/jobs?limit=50')
|
apiJSON<JobsResponse>('/v1/jobs?limit=100')
|
||||||
]);
|
]);
|
||||||
modules = m.items ?? [];
|
modules = m.items ?? [];
|
||||||
jobs = j.items ?? [];
|
jobs = j.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 {
|
||||||
loading = false;
|
initialLoading = false;
|
||||||
|
refreshing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshModule(id: string) {
|
||||||
|
moduleRefreshing = { ...moduleRefreshing, [id]: true };
|
||||||
|
try {
|
||||||
|
const result = await apiMutate<{ job_id?: string }>(`/v1/modules/${id}/refresh`, 'POST');
|
||||||
|
if (result === undefined) {
|
||||||
|
notify.message('Обновление не требуется (тип IP_RANGES)');
|
||||||
|
} else {
|
||||||
|
notify.success('Задача поставлена в очередь');
|
||||||
|
await load();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
moduleRefreshing = { ...moduleRefreshing, [id]: false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(load);
|
onMount(load);
|
||||||
|
|
||||||
async function refreshModule(id: string) {
|
|
||||||
refreshing = { ...refreshing, [id]: true };
|
|
||||||
try {
|
|
||||||
const token = localStorage.getItem('evobgp_api_token') ?? '';
|
|
||||||
const res = await fetch(`/v1/modules/${id}/refresh`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { Authorization: `Bearer ${token}` }
|
|
||||||
});
|
|
||||||
if (res.status === 204) toast.message('Обновление не требуется (тип IP_RANGES)');
|
|
||||||
else if (res.status === 202) {
|
|
||||||
toast.success('Задача поставлена в очередь');
|
|
||||||
await load();
|
|
||||||
} else toast.error(`HTTP ${res.status}`);
|
|
||||||
} catch (e) {
|
|
||||||
toast.error(e instanceof Error ? e.message : String(e));
|
|
||||||
} finally {
|
|
||||||
refreshing = { ...refreshing, [id]: false };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function intervalLabel(sec: number | null) {
|
|
||||||
if (!sec) return '—';
|
|
||||||
if (sec % 3600 === 0) return `${sec / 3600} ч`;
|
|
||||||
if (sec % 60 === 0) return `${sec / 60} мин`;
|
|
||||||
return `${sec} с`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function jobStatusVariant(s: string): 'default' | 'secondary' | 'outline' | 'destructive' {
|
|
||||||
if (s === 'succeeded') return 'default';
|
|
||||||
if (s === 'running') return 'secondary';
|
|
||||||
if (s === 'failed') return 'destructive';
|
|
||||||
return 'outline';
|
|
||||||
}
|
|
||||||
|
|
||||||
const refreshJobs = $derived(jobs.filter((j) => j.kind === 'module_refresh'));
|
|
||||||
const moduleNameById = $derived(new Map(modules.map((m) => [m.id, m.name])));
|
|
||||||
const runningJobsCount = $derived(
|
|
||||||
jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
|
||||||
);
|
|
||||||
const failedJobsCount = $derived(jobs.filter((j) => j.status === 'failed').length);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Расписание и задачи"
|
title="Расписание и задачи"
|
||||||
description="Интервалы обновления модулей и ручной запуск обновления."
|
description={lastUpdated
|
||||||
|
? `Интервалы обновления модулей и ручной запуск. Обновлено: ${lastUpdated.toLocaleTimeString('ru-RU')}`
|
||||||
|
: 'Интервалы обновления модулей и ручной запуск обновления.'}
|
||||||
icon={CalendarClock}
|
icon={CalendarClock}
|
||||||
iconClass="bg-chart-4/15 text-chart-4"
|
iconClass="bg-chart-4/15 text-chart-4"
|
||||||
>
|
>
|
||||||
{#snippet actions()}
|
{#snippet actions()}
|
||||||
<Button variant="outline" size="sm" onclick={load} disabled={loading}>
|
<Button variant="outline" size="sm" onclick={load} disabled={refreshing}>
|
||||||
<RefreshCw class={loading ? 'animate-spin' : ''} />
|
<RefreshCw class={refreshing ? 'animate-spin' : ''} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</PageHeader>
|
</PageHeader>
|
||||||
|
|
||||||
<div class="grid gap-3 sm:grid-cols-3">
|
<Alert class="border-info/30 bg-info/5">
|
||||||
<Card class="p-4">
|
<Info class="text-info" />
|
||||||
<p class="text-xs text-muted-foreground">Всего задач</p>
|
<AlertTitle>Как работает расписание</AlertTitle>
|
||||||
<p class="text-lg font-semibold">{jobs.length}</p>
|
<AlertDescription>
|
||||||
</Card>
|
Планировщик использует <code class="text-xs">refresh_interval_sec</code> и опционально
|
||||||
<Card class="p-4">
|
<code class="text-xs">cron_expr</code>. Ручной запуск —
|
||||||
<p class="text-xs text-muted-foreground">В работе</p>
|
<code class="text-xs">POST /v1/modules{id}/refresh</code>; для модулей
|
||||||
<p class="text-lg font-semibold">{runningJobsCount}</p>
|
<code class="text-xs">IP_RANGES</code>
|
||||||
</Card>
|
сервер может вернуть <strong>204</strong>
|
||||||
<Card class="p-4">
|
(no-op). Полный список задач и деплой — на странице
|
||||||
<p class="text-xs text-muted-foreground">С ошибкой</p>
|
<Button variant="link" class="h-auto p-0" href={resolve('/operations')}>Операции</Button>.
|
||||||
<p class="text-lg font-semibold">{failedJobsCount}</p>
|
</AlertDescription>
|
||||||
</Card>
|
</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" class={card.badgeClass}>{card.badge}</Badge>
|
||||||
|
<p class="text-xs text-muted-foreground">{card.description}</p>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle class="text-base">Модули</CardTitle>
|
<CardTitle class="text-base">Модули</CardTitle>
|
||||||
<CardDescription
|
<CardDescription>
|
||||||
>Запустить обновление вручную (CDN, домены, AS — в очередь; для IP_RANGES ответ 204)</CardDescription
|
Расписание обновления и ручной запуск ingest (CDN, домены, AS — в очередь; IP_RANGES — 204)
|
||||||
>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent class="p-0">
|
<CardContent class="p-4 pt-0">
|
||||||
<Table>
|
<AppDataTable
|
||||||
<TableHeader>
|
columns={[...moduleColumns]}
|
||||||
<TableRow>
|
rows={modules}
|
||||||
<TableHead>Модуль</TableHead>
|
rowKey={(m) => m.id}
|
||||||
<TableHead>Тип</TableHead>
|
loading={initialLoading}
|
||||||
<TableHead>Интервал</TableHead>
|
error={loadError}
|
||||||
<TableHead>Cron</TableHead>
|
emptyTitle="Нет модулей"
|
||||||
<TableHead class="w-28 text-right"></TableHead>
|
emptyDescription="Создайте модуль на странице «Модули»."
|
||||||
</TableRow>
|
>
|
||||||
</TableHeader>
|
{#snippet cell({ row: m, column })}
|
||||||
<TableBody>
|
{#if column.id === 'name'}
|
||||||
{#each modules as m (m.id)}
|
<div class="flex items-center gap-2">
|
||||||
<TableRow>
|
<span class="font-medium">{m.name}</span>
|
||||||
<TableCell class="font-medium">{m.name}</TableCell>
|
<Button variant="ghost" size="icon-sm" href={resolve(`/modules/${m.id}`)}>
|
||||||
<TableCell><Badge variant="outline">{moduleTypeRu(m.type)}</Badge></TableCell>
|
<ExternalLink class="size-3.5" aria-hidden="true" />
|
||||||
<TableCell>{intervalLabel(m.refresh_interval_sec)}</TableCell>
|
</Button>
|
||||||
<TableCell class="font-mono text-xs">{m.cron_expr || '—'}</TableCell>
|
</div>
|
||||||
<TableCell class="text-right">
|
{:else if column.id === 'type'}
|
||||||
<Button
|
<Badge variant={moduleTypeBadgeVariant(m.type)}>{moduleTypeRu(m.type)}</Badge>
|
||||||
size="xs"
|
{:else if column.id === 'schedule'}
|
||||||
variant="secondary"
|
<span class="font-mono text-xs text-muted-foreground">{moduleIntervalLabel(m)}</span>
|
||||||
disabled={!!refreshing[m.id]}
|
{:else if column.id === 'refreshed'}
|
||||||
onclick={() => refreshModule(m.id)}
|
<span class="text-sm whitespace-nowrap text-muted-foreground"
|
||||||
|
>{formatDateTime(m.last_refreshed_at)}</span
|
||||||
|
>
|
||||||
|
{:else if column.id === 'status'}
|
||||||
|
{#if m.enabled}
|
||||||
|
<Badge variant="default" class="text-xs">Вкл</Badge>
|
||||||
|
{:else}
|
||||||
|
<Badge variant="secondary" class="text-xs">Выкл</Badge>
|
||||||
|
{/if}
|
||||||
|
{:else if column.id === 'actions'}
|
||||||
|
<div class="text-right">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!!moduleRefreshing[m.id]}
|
||||||
|
onclick={() => refreshModule(m.id)}
|
||||||
|
>
|
||||||
|
<RefreshCw class={moduleRefreshing[m.id] ? 'animate-spin' : ''} />
|
||||||
|
{moduleRefreshing[m.id] ? '…' : 'Обновить'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<div class="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<CardTitle class="text-base">Задачи</CardTitle>
|
||||||
|
<CardDescription>Последние 100 задач из API</CardDescription>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" href={resolve('/operations')}>Все операции</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="space-y-4 p-4 pt-0">
|
||||||
|
<Tabs bind:value={jobsTab}>
|
||||||
|
<TabsList class="inline-flex min-w-max">
|
||||||
|
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
|
||||||
|
<TabsTrigger value="refresh">
|
||||||
|
Обновление модулей ({jobs.filter((j) => j.kind === 'module_refresh').length})
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="failed">С ошибкой ({failedJobsCount})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value={jobsTab} class="mt-4">
|
||||||
|
<AppDataTable
|
||||||
|
columns={[...jobColumns]}
|
||||||
|
rows={filteredJobs}
|
||||||
|
rowKey={(j) => j.job_id}
|
||||||
|
loading={initialLoading}
|
||||||
|
error={loadError}
|
||||||
|
emptyTitle="Нет задач"
|
||||||
|
emptyDescription={jobsTab === 'failed'
|
||||||
|
? 'В выборке нет задач с ошибкой.'
|
||||||
|
: jobsTab === 'refresh'
|
||||||
|
? 'Задач обновления модулей пока нет.'
|
||||||
|
: 'Задачи появятся после refresh или деплоя.'}
|
||||||
|
>
|
||||||
|
{#snippet cell({ row: j, column })}
|
||||||
|
{#if column.id === 'kind'}
|
||||||
|
<span class="font-medium">{jobKindTitle(j, moduleNameById)}</span>
|
||||||
|
{:else if column.id === 'status'}
|
||||||
|
<Badge variant={jobStatusBadgeVariant(j.status)}>{jobStatusRu(j.status)}</Badge>
|
||||||
|
{:else if column.id === 'created'}
|
||||||
|
<span class="text-xs whitespace-nowrap text-muted-foreground"
|
||||||
|
>{formatDateTime(j.created_at)}</span
|
||||||
>
|
>
|
||||||
<RefreshCw class={refreshing[m.id] ? 'animate-spin' : ''} />
|
{:else if column.id === 'finished'}
|
||||||
{refreshing[m.id] ? '…' : 'Обновить'}
|
<span class="text-xs whitespace-nowrap text-muted-foreground"
|
||||||
</Button>
|
>{formatDateTime(j.finished_at)}</span
|
||||||
</TableCell>
|
>
|
||||||
</TableRow>
|
{:else if column.id === 'error'}
|
||||||
{:else}
|
<span class="text-xs text-destructive">{truncateError(j.error)}</span>
|
||||||
<TableRow>
|
{/if}
|
||||||
<TableCell colspan={5} class="text-muted-foreground text-center py-6">
|
{/snippet}
|
||||||
{loading ? 'Загрузка…' : 'Нет модулей'}
|
</AppDataTable>
|
||||||
</TableCell>
|
</TabsContent>
|
||||||
</TableRow>
|
</Tabs>
|
||||||
{/each}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="text-base">Последние задачи</CardTitle>
|
|
||||||
<CardDescription>Задачи разложены по статусу и времени запуска.</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Вид</TableHead>
|
|
||||||
<TableHead>Статус</TableHead>
|
|
||||||
<TableHead>Создана</TableHead>
|
|
||||||
<TableHead>Ошибка</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{#each jobs as j (j.job_id)}
|
|
||||||
<TableRow>
|
|
||||||
<TableCell class="font-medium">{jobKindTitle(j, moduleNameById)}</TableCell>
|
|
||||||
<TableCell
|
|
||||||
><Badge variant={jobStatusVariant(j.status)}>{jobStatusRu(j.status)}</Badge
|
|
||||||
></TableCell
|
|
||||||
>
|
|
||||||
<TableCell class="text-xs text-muted-foreground"
|
|
||||||
>{j.created_at ? new Date(j.created_at).toLocaleString('ru') : '—'}</TableCell
|
|
||||||
>
|
|
||||||
<TableCell class="max-w-xs truncate text-xs text-destructive"
|
|
||||||
>{j.error ?? ''}</TableCell
|
|
||||||
>
|
|
||||||
</TableRow>
|
|
||||||
{:else}
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colspan={4} class="text-muted-foreground text-center py-6">
|
|
||||||
{loading ? 'Загрузка…' : 'Нет задач'}
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{/each}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle class="text-base">Операции обновления модулей</CardTitle>
|
|
||||||
<CardDescription
|
|
||||||
>Отдельная лента задач обновления модулей для контроля по модулям.</CardDescription
|
|
||||||
>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent class="p-0">
|
|
||||||
<Table>
|
|
||||||
<TableHeader>
|
|
||||||
<TableRow>
|
|
||||||
<TableHead>Статус</TableHead>
|
|
||||||
<TableHead>Создана</TableHead>
|
|
||||||
<TableHead>Ревизия</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{#each refreshJobs as j (j.job_id)}
|
|
||||||
<TableRow>
|
|
||||||
<TableCell
|
|
||||||
><Badge variant={jobStatusVariant(j.status)}>{jobStatusRu(j.status)}</Badge
|
|
||||||
></TableCell
|
|
||||||
>
|
|
||||||
<TableCell class="text-xs"
|
|
||||||
>{j.created_at ? new Date(j.created_at).toLocaleString('ru') : '—'}</TableCell
|
|
||||||
>
|
|
||||||
<TableCell class="font-mono text-xs"
|
|
||||||
>{typeof j.meta?.revision_id === 'string'
|
|
||||||
? `${j.meta.revision_id.slice(0, 12)}…`
|
|
||||||
: '—'}</TableCell
|
|
||||||
>
|
|
||||||
</TableRow>
|
|
||||||
{:else}
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colspan={3} class="text-muted-foreground text-center py-6"
|
|
||||||
>Нет задач обновления модулей</TableCell
|
|
||||||
>
|
|
||||||
</TableRow>
|
|
||||||
{/each}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user