CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 27s
CI / web (push) Successful in 38s
CI / go (push) Successful in 2m36s
CI / bird2 (push) Successful in 15s
CI / release (push) Failing after 3m7s
Refactored the project structure to support a monorepo setup, moving the web application to `apps/web/` and updating related configurations. Adjusted pre-commit hooks to use `pnpm` for linting and formatting. Updated CI workflows to reflect the new directory structure and dependencies. Removed legacy files and configurations from the previous `web/` directory, streamlining the project for better maintainability and clarity.
452 lines
15 KiB
Svelte
452 lines
15 KiB
Svelte
<script lang="ts">
|
|
import { onMount } from 'svelte';
|
|
import { apiJSON } from '$lib/api/client.js';
|
|
import type { AuthSession } from '$lib/api/types.js';
|
|
import {
|
|
cleanupRuntimeLogFile,
|
|
getRuntimeLogTail,
|
|
isRuntimeLogsUnavailable,
|
|
listRuntimeLogCleanupAudit,
|
|
listRuntimeLogFiles,
|
|
type RuntimeLogCleanupAudit,
|
|
type RuntimeLogCleanupMode,
|
|
type RuntimeLogFile
|
|
} from '$lib/runtime-logs/runtime-logs-api.js';
|
|
import { formatBytes } from '$lib/monitoring/postgres.js';
|
|
import { formatDateTime } from '$lib/modules/display.js';
|
|
import { Button } from '@evobgp/ui/components/button/index.js';
|
|
import { Badge } from '@evobgp/ui/components/badge/index.js';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle
|
|
} from '@evobgp/ui/components/card/index.js';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogHeader,
|
|
DialogTitle
|
|
} from '@evobgp/ui/components/dialog/index.js';
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger
|
|
} from '@evobgp/ui/components/dropdown-menu/index.js';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs/index.js';
|
|
import AppDataTable from '$lib/components/patterns/data-table/app-data-table.svelte';
|
|
import type { DataTableColumn } from '$lib/components/patterns/data-table/types.js';
|
|
import EmptyState from '$lib/components/patterns/empty-state/empty-state.svelte';
|
|
import { confirm } from '$lib/components/patterns/confirm/confirm-state.svelte.js';
|
|
import ScrollPreBlock from '$lib/components/app/scroll-pre-block.svelte';
|
|
import { notify, notifyApiError } from '$lib/ui/app/toast.js';
|
|
import {
|
|
dialogBodyDocument,
|
|
dialogContentDocument,
|
|
dialogHeaderDocument
|
|
} from '$lib/dialog-layout.js';
|
|
import RefreshCw from '@lucide/svelte/icons/refresh-cw';
|
|
import Eye from '@lucide/svelte/icons/eye';
|
|
import Eraser from '@lucide/svelte/icons/eraser';
|
|
import Trash2 from '@lucide/svelte/icons/trash-2';
|
|
import HardDrive from '@lucide/svelte/icons/hard-drive';
|
|
import FileText from '@lucide/svelte/icons/file-text';
|
|
import MoreHorizontal from '@lucide/svelte/icons/more-horizontal';
|
|
import LoaderCircle from '@lucide/svelte/icons/loader-circle';
|
|
|
|
type SubTab = 'files' | 'audit';
|
|
|
|
let subTab = $state<SubTab>('files');
|
|
let session = $state<AuthSession | null>(null);
|
|
let filesUnavailable = $state(false);
|
|
let filesLoading = $state(true);
|
|
let files = $state<RuntimeLogFile[]>([]);
|
|
|
|
let auditLoading = $state(false);
|
|
let auditError = $state<string | null>(null);
|
|
let auditItems = $state<RuntimeLogCleanupAudit[]>([]);
|
|
let auditCursor = $state<string | undefined>(undefined);
|
|
let auditHasMore = $state(false);
|
|
|
|
let previewOpen = $state(false);
|
|
let previewFilename = $state('');
|
|
let previewLoading = $state(false);
|
|
let previewContent = $state('');
|
|
let previewTruncated = $state(false);
|
|
let previewLines = $state(0);
|
|
|
|
let cleaningFilename = $state<string | null>(null);
|
|
|
|
const isOperator = $derived(session?.role === 'operator');
|
|
|
|
const totalBytes = $derived(files.reduce((sum, f) => sum + (f.size_bytes ?? 0), 0));
|
|
|
|
const fileColumns: DataTableColumn<RuntimeLogFile>[] = [
|
|
{ id: 'name', label: 'Файл', sortable: true, sortValue: (f) => f.name },
|
|
{
|
|
id: 'size',
|
|
label: 'Размер',
|
|
sortable: true,
|
|
sortValue: (f) => f.size_bytes
|
|
},
|
|
{
|
|
id: 'modified',
|
|
label: 'Изменён',
|
|
sortable: true,
|
|
sortValue: (f) => f.modified_at
|
|
},
|
|
{ id: 'actions', label: '', class: 'w-36' }
|
|
];
|
|
|
|
const auditColumns: DataTableColumn<RuntimeLogCleanupAudit>[] = [
|
|
{
|
|
id: 'created',
|
|
label: 'Время',
|
|
sortable: true,
|
|
sortValue: (r) => r.created_at
|
|
},
|
|
{ id: 'source', label: 'Источник' },
|
|
{ id: 'actor', label: 'Actor' },
|
|
{ id: 'filename', label: 'Файл', sortable: true, sortValue: (r) => r.filename },
|
|
{ id: 'action', label: 'Действие' },
|
|
{ id: 'sizes', label: 'Размер' }
|
|
];
|
|
|
|
async function loadSession() {
|
|
try {
|
|
session = await apiJSON<AuthSession>('/v1/auth/session');
|
|
} catch {
|
|
session = null;
|
|
}
|
|
}
|
|
|
|
async function loadFiles() {
|
|
filesLoading = true;
|
|
try {
|
|
files = await listRuntimeLogFiles();
|
|
filesUnavailable = false;
|
|
} catch (e) {
|
|
if (isRuntimeLogsUnavailable(e)) {
|
|
filesUnavailable = true;
|
|
files = [];
|
|
return;
|
|
}
|
|
notifyApiError(e, 'Файловые логи');
|
|
} finally {
|
|
filesLoading = false;
|
|
}
|
|
}
|
|
|
|
async function loadAudit(reset = true) {
|
|
auditLoading = true;
|
|
if (reset) auditError = null;
|
|
try {
|
|
const page = await listRuntimeLogCleanupAudit({
|
|
limit: 20,
|
|
cursor: reset ? undefined : auditCursor
|
|
});
|
|
auditItems = reset ? (page.items ?? []) : [...auditItems, ...(page.items ?? [])];
|
|
auditCursor = page.next_cursor;
|
|
auditHasMore = Boolean(page.has_more && page.next_cursor);
|
|
auditError = null;
|
|
} catch (e) {
|
|
auditError = e instanceof Error ? e.message : 'Не удалось загрузить audit';
|
|
if (reset) auditItems = [];
|
|
notifyApiError(e, 'Audit очистки логов');
|
|
} finally {
|
|
auditLoading = false;
|
|
}
|
|
}
|
|
|
|
function auditSourceLabel(actor: string): string {
|
|
if (actor.startsWith('auto:')) return 'Авто';
|
|
if (actor.startsWith('op:')) return 'Оператор';
|
|
return '—';
|
|
}
|
|
|
|
async function refreshAll() {
|
|
await Promise.all([loadFiles(), loadAudit(true)]);
|
|
}
|
|
|
|
async function openPreview(file: RuntimeLogFile) {
|
|
previewFilename = file.name;
|
|
previewOpen = true;
|
|
previewLoading = true;
|
|
previewContent = '';
|
|
try {
|
|
const tail = await getRuntimeLogTail(file.name, { lines: 200 });
|
|
previewContent = tail.content;
|
|
previewTruncated = tail.truncated;
|
|
previewLines = tail.lines_returned;
|
|
} catch (e) {
|
|
notifyApiError(e);
|
|
previewOpen = false;
|
|
} finally {
|
|
previewLoading = false;
|
|
}
|
|
}
|
|
|
|
function requestCleanup(file: RuntimeLogFile, mode: RuntimeLogCleanupMode) {
|
|
const actionLabel = mode === 'delete' ? 'удалить файл' : 'обнулить (truncate)';
|
|
void confirm({
|
|
title: mode === 'delete' ? 'Удалить log-файл?' : 'Очистить log-файл?',
|
|
description: `${file.name} · ${formatBytes(file.size_bytes)}. Действие: ${actionLabel}. Операция синхронная.`,
|
|
confirmLabel: mode === 'delete' ? 'Удалить' : 'Очистить',
|
|
destructive: true,
|
|
onConfirm: async () => {
|
|
cleaningFilename = file.name;
|
|
try {
|
|
await cleanupRuntimeLogFile(file.name, mode);
|
|
notify.success(mode === 'delete' ? 'Файл удалён' : 'Файл обнулён');
|
|
await refreshAll();
|
|
} catch (e) {
|
|
notifyApiError(e);
|
|
} finally {
|
|
cleaningFilename = null;
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function actionBadgeVariant(action: RuntimeLogCleanupMode): 'secondary' | 'destructive' {
|
|
return action === 'delete' ? 'destructive' : 'secondary';
|
|
}
|
|
|
|
onMount(() => {
|
|
void (async () => {
|
|
await loadSession();
|
|
await loadFiles();
|
|
await loadAudit(true);
|
|
})();
|
|
});
|
|
</script>
|
|
|
|
<div class="flex flex-col gap-4">
|
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
|
<div class="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<HardDrive class="size-4" />
|
|
<span>Файловые логи Docker-сервисов (sidecar stack-runtime-logs)</span>
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onclick={() => refreshAll()}
|
|
disabled={filesLoading || auditLoading}
|
|
>
|
|
<RefreshCw class={filesLoading || auditLoading ? 'animate-spin' : ''} />
|
|
Обновить
|
|
</Button>
|
|
</div>
|
|
|
|
{#if filesUnavailable}
|
|
<EmptyState
|
|
icon={HardDrive}
|
|
title="Файловые логи недоступны"
|
|
description="Список и очистка *.log доступны только на evobgp-all с примонтированным каталогом runtime-logs (EVOBGP_RUNTIME_LOGS_DIR и EVOBGP_SERVICE=evobgp-all). См. docs/access.md в репозитории. Вкладка audit очистки ниже работает без volume."
|
|
/>
|
|
{/if}
|
|
|
|
{#if !filesUnavailable}
|
|
<div class="grid gap-3 sm:grid-cols-2">
|
|
<Card>
|
|
<CardHeader class="pb-2">
|
|
<CardDescription>Файлов</CardDescription>
|
|
<CardTitle class="text-2xl tabular-nums">{files.length}</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
<Card>
|
|
<CardHeader class="pb-2">
|
|
<CardDescription>Суммарный размер</CardDescription>
|
|
<CardTitle class="text-2xl tabular-nums">{formatBytes(totalBytes)}</CardTitle>
|
|
</CardHeader>
|
|
</Card>
|
|
</div>
|
|
{/if}
|
|
|
|
<Tabs bind:value={subTab}>
|
|
<TabsList>
|
|
<TabsTrigger value="files">Файлы</TabsTrigger>
|
|
<TabsTrigger value="audit">Audit очистки</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="files" class="mt-4">
|
|
{#if filesUnavailable}
|
|
<EmptyState
|
|
icon={FileText}
|
|
title="FS API отключён"
|
|
description="Примонтируйте runtime-logs к evobgp-all и задайте EVOBGP_RUNTIME_LOGS_DIR."
|
|
/>
|
|
{:else}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="text-base">*.log на хосте</CardTitle>
|
|
<CardDescription>
|
|
Просмотр хвоста и синхронная очистка (truncate по умолчанию). Очистка — роль operator.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent class="min-w-0 p-4 pt-0">
|
|
<AppDataTable
|
|
columns={fileColumns}
|
|
rows={files}
|
|
rowKey={(f) => f.name}
|
|
loading={filesLoading}
|
|
emptyTitle="Нет log-файлов"
|
|
emptyDescription="Sidecar ещё не создал файлы или каталог пуст."
|
|
>
|
|
{#snippet cell({ row, column })}
|
|
{#if column.id === 'name'}
|
|
<span class="font-mono text-xs">{row.name}</span>
|
|
{:else if column.id === 'size'}
|
|
{formatBytes(row.size_bytes)}
|
|
{:else if column.id === 'modified'}
|
|
<span class="text-sm">{formatDateTime(row.modified_at)}</span>
|
|
{:else if column.id === 'actions'}
|
|
<div class="flex items-center justify-end gap-1">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="Просмотр"
|
|
onclick={() => openPreview(row)}
|
|
>
|
|
<Eye class="size-3.5" />
|
|
</Button>
|
|
{#if isOperator}
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label="Очистить (truncate)"
|
|
disabled={cleaningFilename === row.name}
|
|
onclick={() => requestCleanup(row, 'truncate')}
|
|
>
|
|
{#if cleaningFilename === row.name}
|
|
<LoaderCircle class="size-3.5 animate-spin" />
|
|
{:else}
|
|
<Eraser class="size-3.5" />
|
|
{/if}
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger>
|
|
{#snippet child({ props })}
|
|
<Button {...props} variant="ghost" size="icon-sm" aria-label="Ещё">
|
|
<MoreHorizontal class="size-3.5" />
|
|
</Button>
|
|
{/snippet}
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
variant="destructive"
|
|
disabled={cleaningFilename === row.name}
|
|
onclick={() => requestCleanup(row, 'delete')}
|
|
>
|
|
<Trash2 class="size-4" />
|
|
Удалить файл
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
{/snippet}
|
|
</AppDataTable>
|
|
</CardContent>
|
|
</Card>
|
|
{/if}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="audit" class="mt-4">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle class="text-base">История очистки</CardTitle>
|
|
<CardDescription>
|
|
Записи из <code class="text-xs">runtime_log_cleanup_audit</code> (viewer+).
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent class="min-w-0 space-y-3 p-4 pt-0">
|
|
{#if auditError && auditItems.length === 0}
|
|
<EmptyState icon={FileText} title="Не удалось загрузить audit" description={auditError}>
|
|
{#snippet action()}
|
|
<Button variant="outline" size="sm" onclick={() => loadAudit(true)}>
|
|
Повторить
|
|
</Button>
|
|
{/snippet}
|
|
</EmptyState>
|
|
{:else}
|
|
<AppDataTable
|
|
columns={auditColumns}
|
|
rows={auditItems}
|
|
rowKey={(r) => r.id}
|
|
loading={auditLoading && auditItems.length === 0}
|
|
emptyTitle="Записей пока нет"
|
|
emptyDescription="Очистка появится после ручного DELETE или автоочистки. Настройки — Параметры → Файловые логи."
|
|
>
|
|
{#snippet cell({ row, column })}
|
|
{#if column.id === 'created'}
|
|
<span class="text-sm">{formatDateTime(row.created_at)}</span>
|
|
{:else if column.id === 'source'}
|
|
<Badge variant={row.actor_prefix.startsWith('auto:') ? 'secondary' : 'outline'}>
|
|
{auditSourceLabel(row.actor_prefix)}
|
|
</Badge>
|
|
{:else if column.id === 'actor'}
|
|
<span class="font-mono text-xs">{row.actor_prefix}</span>
|
|
{:else if column.id === 'filename'}
|
|
<span class="font-mono text-xs">{row.filename}</span>
|
|
{:else if column.id === 'action'}
|
|
<Badge variant={actionBadgeVariant(row.action)}>{row.action}</Badge>
|
|
{:else if column.id === 'sizes'}
|
|
<span class="text-xs tabular-nums">
|
|
{formatBytes(row.size_before)}
|
|
{#if row.size_after != null}
|
|
→ {formatBytes(row.size_after)}
|
|
{/if}
|
|
</span>
|
|
{/if}
|
|
{/snippet}
|
|
</AppDataTable>
|
|
{/if}
|
|
{#if auditHasMore}
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={auditLoading}
|
|
onclick={() => loadAudit(false)}
|
|
>
|
|
{#if auditLoading}
|
|
<LoaderCircle class="size-4 animate-spin" />
|
|
{/if}
|
|
Загрузить ещё
|
|
</Button>
|
|
{/if}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
<Dialog bind:open={previewOpen}>
|
|
<DialogContent class={dialogContentDocument}>
|
|
<DialogHeader class={dialogHeaderDocument}>
|
|
<DialogTitle class="flex items-center gap-2">
|
|
<FileText class="size-4" />
|
|
{previewFilename}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{#if previewTruncated}
|
|
Показан усечённый хвост ({previewLines} строк).
|
|
{:else}
|
|
Хвост файла ({previewLines} строк).
|
|
{/if}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div class={dialogBodyDocument}>
|
|
{#if previewLoading}
|
|
<p class="text-sm text-muted-foreground">Загрузка…</p>
|
|
{:else}
|
|
<ScrollPreBlock variant="preserve" text={previewContent || '—'} class="max-h-[70vh]" />
|
|
{/if}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|