a182a93950
Build / build (push) Successful in 10m29s
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
334 lines
9.7 KiB
Svelte
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; }}
|
|
/>
|