feat(runtime-logs): update documentation and UI for runtime log management
Обновлены разделы документации для управления файловыми логами, включая новые эндпоинты и параметры. Добавлены описания для вкладки «Файловые логи» в интерфейсе мониторинга и обновлены настройки tenant. Улучшен доступ к логам через API и интерфейс пользователя. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
+14
-1
@@ -90,7 +90,20 @@
|
|||||||
|
|
||||||
### Settings
|
### Settings
|
||||||
|
|
||||||
- `GET /v1/settings`, `PATCH /v1/settings`
|
- `GET /v1/settings`, `PATCH /v1/settings` — tenant KV (`global_settings`): BIRD, `revision_retention_minutes`, произвольные ключи. Чтение — viewer+; `PATCH` — operator+.
|
||||||
|
|
||||||
|
### RuntimeLogs
|
||||||
|
|
||||||
|
Файловые логи Docker-сервисов (sidecar `stack-runtime-logs`). FS API **только** в процессе **`evobgp-all`** при `EVOBGP_SERVICE=evobgp-all` и `EVOBGP_RUNTIME_LOGS_DIR` (см. [access.md](access.md)). Иначе `GET`/`DELETE` по файлам → **503** (`runtime_logs_unavailable`).
|
||||||
|
|
||||||
|
| Метод | Путь | Роль | Назначение |
|
||||||
|
|-------|------|------|------------|
|
||||||
|
| `GET` | `/v1/runtime-logs/files` | viewer+ | Список `*.log` (имя, размер, mtime) |
|
||||||
|
| `GET` | `/v1/runtime-logs/files/{filename}` | viewer+ | Хвост файла (`?lines=`, `?bytes=`, `?grep=`) |
|
||||||
|
| `DELETE` | `/v1/runtime-logs/files/{filename}` | operator+ | Синхронная очистка (`?mode=truncate\|delete`, default truncate); max 512 MiB |
|
||||||
|
| `GET` | `/v1/runtime-logs/cleanup-audit` | viewer+ | Пагинированный audit очистки (`cursor`, `limit`) |
|
||||||
|
|
||||||
|
`{filename}` — только basename, паттерн `^[a-z0-9][a-z0-9_.-]*\.log$`. Очистка пишет строку в таблицу `runtime_log_cleanup_audit` (миграция `000026`).
|
||||||
|
|
||||||
## Соглашения из OpenAPI
|
## Соглашения из OpenAPI
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -85,7 +85,8 @@ EvoBGP управляет генерацией и применением BGP-к
|
|||||||
- `AS Entries`, `CDN Sources`, `Domain Entries`, `IP Range Entries`: источники префиксов.
|
- `AS Entries`, `CDN Sources`, `Domain Entries`, `IP Range Entries`: источники префиксов.
|
||||||
- `Peers`, `Speakers`: сетевая топология применения.
|
- `Peers`, `Speakers`: сетевая топология применения.
|
||||||
- `Revisions`, `Deploy`, `Jobs`: жизненный цикл ревизий и фоновых задач.
|
- `Revisions`, `Deploy`, `Jobs`: жизненный цикл ревизий и фоновых задач.
|
||||||
- `Settings`: глобальные KV-настройки.
|
- `Settings`: глобальные KV-настройки tenant.
|
||||||
|
- `RuntimeLogs`: файловые логи compose (только `evobgp-all` + volume).
|
||||||
- `Node`: edge-флоу бандлов/enrollment.
|
- `Node`: edge-флоу бандлов/enrollment.
|
||||||
|
|
||||||
### Типовой сценарий оператора
|
### Типовой сценарий оператора
|
||||||
@@ -106,6 +107,22 @@ EvoBGP управляет генерацией и применением BGP-к
|
|||||||
- Ключевые параметры BIRD: `bird_router_id`, `bird_local_ipv4`, `bird_local_ipv6`, `bird_local_asn`, `bird_bgp_source_ipv4`, `bird_bgp_source_ipv6`.
|
- Ключевые параметры BIRD: `bird_router_id`, `bird_local_ipv4`, `bird_local_ipv6`, `bird_local_asn`, `bird_bgp_source_ipv4`, `bird_bgp_source_ipv6`.
|
||||||
- **Tenant settings** — глобальный default. **Per-speaker** override: `meta_json.bird_bgp_source_ipv4` / `node_ipv4` в карточке спикера (Web UI → Сеть → Спикеры); pipeline накладывает overlay при сборке бандла для реплики. См. [remote-speakers.md](remote-speakers.md).
|
- **Tenant settings** — глобальный default. **Per-speaker** override: `meta_json.bird_bgp_source_ipv4` / `node_ipv4` в карточке спикера (Web UI → Сеть → Спикеры); pipeline накладывает overlay при сборке бандла для реплики. См. [remote-speakers.md](remote-speakers.md).
|
||||||
|
|
||||||
|
### Web UI: настройки tenant и интерфейса
|
||||||
|
|
||||||
|
| Маршрут | Назначение |
|
||||||
|
|---------|------------|
|
||||||
|
| `/settings` | Только браузер: API-токен, тема (localStorage). Tenant KV здесь **не** редактируются. |
|
||||||
|
| `/tenant-settings` | Все tenant-параметры из `/v1/settings`: вкладки **BIRD**, **Ревизии** (`revision_retention_minutes`), **Дополнительно** (custom KV). Пункт nav **«Параметры»**. |
|
||||||
|
| `/network` → Control plane | Краткая сводка BIRD + ссылка на `/tenant-settings?tab=bird`. |
|
||||||
|
| `/operations` | Ревизии, diff, jobs; вкладка «Система» перенесена в `/tenant-settings`. |
|
||||||
|
|
||||||
|
### Web UI: файловые runtime-логи
|
||||||
|
|
||||||
|
- **Мониторинг** → вкладка **«Файловые логи»** (`/monitoring?tab=runtime-logs`).
|
||||||
|
- Подвкладки: **Файлы** (список, preview хвоста, очистка operator) и **Audit очистки**.
|
||||||
|
- При **503** на списке файлов: FS API недоступен (не `evobgp-all` или нет volume); audit из БД может отображаться отдельно.
|
||||||
|
- Deploy: `EVOBGP_RUNTIME_LOGS_DIR`, bind-mount на `evobgp-all`, sidecar `stack-runtime-logs` — [quickstart.md](quickstart.md#файловые-runtime-логи-api-v1runtime-logs), [access.md](access.md).
|
||||||
|
|
||||||
## 7. Эксплуатация и runbook
|
## 7. Эксплуатация и runbook
|
||||||
|
|
||||||
### Что проверять при инцидентах
|
### Что проверять при инцидентах
|
||||||
|
|||||||
@@ -3,21 +3,22 @@
|
|||||||
## Текущий фокус␍
|
## Текущий фокус␍
|
||||||
␍
|
␍
|
||||||
**Task:** `settings-ui-and-runtime-logs` ␍
|
**Task:** `settings-ui-and-runtime-logs` ␍
|
||||||
**Phase:** **BUILD Phase 5 complete** → **Phase 6** (Runtime logs Web UI)␍
|
**Phase:** **BUILD complete** → **REFLECT**␍
|
||||||
␍
|
␍
|
||||||
## Phase 5 deliverables␍
|
## Phase 7 deliverables␍
|
||||||
␍
|
␍
|
||||||
- `web/src/routes/tenant-settings/+page.svelte`␍
|
- `docs/api.md` — RuntimeLogs endpoints␍
|
||||||
- `web/src/lib/components/tenant-settings/` — TenantSettingsPage, Bird/Revision/Additional cards␍
|
- `docs/manual.md` — Web UI `/tenant-settings`, Monitoring runtime logs␍
|
||||||
- `web/src/lib/components/network/NetworkBirdSettingsSummaryCard.svelte`␍
|
- QA: `go test ./...`, redocly lint, web check+lint, lint-go.ps1 — pass␍
|
||||||
- nav «Параметры» → `/tenant-settings`␍
|
␍
|
||||||
- Operations: убран tab `system`, редирект `?tab=system` → tenant-settings␍
|
## E2E manual (на сервере, не автоматизировано)␍
|
||||||
- Удалены `OperationsSystemSettingsTab`, `BirdSettingsForm`␍
|
␍
|
||||||
|
См. checklist в `memory-bank/tasks.md` Phase 7.␍
|
||||||
␍
|
␍
|
||||||
## Следующий шаг␍
|
## Следующий шаг␍
|
||||||
␍
|
␍
|
||||||
```␍
|
```␍
|
||||||
/build Phase 6␍
|
/reflect␍
|
||||||
```␍
|
```␍
|
||||||
␍
|
␍
|
||||||
Monitoring tab «Файловые логи» + API client runtime logs.␍
|
Затем `/archive` по workflow Memory Bank.␍
|
||||||
|
|||||||
@@ -10,5 +10,5 @@
|
|||||||
| BUILD P3 HTTP | ✅ 2026-06-12 |
|
| BUILD P3 HTTP | ✅ 2026-06-12 |
|
||||||
| BUILD P4 Deploy | ✅ 2026-06-12 |
|
| BUILD P4 Deploy | ✅ 2026-06-12 |
|
||||||
| BUILD P5 Tenant UI | ✅ 2026-06-12 |
|
| BUILD P5 Tenant UI | ✅ 2026-06-12 |
|
||||||
| BUILD P6 Runtime logs UI | ⏳ |
|
| BUILD P6 Runtime logs UI | ✅ 2026-06-12 |
|
||||||
| BUILD P7 QA | ⏳ |
|
| BUILD P7 QA | ✅ 2026-06-12 |
|
||||||
|
|||||||
+28
-9
@@ -8,7 +8,7 @@
|
|||||||
|------|----------|
|
|------|----------|
|
||||||
| **Task ID** | `settings-ui-and-runtime-logs` |
|
| **Task ID** | `settings-ui-and-runtime-logs` |
|
||||||
| **Complexity** | **Level 4** |
|
| **Complexity** | **Level 4** |
|
||||||
| **Status** | **BUILD Phase 5 complete** → Phase 6 |
|
| **Status** | **BUILD complete** → REFLECT |
|
||||||
| **Дата VAN** | 2026-06-12 |
|
| **Дата VAN** | 2026-06-12 |
|
||||||
| **Дата PLAN** | 2026-06-12 |
|
| **Дата PLAN** | 2026-06-12 |
|
||||||
|
|
||||||
@@ -274,7 +274,8 @@ environment:
|
|||||||
| 6.8 | 503 empty state | «Доступно только на evobgp-all с volume» |
|
| 6.8 | 503 empty state | «Доступно только на evobgp-all с volume» |
|
||||||
|
|
||||||
**Checklist Phase 6:**
|
**Checklist Phase 6:**
|
||||||
- [ ] `npm run check && npm run lint`
|
- [x] `npm run check && npm run lint`
|
||||||
|
- [x] `/monitoring?tab=runtime-logs` + nested files/audit
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -287,6 +288,23 @@ environment:
|
|||||||
| 7.3 | `go test ./... -race -count=1` |
|
| 7.3 | `go test ./... -race -count=1` |
|
||||||
| 7.4 | E2E manual: list → tail → truncate → audit row |
|
| 7.4 | E2E manual: list → tail → truncate → audit row |
|
||||||
|
|
||||||
|
**Checklist Phase 7:**
|
||||||
|
- [x] `docs/api.md` — секция RuntimeLogs
|
||||||
|
- [x] `docs/manual.md` — Web UI tenant-settings + runtime logs
|
||||||
|
- [x] `go test ./... -count=1` exit 0 (Windows: без `-race`, CGO disabled)
|
||||||
|
- [x] `npx @redocly/cli lint docs/openapi.yaml`
|
||||||
|
- [x] `npm run check && npm run lint`
|
||||||
|
- [x] `scripts/lint-go.ps1`
|
||||||
|
- [ ] E2E manual на prod/staging (см. ниже)
|
||||||
|
|
||||||
|
**E2E manual (оператор на evobgp-all с volume):**
|
||||||
|
1. `GET /v1/runtime-logs/files` → 200, список `*.log`
|
||||||
|
2. `GET /v1/runtime-logs/files/evobgp-all.log?lines=50` → content
|
||||||
|
3. UI: Monitoring → Файловые логи → preview
|
||||||
|
4. `DELETE ...?mode=truncate` (operator) → 200 + audit_id
|
||||||
|
5. `GET /v1/runtime-logs/cleanup-audit` → новая строка
|
||||||
|
6. `/tenant-settings` — BIRD + revision сохраняются через PATCH
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dependency Graph
|
## Dependency Graph
|
||||||
@@ -334,10 +352,10 @@ graph TD
|
|||||||
- [x] `/settings` — только frontend (токен, тема)
|
- [x] `/settings` — только frontend (токен, тема)
|
||||||
- [x] `/tenant-settings` — все tenant KV (BIRD + revision + custom)
|
- [x] `/tenant-settings` — все tenant KV (BIRD + revision + custom)
|
||||||
- [x] Operations без tab `system`; Network без полной BIRD-формы (summary + link)
|
- [x] Operations без tab `system`; Network без полной BIRD-формы (summary + link)
|
||||||
- [ ] Runtime logs: list, tail, sync cleanup на evobgp-all
|
- [x] Runtime logs: list, tail, sync cleanup на evobgp-all (UI)
|
||||||
- [ ] Audit cleanup в БД + просмотр в UI
|
- [x] Audit cleanup в БД + просмотр в UI
|
||||||
- [x] `EVOBGP_RUNTIME_LOGS_DIR`, volume в compose
|
- [x] `EVOBGP_RUNTIME_LOGS_DIR`, volume в compose
|
||||||
- [ ] redocly lint, go test -race, web check+lint
|
- [x] redocly lint, go test, web check+lint
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -351,7 +369,8 @@ graph TD
|
|||||||
- [x] BUILD Phase 3 HTTP handlers
|
- [x] BUILD Phase 3 HTTP handlers
|
||||||
- [x] BUILD Phase 4 Deploy (compose)
|
- [x] BUILD Phase 4 Deploy (compose)
|
||||||
- [x] BUILD Phase 5 Tenant settings UI
|
- [x] BUILD Phase 5 Tenant settings UI
|
||||||
- [ ] BUILD Phase 6–7
|
- [x] BUILD Phase 6 Runtime logs UI
|
||||||
|
- [x] BUILD Phase 7 (docs + QA)
|
||||||
- [ ] REFLECT
|
- [ ] REFLECT
|
||||||
- [ ] ARCHIVE
|
- [ ] ARCHIVE
|
||||||
|
|
||||||
@@ -360,9 +379,9 @@ graph TD
|
|||||||
## Key Files (reference)
|
## Key Files (reference)
|
||||||
|
|
||||||
**Settings today:**
|
**Settings today:**
|
||||||
- `web/src/routes/settings/+page.svelte` — keep frontend-only
|
- `web/src/routes/settings/+page.svelte` — frontend-only
|
||||||
- `web/src/lib/components/operations/OperationsSystemSettingsTab.svelte` — migrate out
|
- `web/src/routes/tenant-settings/+page.svelte` — tenant KV
|
||||||
- `web/src/lib/components/network/BirdSettingsForm.svelte` — migrate out
|
- `web/src/lib/components/tenant-settings/*` — BIRD / revision / additional cards
|
||||||
- `internal/httpapi/routes_crud.go` — settings handlers (unchanged contract)
|
- `internal/httpapi/routes_crud.go` — settings handlers (unchanged contract)
|
||||||
|
|
||||||
**Runtime logs today:**
|
**Runtime logs today:**
|
||||||
|
|||||||
@@ -0,0 +1,431 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { apiJSON } from '$lib/api/client.js';
|
||||||
|
import type { AuthSession } from '$lib/api/types.js';
|
||||||
|
import {
|
||||||
|
cleanupRuntimeLogFile,
|
||||||
|
getRuntimeLogTail,
|
||||||
|
isRuntimeLogsUnavailable,
|
||||||
|
listRuntimeLogCleanupAudit,
|
||||||
|
listRuntimeLogFiles,
|
||||||
|
type RuntimeLogCleanupAudit,
|
||||||
|
type RuntimeLogCleanupMode,
|
||||||
|
type RuntimeLogFile
|
||||||
|
} from '$lib/runtime-logs/runtime-logs-api.js';
|
||||||
|
import { formatBytes } from '$lib/monitoring/postgres.js';
|
||||||
|
import { formatDateTime } from '$lib/modules/display.js';
|
||||||
|
import { Button } from '$lib/ui/core/button/index.js';
|
||||||
|
import { Badge } from '$lib/ui/core/badge/index.js';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle
|
||||||
|
} from '$lib/ui/core/card/index.js';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '$lib/ui/core/dialog/index.js';
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '$lib/ui/core/dropdown-menu/index.js';
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||||
|
import AppDataTable from '$lib/ui/patterns/data-table/app-data-table.svelte';
|
||||||
|
import type { DataTableColumn } from '$lib/ui/patterns/data-table/types.js';
|
||||||
|
import EmptyState from '$lib/ui/patterns/empty-state/empty-state.svelte';
|
||||||
|
import { confirm } from '$lib/ui/patterns/confirm/confirm-state.svelte.js';
|
||||||
|
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
||||||
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
||||||
|
import {
|
||||||
|
dialogBodyDocument,
|
||||||
|
dialogContentDocument,
|
||||||
|
dialogHeaderDocument
|
||||||
|
} from '$lib/dialog-layout.js';
|
||||||
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
|
import Eye from '@lucide/svelte/icons/eye';
|
||||||
|
import Eraser from '@lucide/svelte/icons/eraser';
|
||||||
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
||||||
|
import HardDrive from '@lucide/svelte/icons/hard-drive';
|
||||||
|
import FileText from '@lucide/svelte/icons/file-text';
|
||||||
|
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
|
||||||
|
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
||||||
|
|
||||||
|
type SubTab = 'files' | 'audit';
|
||||||
|
|
||||||
|
let subTab = $state<SubTab>('files');
|
||||||
|
let session = $state<AuthSession | null>(null);
|
||||||
|
let filesUnavailable = $state(false);
|
||||||
|
let filesLoading = $state(true);
|
||||||
|
let files = $state<RuntimeLogFile[]>([]);
|
||||||
|
|
||||||
|
let auditLoading = $state(false);
|
||||||
|
let auditItems = $state<RuntimeLogCleanupAudit[]>([]);
|
||||||
|
let auditCursor = $state<string | undefined>(undefined);
|
||||||
|
let auditHasMore = $state(false);
|
||||||
|
|
||||||
|
let previewOpen = $state(false);
|
||||||
|
let previewFilename = $state('');
|
||||||
|
let previewLoading = $state(false);
|
||||||
|
let previewContent = $state('');
|
||||||
|
let previewTruncated = $state(false);
|
||||||
|
let previewLines = $state(0);
|
||||||
|
|
||||||
|
let cleaningFilename = $state<string | null>(null);
|
||||||
|
|
||||||
|
const isOperator = $derived(session?.role === 'operator');
|
||||||
|
|
||||||
|
const totalBytes = $derived(files.reduce((sum, f) => sum + (f.size_bytes ?? 0), 0));
|
||||||
|
|
||||||
|
const fileColumns: DataTableColumn<RuntimeLogFile>[] = [
|
||||||
|
{ id: 'name', label: 'Файл', sortable: true, sortValue: (f) => f.name },
|
||||||
|
{
|
||||||
|
id: 'size',
|
||||||
|
label: 'Размер',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (f) => f.size_bytes
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'modified',
|
||||||
|
label: 'Изменён',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (f) => f.modified_at
|
||||||
|
},
|
||||||
|
{ id: 'actions', label: '', class: 'w-36' }
|
||||||
|
];
|
||||||
|
|
||||||
|
const auditColumns: DataTableColumn<RuntimeLogCleanupAudit>[] = [
|
||||||
|
{
|
||||||
|
id: 'created',
|
||||||
|
label: 'Время',
|
||||||
|
sortable: true,
|
||||||
|
sortValue: (r) => r.created_at
|
||||||
|
},
|
||||||
|
{ id: 'actor', label: 'Actor' },
|
||||||
|
{ id: 'filename', label: 'Файл', sortable: true, sortValue: (r) => r.filename },
|
||||||
|
{ id: 'action', label: 'Действие' },
|
||||||
|
{ id: 'sizes', label: 'Размер' }
|
||||||
|
];
|
||||||
|
|
||||||
|
async function loadSession() {
|
||||||
|
try {
|
||||||
|
session = await apiJSON<AuthSession>('/v1/auth/session');
|
||||||
|
} catch {
|
||||||
|
session = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadFiles() {
|
||||||
|
filesLoading = true;
|
||||||
|
try {
|
||||||
|
files = await listRuntimeLogFiles();
|
||||||
|
filesUnavailable = false;
|
||||||
|
} catch (e) {
|
||||||
|
if (isRuntimeLogsUnavailable(e)) {
|
||||||
|
filesUnavailable = true;
|
||||||
|
files = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
notifyApiError(e, 'Файловые логи');
|
||||||
|
} finally {
|
||||||
|
filesLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAudit(reset = true) {
|
||||||
|
auditLoading = true;
|
||||||
|
try {
|
||||||
|
const page = await listRuntimeLogCleanupAudit({
|
||||||
|
limit: 20,
|
||||||
|
cursor: reset ? undefined : auditCursor
|
||||||
|
});
|
||||||
|
auditItems = reset ? (page.items ?? []) : [...auditItems, ...(page.items ?? [])];
|
||||||
|
auditCursor = page.next_cursor;
|
||||||
|
auditHasMore = Boolean(page.has_more && page.next_cursor);
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e, 'Audit очистки логов');
|
||||||
|
} finally {
|
||||||
|
auditLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshAll() {
|
||||||
|
await Promise.all([loadFiles(), loadAudit(true)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openPreview(file: RuntimeLogFile) {
|
||||||
|
previewFilename = file.name;
|
||||||
|
previewOpen = true;
|
||||||
|
previewLoading = true;
|
||||||
|
previewContent = '';
|
||||||
|
try {
|
||||||
|
const tail = await getRuntimeLogTail(file.name, { lines: 200 });
|
||||||
|
previewContent = tail.content;
|
||||||
|
previewTruncated = tail.truncated;
|
||||||
|
previewLines = tail.lines_returned;
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
previewOpen = false;
|
||||||
|
} finally {
|
||||||
|
previewLoading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestCleanup(file: RuntimeLogFile, mode: RuntimeLogCleanupMode) {
|
||||||
|
const actionLabel = mode === 'delete' ? 'удалить файл' : 'обнулить (truncate)';
|
||||||
|
void confirm({
|
||||||
|
title: mode === 'delete' ? 'Удалить log-файл?' : 'Очистить log-файл?',
|
||||||
|
description: `${file.name} · ${formatBytes(file.size_bytes)}. Действие: ${actionLabel}. Операция синхронная.`,
|
||||||
|
confirmLabel: mode === 'delete' ? 'Удалить' : 'Очистить',
|
||||||
|
destructive: true,
|
||||||
|
onConfirm: async () => {
|
||||||
|
cleaningFilename = file.name;
|
||||||
|
try {
|
||||||
|
await cleanupRuntimeLogFile(file.name, mode);
|
||||||
|
notify.success(mode === 'delete' ? 'Файл удалён' : 'Файл обнулён');
|
||||||
|
await refreshAll();
|
||||||
|
} catch (e) {
|
||||||
|
notifyApiError(e);
|
||||||
|
} finally {
|
||||||
|
cleaningFilename = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function actionBadgeVariant(action: RuntimeLogCleanupMode): 'secondary' | 'destructive' {
|
||||||
|
return action === 'delete' ? 'destructive' : 'secondary';
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
void (async () => {
|
||||||
|
await loadSession();
|
||||||
|
await loadFiles();
|
||||||
|
await loadAudit(true);
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (subTab === 'audit' && auditItems.length === 0 && !auditLoading) {
|
||||||
|
void loadAudit(true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<HardDrive class="size-4" />
|
||||||
|
<span>Файловые логи Docker-сервисов (sidecar stack-runtime-logs)</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onclick={() => refreshAll()}
|
||||||
|
disabled={filesLoading || auditLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw class={filesLoading || auditLoading ? 'animate-spin' : ''} />
|
||||||
|
Обновить
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if filesUnavailable}
|
||||||
|
<EmptyState
|
||||||
|
icon={HardDrive}
|
||||||
|
title="Файловые логи недоступны"
|
||||||
|
description="Список и очистка *.log доступны только на evobgp-all с примонтированным каталогом runtime-logs (EVOBGP_RUNTIME_LOGS_DIR и EVOBGP_SERVICE=evobgp-all). См. docs/access.md в репозитории. Вкладка audit очистки ниже работает без volume."
|
||||||
|
/>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if !filesUnavailable}
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardDescription>Файлов</CardDescription>
|
||||||
|
<CardTitle class="text-2xl tabular-nums">{files.length}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
<Card>
|
||||||
|
<CardHeader class="pb-2">
|
||||||
|
<CardDescription>Суммарный размер</CardDescription>
|
||||||
|
<CardTitle class="text-2xl tabular-nums">{formatBytes(totalBytes)}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<Tabs bind:value={subTab}>
|
||||||
|
<TabsList>
|
||||||
|
<TabsTrigger value="files">Файлы</TabsTrigger>
|
||||||
|
<TabsTrigger value="audit">Audit очистки</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="files" class="mt-4">
|
||||||
|
{#if filesUnavailable}
|
||||||
|
<EmptyState
|
||||||
|
icon={FileText}
|
||||||
|
title="FS API отключён"
|
||||||
|
description="Примонтируйте runtime-logs к evobgp-all и задайте EVOBGP_RUNTIME_LOGS_DIR."
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">*.log на хосте</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Просмотр хвоста и синхронная очистка (truncate по умолчанию). Очистка — роль operator.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="min-w-0 p-4 pt-0">
|
||||||
|
<AppDataTable
|
||||||
|
columns={fileColumns}
|
||||||
|
rows={files}
|
||||||
|
rowKey={(f) => f.name}
|
||||||
|
loading={filesLoading}
|
||||||
|
emptyTitle="Нет log-файлов"
|
||||||
|
emptyDescription="Sidecar ещё не создал файлы или каталог пуст."
|
||||||
|
>
|
||||||
|
{#snippet cell({ row, column })}
|
||||||
|
{#if column.id === 'name'}
|
||||||
|
<span class="font-mono text-xs">{row.name}</span>
|
||||||
|
{:else if column.id === 'size'}
|
||||||
|
{formatBytes(row.size_bytes)}
|
||||||
|
{:else if column.id === 'modified'}
|
||||||
|
<span class="text-sm">{formatDateTime(row.modified_at)}</span>
|
||||||
|
{:else if column.id === 'actions'}
|
||||||
|
<div class="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Просмотр"
|
||||||
|
onclick={() => openPreview(row)}
|
||||||
|
>
|
||||||
|
<Eye class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
{#if isOperator}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon-sm"
|
||||||
|
aria-label="Очистить (truncate)"
|
||||||
|
disabled={cleaningFilename === row.name}
|
||||||
|
onclick={() => requestCleanup(row, 'truncate')}
|
||||||
|
>
|
||||||
|
{#if cleaningFilename === row.name}
|
||||||
|
<LoaderCircle class="size-3.5 animate-spin" />
|
||||||
|
{:else}
|
||||||
|
<Eraser class="size-3.5" />
|
||||||
|
{/if}
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger>
|
||||||
|
{#snippet child({ props })}
|
||||||
|
<Button {...props} variant="ghost" size="icon-sm" aria-label="Ещё">
|
||||||
|
<MoreHorizontal class="size-3.5" />
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem
|
||||||
|
variant="destructive"
|
||||||
|
disabled={cleaningFilename === row.name}
|
||||||
|
onclick={() => requestCleanup(row, 'delete')}
|
||||||
|
>
|
||||||
|
<Trash2 class="size-4" />
|
||||||
|
Удалить файл
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
{/if}
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="audit" class="mt-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle class="text-base">История очистки</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
Записи из <code class="text-xs">runtime_log_cleanup_audit</code> (viewer+).
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent class="min-w-0 space-y-3 p-4 pt-0">
|
||||||
|
<AppDataTable
|
||||||
|
columns={auditColumns}
|
||||||
|
rows={auditItems}
|
||||||
|
rowKey={(r) => r.id}
|
||||||
|
loading={auditLoading && auditItems.length === 0}
|
||||||
|
emptyTitle="Записей пока нет"
|
||||||
|
emptyDescription="Очистка log-файлов появится здесь после operator DELETE."
|
||||||
|
>
|
||||||
|
{#snippet cell({ row, column })}
|
||||||
|
{#if column.id === 'created'}
|
||||||
|
<span class="text-sm">{formatDateTime(row.created_at)}</span>
|
||||||
|
{:else if column.id === 'actor'}
|
||||||
|
<span class="font-mono text-xs">{row.actor_prefix}</span>
|
||||||
|
{:else if column.id === 'filename'}
|
||||||
|
<span class="font-mono text-xs">{row.filename}</span>
|
||||||
|
{:else if column.id === 'action'}
|
||||||
|
<Badge variant={actionBadgeVariant(row.action)}>{row.action}</Badge>
|
||||||
|
{:else if column.id === 'sizes'}
|
||||||
|
<span class="text-xs tabular-nums">
|
||||||
|
{formatBytes(row.size_before)}
|
||||||
|
{#if row.size_after != null}
|
||||||
|
→ {formatBytes(row.size_after)}
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{/snippet}
|
||||||
|
</AppDataTable>
|
||||||
|
{#if auditHasMore}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={auditLoading}
|
||||||
|
onclick={() => loadAudit(false)}
|
||||||
|
>
|
||||||
|
{#if auditLoading}
|
||||||
|
<LoaderCircle class="size-4 animate-spin" />
|
||||||
|
{/if}
|
||||||
|
Загрузить ещё
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Dialog bind:open={previewOpen}>
|
||||||
|
<DialogContent class={dialogContentDocument}>
|
||||||
|
<DialogHeader class={dialogHeaderDocument}>
|
||||||
|
<DialogTitle class="flex items-center gap-2">
|
||||||
|
<FileText class="size-4" />
|
||||||
|
{previewFilename}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{#if previewTruncated}
|
||||||
|
Показан усечённый хвост ({previewLines} строк).
|
||||||
|
{:else}
|
||||||
|
Хвост файла ({previewLines} строк).
|
||||||
|
{/if}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div class={dialogBodyDocument}>
|
||||||
|
{#if previewLoading}
|
||||||
|
<p class="text-sm text-muted-foreground">Загрузка…</p>
|
||||||
|
{:else}
|
||||||
|
<ScrollPreBlock variant="preserve" text={previewContent || '—'} class="max-h-[70vh]" />
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { apiJSON, apiMutate, ApiError } from '$lib/api/client.js';
|
||||||
|
|
||||||
|
export type RuntimeLogFile = {
|
||||||
|
name: string;
|
||||||
|
size_bytes: number;
|
||||||
|
modified_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeLogTail = {
|
||||||
|
filename: string;
|
||||||
|
content: string;
|
||||||
|
truncated: boolean;
|
||||||
|
lines_returned: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeLogCleanupMode = 'truncate' | 'delete';
|
||||||
|
|
||||||
|
export type RuntimeLogCleanupResult = {
|
||||||
|
audit_id: string;
|
||||||
|
filename: string;
|
||||||
|
action: RuntimeLogCleanupMode;
|
||||||
|
size_before: number;
|
||||||
|
size_after?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeLogCleanupAudit = {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
actor_prefix: string;
|
||||||
|
filename: string;
|
||||||
|
action: RuntimeLogCleanupMode;
|
||||||
|
size_before: number;
|
||||||
|
size_after?: number | null;
|
||||||
|
detail?: Record<string, unknown>;
|
||||||
|
created_at: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RuntimeLogCleanupAuditList = {
|
||||||
|
items: RuntimeLogCleanupAudit[];
|
||||||
|
next_cursor?: string;
|
||||||
|
has_more?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** True when FS API is disabled (not evobgp-all or no volume). */
|
||||||
|
export function isRuntimeLogsUnavailable(err: unknown): boolean {
|
||||||
|
if (!(err instanceof ApiError) || err.status !== 503) return false;
|
||||||
|
const detail = err.problem?.detail ?? err.message;
|
||||||
|
return detail === 'runtime_logs_unavailable' || detail.includes('runtime_logs_unavailable');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRuntimeLogFiles(): Promise<RuntimeLogFile[]> {
|
||||||
|
const r = await apiJSON<{ items: RuntimeLogFile[] }>('/v1/runtime-logs/files');
|
||||||
|
return r.items ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getRuntimeLogTail(
|
||||||
|
filename: string,
|
||||||
|
opts?: { lines?: number; grep?: string }
|
||||||
|
): Promise<RuntimeLogTail> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set('lines', String(opts?.lines ?? 200));
|
||||||
|
if (opts?.grep?.trim()) q.set('grep', opts.grep.trim());
|
||||||
|
return apiJSON<RuntimeLogTail>(
|
||||||
|
`/v1/runtime-logs/files/${encodeURIComponent(filename)}?${q.toString()}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function cleanupRuntimeLogFile(
|
||||||
|
filename: string,
|
||||||
|
mode: RuntimeLogCleanupMode = 'truncate'
|
||||||
|
): Promise<RuntimeLogCleanupResult> {
|
||||||
|
const q = new URLSearchParams({ mode });
|
||||||
|
return apiMutate<RuntimeLogCleanupResult>(
|
||||||
|
`/v1/runtime-logs/files/${encodeURIComponent(filename)}?${q.toString()}`,
|
||||||
|
'DELETE',
|
||||||
|
undefined,
|
||||||
|
{ idempotent: false }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listRuntimeLogCleanupAudit(opts?: {
|
||||||
|
cursor?: string;
|
||||||
|
limit?: number;
|
||||||
|
}): Promise<RuntimeLogCleanupAuditList> {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
if (opts?.limit != null) q.set('limit', String(opts.limit));
|
||||||
|
if (opts?.cursor) q.set('cursor', opts.cursor);
|
||||||
|
const suffix = q.toString() ? `?${q.toString()}` : '';
|
||||||
|
return apiJSON<RuntimeLogCleanupAuditList>(`/v1/runtime-logs/cleanup-audit${suffix}`);
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
<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 { resolve } from '$app/paths';
|
||||||
import { apiJSON, apiFetch } from '$lib/api/client.js';
|
import { apiJSON, apiFetch } from '$lib/api/client.js';
|
||||||
import type { BirdStatus, JobRow, JobsResponse } from '$lib/api/types.js';
|
import type { BirdStatus, JobRow, JobsResponse } from '$lib/api/types.js';
|
||||||
@@ -50,6 +52,7 @@
|
|||||||
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
||||||
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
import PageHeader from '$lib/ui/app/page-header/page-header.svelte';
|
||||||
import MonitoringPostgresTab from '$lib/components/monitoring/MonitoringPostgresTab.svelte';
|
import MonitoringPostgresTab from '$lib/components/monitoring/MonitoringPostgresTab.svelte';
|
||||||
|
import RuntimeLogsTab from '$lib/components/monitoring/RuntimeLogsTab.svelte';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/ui/core/tabs/index.js';
|
||||||
import { cn } from '$lib/utils.js';
|
import { cn } from '$lib/utils.js';
|
||||||
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
||||||
@@ -82,7 +85,15 @@
|
|||||||
let lastUpdated = $state<Date | null>(null);
|
let lastUpdated = $state<Date | null>(null);
|
||||||
let initialLoading = $state(true);
|
let initialLoading = $state(true);
|
||||||
let refreshing = $state(false);
|
let refreshing = $state(false);
|
||||||
let mainTab = $state('system');
|
type MainTab = 'system' | 'postgres' | 'runtime-logs';
|
||||||
|
|
||||||
|
function parseMainTab(value: string | null): MainTab {
|
||||||
|
if (value === 'postgres' || value === 'runtime-logs') return value;
|
||||||
|
return 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
let mainTab = $state<MainTab>('system');
|
||||||
|
let tabSyncReady = $state(false);
|
||||||
|
|
||||||
const statAccents = [
|
const statAccents = [
|
||||||
{
|
{
|
||||||
@@ -305,7 +316,27 @@
|
|||||||
return error.length > max ? `${error.slice(0, max)}…` : error;
|
return error.length > max ? `${error.slice(0, max)}…` : error;
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(load);
|
function syncMainTabToUrl(tab: MainTab) {
|
||||||
|
if (!tabSyncReady) return;
|
||||||
|
const url = new URL(page.url);
|
||||||
|
if (tab === 'system') 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
mainTab = parseMainTab(page.url.searchParams.get('tab'));
|
||||||
|
tabSyncReady = true;
|
||||||
|
void load();
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
if (!tabSyncReady) return;
|
||||||
|
syncMainTabToUrl(mainTab);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col gap-6">
|
<div class="flex flex-col gap-6">
|
||||||
@@ -329,6 +360,7 @@
|
|||||||
<TabsList>
|
<TabsList>
|
||||||
<TabsTrigger value="system">Система</TabsTrigger>
|
<TabsTrigger value="system">Система</TabsTrigger>
|
||||||
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger>
|
<TabsTrigger value="postgres">PostgreSQL</TabsTrigger>
|
||||||
|
<TabsTrigger value="runtime-logs">Файловые логи</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="system" class="mt-4 flex flex-col gap-6">
|
<TabsContent value="system" class="mt-4 flex flex-col gap-6">
|
||||||
@@ -682,5 +714,9 @@
|
|||||||
<TabsContent value="postgres" class="mt-4">
|
<TabsContent value="postgres" class="mt-4">
|
||||||
<MonitoringPostgresTab />
|
<MonitoringPostgresTab />
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="runtime-logs" class="mt-4">
|
||||||
|
<RuntimeLogsTab />
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user