feat(observability): phases 4-7 - complete frontend UI (big bang)

Add all frontend pages for observability & proxy management:
- Proxy Viewer: /proxies with grouped view, filtering, health indicators
- Proxy Creation: form with live validation, diagnostic hints, edit/delete
- Stale Containers: /containers/stale with dashboard widget, cleanup actions
- Event Log: /events with filters, pagination, real-time SSE streaming
- Navigation: proxies and events links in sidebar
- i18n: full EN/RU translations for all new features
- Settings: stale threshold configuration
This commit is contained in:
2026-03-30 11:29:10 +03:00
parent 7a85441b81
commit 79a40f3d9c
29 changed files with 2237 additions and 12 deletions
+314
View File
@@ -0,0 +1,314 @@
<!--
Event Log page.
Displays a filterable, paginated, real-time event log.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
import { fetchEventLog, fetchEventLogStats } from '$lib/api';
import { connectGlobalEvents, type SSEConnection, type EventLogSSEPayload } from '$lib/sse';
import type { EventLogEntry, EventLogStats } from '$lib/types';
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
import EventLogFilter from '$lib/components/EventLogFilter.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
// ── State ─────────────────────────────────────────────────────
let events = $state<EventLogEntry[]>([]);
let stats = $state<EventLogStats>({ info: 0, warn: 0, error: 0, total: 0 });
let loading = $state(true);
let loadingMore = $state(false);
let hasMore = $state(true);
let newEventIds = $state<Set<number>>(new Set());
let pendingNewEvents = $state<EventLogEntry[]>([]);
let scrolledDown = $state(false);
// Filters
let severities = $state<string[]>(['info', 'warn', 'error']);
let sources = $state<string[]>(['deploy', 'container', 'proxy', 'system']);
let dateRange = $state('all');
let searchText = $state('');
const PAGE_SIZE = 50;
let offset = $state(0);
let sseConnection: SSEConnection | null = null;
let listEl: HTMLDivElement | undefined = $state();
// ── Date range to ISO string ─────────────────────────────────
function getDateRangeSince(range: string): string | undefined {
if (range === 'all') return undefined;
const now = Date.now();
const offsets: Record<string, number> = {
'1h': 60 * 60 * 1000,
'24h': 24 * 60 * 60 * 1000,
'7d': 7 * 24 * 60 * 60 * 1000
};
const ms = offsets[range];
if (!ms) return undefined;
return new Date(now - ms).toISOString();
}
// ── Load data ────────────────────────────────────────────────
async function loadEvents(append = false): Promise<void> {
const currentOffset = append ? offset : 0;
if (append) {
loadingMore = true;
} else {
loading = true;
}
try {
const severityParam = severities.length < 3 ? severities.join(',') : undefined;
const sourceParam = sources.length < 4 ? sources.join(',') : undefined;
const sinceParam = getDateRangeSince(dateRange);
const result = await fetchEventLog({
severity: severityParam,
source: sourceParam,
since: sinceParam,
limit: PAGE_SIZE,
offset: currentOffset
});
if (append) {
events = [...events, ...result];
} else {
events = result;
}
hasMore = result.length === PAGE_SIZE;
offset = (append ? currentOffset : 0) + result.length;
} catch {
// Error silently for now — user sees empty state.
} finally {
loading = false;
loadingMore = false;
}
}
async function loadStats(): Promise<void> {
try {
stats = await fetchEventLogStats();
} catch {
// Keep default stats on error.
}
}
function loadMore(): void {
loadEvents(true);
}
// ── Filter change handlers ───────────────────────────────────
function handleSeveritiesChange(v: string[]): void {
severities = v;
loadEvents();
}
function handleSourcesChange(v: string[]): void {
sources = v;
loadEvents();
}
function handleDateRangeChange(v: string): void {
dateRange = v;
loadEvents();
}
function handleSearchChange(v: string): void {
searchText = v;
}
function handleClear(): void {
severities = ['info', 'warn', 'error'];
sources = ['deploy', 'container', 'proxy', 'system'];
dateRange = 'all';
searchText = '';
loadEvents();
}
// ── Client-side text filter ──────────────────────────────────
const filteredEvents = $derived(
searchText.trim() === ''
? events
: events.filter((e) => e.message.toLowerCase().includes(searchText.toLowerCase()))
);
// ── SSE real-time events ─────────────────────────────────────
function handleSSEEvent(payload: EventLogSSEPayload): void {
const newEntry: EventLogEntry = {
id: payload.id,
source: payload.source,
severity: payload.severity as EventLogEntry['severity'],
message: payload.message,
metadata: payload.metadata,
created_at: payload.created_at
};
// Update stats.
stats = {
...stats,
[newEntry.severity]: (stats[newEntry.severity] ?? 0) + 1,
total: stats.total + 1
};
if (scrolledDown) {
pendingNewEvents = [newEntry, ...pendingNewEvents];
} else {
events = [newEntry, ...events];
newEventIds = new Set([...newEventIds, newEntry.id]);
// Clear "new" highlight after animation.
setTimeout(() => {
newEventIds = new Set([...newEventIds].filter((id) => id !== newEntry.id));
}, 3000);
}
}
function showPendingEvents(): void {
events = [...pendingNewEvents, ...events];
const ids = new Set([...newEventIds, ...pendingNewEvents.map((e) => e.id)]);
newEventIds = ids;
pendingNewEvents = [];
// Scroll to top.
listEl?.scrollTo({ top: 0, behavior: 'smooth' });
// Clear highlights after animation.
setTimeout(() => {
newEventIds = new Set();
}, 3000);
}
// ── Scroll tracking ──────────────────────────────────────────
function handleScroll(): void {
if (!listEl) return;
scrolledDown = listEl.scrollTop > 200;
}
// ── Lifecycle ────────────────────────────────────────────────
onMount(() => {
loadEvents();
loadStats();
sseConnection = connectGlobalEvents({
onEventLog(payload) {
handleSSEEvent(payload);
}
});
});
onDestroy(() => {
sseConnection?.close();
sseConnection = null;
});
</script>
<div class="space-y-6">
<!-- Header -->
<div>
<h1 class="text-2xl font-bold text-[var(--text-primary)]">{$t('events.title')}</h1>
</div>
<!-- Stats bar -->
<div class="flex items-center gap-3 flex-wrap">
<div class="flex items-center gap-1.5 rounded-md bg-blue-50 px-2.5 py-1 dark:bg-blue-900/30">
<div class="h-2 w-2 rounded-full bg-blue-500"></div>
<span class="text-xs font-medium text-blue-700 dark:text-blue-300">{$t('events.severity.info')}</span>
<span class="text-xs font-bold text-blue-800 dark:text-blue-200">{stats.info}</span>
</div>
<div class="flex items-center gap-1.5 rounded-md bg-amber-50 px-2.5 py-1 dark:bg-amber-900/30">
<div class="h-2 w-2 rounded-full bg-amber-500"></div>
<span class="text-xs font-medium text-amber-700 dark:text-amber-300">{$t('events.severity.warn')}</span>
<span class="text-xs font-bold text-amber-800 dark:text-amber-200">{stats.warn}</span>
</div>
<div class="flex items-center gap-1.5 rounded-md bg-red-50 px-2.5 py-1 dark:bg-red-900/30">
<div class="h-2 w-2 rounded-full bg-red-500"></div>
<span class="text-xs font-medium text-red-700 dark:text-red-300">{$t('events.severity.error')}</span>
<span class="text-xs font-bold text-red-800 dark:text-red-200">{stats.error}</span>
</div>
<div class="flex items-center gap-1.5 rounded-md bg-[var(--surface-card-hover)] px-2.5 py-1">
<span class="text-xs text-[var(--text-secondary)]">Total</span>
<span class="text-xs font-bold text-[var(--text-primary)]">{stats.total}</span>
</div>
</div>
<!-- Filter bar -->
<EventLogFilter
{severities}
{sources}
{dateRange}
{searchText}
onseveritieschange={handleSeveritiesChange}
onsourceschange={handleSourcesChange}
ondaterangechange={handleDateRangeChange}
onsearchchange={handleSearchChange}
onclear={handleClear}
/>
<!-- "N new events" banner -->
{#if pendingNewEvents.length > 0}
<button
type="button"
class="w-full rounded-lg bg-[var(--color-brand-600)] px-4 py-2 text-sm font-medium text-white shadow-sm transition-all hover:bg-[var(--color-brand-700)] animate-fade-in"
onclick={showPendingEvents}
>
{pendingNewEvents.length} {$t('events.newEvents')}
</button>
{/if}
<!-- Event list -->
{#if loading}
<div class="flex items-center justify-center py-16">
<svg class="h-6 w-6 animate-spin text-[var(--color-brand-600)]" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
</div>
{:else if filteredEvents.length === 0}
<EmptyState
title={$t('events.noEvents')}
description={$t('events.noEventsDesc')}
icon="deploys"
/>
{:else}
<div
bind:this={listEl}
onscroll={handleScroll}
class="space-y-2"
>
{#each filteredEvents as entry (entry.id)}
<EventLogEntryComponent
{entry}
isNew={newEventIds.has(entry.id)}
/>
{/each}
<!-- Load more -->
{#if hasMore && searchText.trim() === ''}
<div class="flex justify-center pt-4 pb-2">
<button
type="button"
class="inline-flex items-center gap-2 rounded-lg border border-[var(--border-primary)] bg-[var(--surface-card)] px-4 py-2 text-sm font-medium text-[var(--text-secondary)] transition-colors hover:bg-[var(--surface-card-hover)] hover:text-[var(--text-primary)] disabled:opacity-50"
onclick={loadMore}
disabled={loadingMore}
>
{#if loadingMore}
<svg class="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
{/if}
{$t('events.loadMore')}
</button>
</div>
{/if}
</div>
{/if}
</div>
+2
View File
@@ -0,0 +1,2 @@
// Event log page — all data loaded client-side.
export const ssr = false;