Files
tiny-forge/web/src/routes/events/+page.svelte
T
alexei.dolgolyov a182a93950
Build / build (push) Successful in 10m29s
feat: nav counter badges, login backdrop, events i18n + misc fixes
Nav & UI polish
- Sidebar nav items show monospace count badges (projects, sites, stacks,
  proxies). Events badge shows error count only, styled red as actionable
- New $lib/stores/navCounts.ts polls all counts in parallel every 60s and
  refreshes on route change so badges track mutations
- Login page gets a dynamic forge backdrop: rotating conic glow, drifting
  embers, dot-grid texture, vignette — all pure CSS, reduced-motion safe
- main element gets scrollbar-gutter: stable so Settings tab switching no
  longer shifts horizontally when content heights differ

Events i18n
- events.source.* dictionary rewritten to match actually-emitted backend
  sources (deploy, static_site, stale_scanner, stale_cleanup, admin);
  dead keys (container, proxy, system) removed
- EventLogFilter.allSources + /events default sources state updated to match
- Localize "{N} total" via events.totalCount in the page hero toolbar

Backend
- Stage API accepts enable_proxy on create/update (defaults to true) so
  proxy registration can be opted out per stage

Concurrency
- api.ts: queued request waiters no longer double-increment the inflight
  counter; releasing a slot hands it off directly

Reactive effects
- project detail / env / volumes pages wrap side-effect calls in untrack()
  to prevent $effect feedback loops when their loaders mutate tracked state
2026-04-22 18:30:34 +03:00

334 lines
9.7 KiB
Svelte

<!--
Event Log page.
Filterable, paginated, real-time event log with timeline-style entries.
-->
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { t } from '$lib/i18n';
import { fetchEventLog, fetchEventLogStats, clearAllEvents, deleteEvent } from '$lib/api';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { toasts } from '$lib/stores/toast';
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';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import { IconLoader } from '$lib/components/icons';
// ── 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', 'static_site', 'stale_scanner', 'stale_cleanup', 'admin']);
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();
let showClearConfirm = $state(false);
// ── 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 — 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]);
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 = [];
listEl?.scrollTo({ top: 0, behavior: 'smooth' });
setTimeout(() => {
newEventIds = new Set();
}, 3000);
}
// ── Scroll tracking ──────────────────────────────────────────
function handleScroll(): void {
if (!listEl) return;
scrolledDown = listEl.scrollTop > 200;
}
// ── Lifecycle ────────────────────────────────────────────────
onMount(() => {
loadEvents();
loadStats();
// Open SSE connection only while this page is mounted.
sseConnection = connectGlobalEvents({
onEventLog(payload) {
handleSSEEvent(payload);
}
});
});
onDestroy(() => {
sseConnection?.close();
sseConnection = null;
});
</script>
<div class="space-y-4">
{#snippet heroToolbar()}
{#if stats.total > 0}
<span class="forge-pill"><span class="pulse"></span>{$t('events.totalCount', { count: String(stats.total) })}</span>
<button
type="button"
onclick={() => { showClearConfirm = true; }}
class="forge-btn-ghost forge-btn-danger"
>
{$t('events.clearAll')}
</button>
{/if}
{/snippet}
<ForgeHero
eyebrowSuffix="EVENTS"
title={$t('events.title')}
size="lg"
toolbar={heroToolbar}
/>
<!-- Filter bar (includes severity stats as pill counts) -->
<EventLogFilter
{severities}
{sources}
{dateRange}
{searchText}
{stats}
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">
<IconLoader size={20} class="animate-spin text-[var(--color-brand-500)]" />
</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="rounded-xl border border-[var(--border-primary)] bg-[var(--surface-card)] divide-y divide-[var(--border-secondary)]"
>
{#each filteredEvents as entry (entry.id)}
<EventLogEntryComponent
{entry}
isNew={newEventIds.has(entry.id)}
ondelete={async (id) => {
try {
await deleteEvent(id);
events = events.filter(e => e.id !== id);
stats = { ...stats, total: Math.max(0, stats.total - 1) };
} catch (err) {
toasts.error(err instanceof Error ? err.message : 'Failed to delete event');
}
}}
/>
{/each}
</div>
<!-- Load more -->
{#if hasMore && searchText.trim() === ''}
<div class="flex justify-center pt-2 pb-1">
<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}
<IconLoader size={16} class="animate-spin" />
{/if}
{$t('events.loadMore')}
</button>
</div>
{/if}
{/if}
</div>
<ConfirmDialog
open={showClearConfirm}
title={$t('events.clearAllTitle')}
message={$t('events.clearAllMessage')}
confirmLabel={$t('events.clearAll')}
confirmVariant="danger"
onconfirm={async () => {
showClearConfirm = false;
try {
const result = await clearAllEvents();
toasts.success($t('events.cleared', { count: String(result.count) }));
events = [];
stats = { info: 0, warn: 0, error: 0, total: 0 };
offset = 0;
} catch (err) {
toasts.error(err instanceof Error ? err.message : $t('events.clearFailed'));
}
}}
oncancel={() => { showClearConfirm = false; }}
/>