Files
dockhand/routes/activity/+page.svelte
Jarek Krochmalski 62e3c6439e Initial commit
2025-12-28 21:16:03 +01:00

932 lines
28 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, untrack } from 'svelte';
import { page } from '$app/stores';
import * as Select from '$lib/components/ui/select';
import MultiSelectFilter from '$lib/components/MultiSelectFilter.svelte';
import { Button } from '$lib/components/ui/button';
import { DatePicker } from '$lib/components/ui/date-picker';
import { Input } from '$lib/components/ui/input';
import { Badge } from '$lib/components/ui/badge';
import * as Dialog from '$lib/components/ui/dialog';
import {
RefreshCw,
Calendar,
Box,
X,
Eye,
Server,
Play,
Square,
RotateCcw,
Pause,
CirclePlay,
Trash2,
Plus,
Skull,
Zap,
AlertTriangle,
Pencil,
Activity,
Loader2,
FileX,
Heart,
Search
} from 'lucide-svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import { currentEnvironment, environments as environmentsStore } from '$lib/stores/environment';
import { getIconComponent } from '$lib/utils/icons';
import { canAccess } from '$lib/stores/auth';
import ConfirmPopover from '$lib/components/ConfirmPopover.svelte';
import { toast } from 'svelte-sonner';
import { formatDateTime } from '$lib/stores/settings';
import { NoEnvironment } from '$lib/components/ui/empty-state';
import { DataGrid } from '$lib/components/data-grid';
interface ContainerEvent {
id: number;
environmentId: number | null;
environmentName: string | null;
environmentIcon: string | null;
containerId: string | null;
containerName: string | null;
image: string | null;
action: string;
actorAttributes: Record<string, string> | null;
timestamp: string;
createdAt: string;
}
interface Environment {
id: number;
name: string;
icon: string;
labels?: string[];
}
// Constants
const FETCH_BATCH_SIZE = 100;
// State
let events = $state<ContainerEvent[]>([]);
let total = $state(0);
let loading = $state(false);
let loadingMore = $state(false);
let containers = $state<string[]>([]);
let environments = $state<Environment[]>([]);
let envId = $state<number | null>(null);
let hasMore = $state(true);
let initialized = $state(false);
let dataFetched = $state(false);
let showClearConfirm = $state(false);
let clearingActivity = $state(false);
// Visible range for virtual scroll
let visibleStart = $state(1);
let visibleEnd = $state(0);
// localStorage key for filters
const STORAGE_KEY = 'dockhand_activity_filters';
// Filters
let filterContainerName = $state('');
let filterActions = $state<string[]>([]);
let filterEnvironmentId = $state<number | null>(null);
let filterLabels = $state<string[]>([]);
let filterFromDate = $state('');
let filterToDate = $state('');
// Load filters from localStorage
function loadFiltersFromStorage() {
if (typeof window === 'undefined') return;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
filterContainerName = parsed.containerName || '';
filterActions = parsed.actions || [];
filterEnvironmentId = parsed.environmentId || null;
filterLabels = parsed.labels || [];
filterFromDate = parsed.fromDate || '';
filterToDate = parsed.toDate || '';
}
} catch (e) {
console.error('Failed to load activity filters from localStorage:', e);
}
}
// Save filters to localStorage
function saveFiltersToStorage() {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
containerName: filterContainerName,
actions: filterActions,
environmentId: filterEnvironmentId,
labels: filterLabels,
fromDate: filterFromDate,
toDate: filterToDate
}));
} catch (e) {
console.error('Failed to save activity filters to localStorage:', e);
}
}
// Detail dialog
let showDetailDialog = $state(false);
let selectedEvent = $state<ContainerEvent | null>(null);
// SSE connection
let sseConnected = $state(false);
let eventSource: EventSource | null = null;
const actionOptions = [
{ value: 'create', label: 'Create', icon: Plus, color: 'text-emerald-500' },
{ value: 'start', label: 'Start', icon: Play, color: 'text-emerald-500' },
{ value: 'stop', label: 'Stop', icon: Square, color: 'text-amber-500' },
{ value: 'die', label: 'Die', icon: Skull, color: 'text-red-500' },
{ value: 'kill', label: 'Kill', icon: Zap, color: 'text-red-500' },
{ value: 'restart', label: 'Restart', icon: RotateCcw, color: 'text-sky-500' },
{ value: 'pause', label: 'Pause', icon: Pause, color: 'text-amber-500' },
{ value: 'unpause', label: 'Unpause', icon: CirclePlay, color: 'text-emerald-500' },
{ value: 'destroy', label: 'Destroy', icon: Trash2, color: 'text-red-500' },
{ value: 'rename', label: 'Rename', icon: Pencil, color: 'text-muted-foreground' },
{ value: 'update', label: 'Update', icon: Pencil, color: 'text-sky-500' },
{ value: 'oom', label: 'Out of memory', icon: AlertTriangle, color: 'text-red-500' },
{ value: 'health_status', label: 'Health status', icon: Heart, color: 'text-amber-500' }
];
// Date filter preset
let selectedDatePreset = $state<string>('');
// Check if any filters are active
const hasActiveFilters = $derived(
filterContainerName || filterActions.length > 0 || filterEnvironmentId !== null || selectedDatePreset
);
const datePresets = [
{ value: 'today', label: 'Today' },
{ value: 'yesterday', label: 'Yesterday' },
{ value: 'last7days', label: 'Last 7 days' },
{ value: 'last30days', label: 'Last 30 days' },
{ value: 'thisMonth', label: 'This month' },
{ value: 'lastMonth', label: 'Last month' }
];
function formatDateForInput(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
function applyDatePreset(preset: string): { from: string; to: string } {
const today = new Date();
today.setHours(0, 0, 0, 0);
let from = '';
let to = '';
switch (preset) {
case 'today':
from = formatDateForInput(today);
to = formatDateForInput(today);
break;
case 'yesterday': {
const yesterday = new Date(today);
yesterday.setDate(yesterday.getDate() - 1);
from = formatDateForInput(yesterday);
to = formatDateForInput(yesterday);
break;
}
case 'last7days': {
const weekAgo = new Date(today);
weekAgo.setDate(weekAgo.getDate() - 6);
from = formatDateForInput(weekAgo);
to = formatDateForInput(today);
break;
}
case 'last30days': {
const monthAgo = new Date(today);
monthAgo.setDate(monthAgo.getDate() - 29);
from = formatDateForInput(monthAgo);
to = formatDateForInput(today);
break;
}
case 'thisMonth': {
const firstOfMonth = new Date(today.getFullYear(), today.getMonth(), 1);
from = formatDateForInput(firstOfMonth);
to = formatDateForInput(today);
break;
}
case 'lastMonth': {
const firstOfLastMonth = new Date(today.getFullYear(), today.getMonth() - 1, 1);
const lastOfLastMonth = new Date(today.getFullYear(), today.getMonth(), 0);
from = formatDateForInput(firstOfLastMonth);
to = formatDateForInput(lastOfLastMonth);
break;
}
}
filterFromDate = from;
filterToDate = to;
return { from, to };
}
async function clearActivity() {
clearingActivity = true;
try {
const res = await fetch('/api/activity', {
method: 'DELETE'
});
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || 'Failed to clear activity');
}
toast.success('Activity log cleared');
// Reset and reload
events = [];
total = 0;
hasMore = true;
fetchEvents(false);
} catch (error: any) {
toast.error(error.message);
} finally {
clearingActivity = false;
showClearConfirm = false;
}
}
// Subscribe to environment
$effect(() => {
const env = $currentEnvironment;
envId = env?.id ?? null;
});
let fetchController: AbortController | null = null;
async function fetchEvents(append = false, silent = false) {
if (append && loadingMore) return;
if (!append && fetchController) {
fetchController.abort();
}
if (append) {
loadingMore = true;
} else if (!silent) {
loading = true;
hasMore = true;
}
fetchController = new AbortController();
try {
const params = new URLSearchParams();
if (filterContainerName) params.set('containerName', filterContainerName);
if (filterActions.length > 0) params.set('actions', filterActions.join(','));
if (filterEnvironmentId !== null) params.set('environmentId', String(filterEnvironmentId));
if (filterLabels.length > 0) params.set('labels', filterLabels.join(','));
if (filterFromDate) params.set('fromDate', filterFromDate);
if (filterToDate) params.set('toDate', filterToDate + 'T23:59:59');
params.set('limit', String(FETCH_BATCH_SIZE));
params.set('offset', String(append ? events.length : 0));
const response = await fetch(`/api/activity?${params.toString()}`, {
signal: fetchController.signal
});
if (!response.ok) {
throw new Error('Failed to fetch events');
}
const data = await response.json();
if (append) {
events = [...events, ...data.events];
} else {
events = data.events;
}
total = data.total;
hasMore = events.length < total;
dataFetched = true;
loading = false;
loadingMore = false;
fetchController = null;
} catch (error: any) {
if (error?.name === 'AbortError') {
return;
}
console.error('Failed to fetch events:', error);
if (!append && !silent) {
events = [];
total = 0;
}
loading = false;
loadingMore = false;
fetchController = null;
hasMore = false;
}
}
async function fetchContainers() {
try {
const params = new URLSearchParams();
if (filterEnvironmentId !== null) {
params.set('environmentId', String(filterEnvironmentId));
}
const response = await fetch(`/api/activity/containers?${params.toString()}`);
if (response.ok) {
containers = await response.json();
}
} catch (error) {
console.error('Failed to fetch containers:', error);
}
}
async function fetchEnvironments() {
try {
const response = await fetch('/api/environments');
if (response.ok) {
environments = await response.json();
}
} catch (error) {
console.error('Failed to fetch environments:', error);
}
}
function clearFilters() {
filterContainerName = '';
filterActions = [];
filterEnvironmentId = null;
filterLabels = [];
filterFromDate = '';
filterToDate = '';
selectedDatePreset = '';
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
}
// Track if initial load is done
let initialLoadDone = $state(false);
// Auto-fetch when filters change
$effect(() => {
const _cn = filterContainerName;
const _a = filterActions;
const _fd = filterFromDate;
const _td = filterToDate;
const _ei = filterEnvironmentId;
const _l = filterLabels;
const isReady = untrack(() => initialLoadDone);
if (isReady) {
saveFiltersToStorage();
fetchEvents(false);
}
});
// Called by DataGrid when user scrolls near the bottom
function loadMoreEvents() {
if (hasMore && !loadingMore && !loading) {
fetchEvents(true);
}
}
// Called by DataGrid when visible range changes
function handleVisibleRangeChange(start: number, end: number, _total: number) {
visibleStart = start;
visibleEnd = end;
}
function showDetails(event: ContainerEvent) {
selectedEvent = event;
showDetailDialog = true;
}
function formatTimestamp(ts: string): string {
return formatDateTime(ts, true);
}
function getActionIcon(action: string) {
switch (action) {
case 'create': return Plus;
case 'start': return Play;
case 'stop': return Square;
case 'die': return Skull;
case 'kill': return Zap;
case 'restart': return RotateCcw;
case 'pause': return Pause;
case 'unpause': return CirclePlay;
case 'destroy': return Trash2;
case 'rename': return Pencil;
case 'update': return Pencil;
case 'oom': return AlertTriangle;
case 'health_status': return Heart;
default: return Activity;
}
}
function getActionColor(action: string): string {
switch (action) {
case 'create':
case 'start':
case 'unpause':
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400';
case 'stop':
case 'die':
case 'kill':
case 'destroy':
case 'oom':
return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400';
case 'restart':
case 'pause':
case 'update':
case 'rename':
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
case 'health_status':
return 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400';
default:
return 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-400';
}
}
// SSE connection
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let wasConnected = false;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 10;
const BASE_RECONNECT_DELAY = 3000;
function connectSSE() {
if (eventSource) {
eventSource.close();
}
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
eventSource = new EventSource('/api/activity/events');
eventSource.addEventListener('connected', () => {
const wasDisconnected = wasConnected && !sseConnected;
sseConnected = true;
wasConnected = true;
reconnectAttempts = 0; // Reset backoff on successful connection
// If we were previously connected and reconnected, fetch any missed events
if (wasDisconnected) {
fetchEvents(false, true); // silent fetch to catch up on missed events
}
});
eventSource.addEventListener('activity', (e) => {
try {
const newEvent = JSON.parse(e.data);
// Check if event matches current filters
if (filterContainerName && !newEvent.containerName?.includes(filterContainerName)) return;
if (filterActions.length > 0 && !filterActions.includes(newEvent.action)) return;
if (filterEnvironmentId !== null && newEvent.environmentId !== filterEnvironmentId) return;
// Check date filters
if (filterFromDate) {
const eventDate = new Date(newEvent.timestamp).toISOString().split('T')[0];
if (eventDate < filterFromDate) return;
}
if (filterToDate) {
const eventDate = new Date(newEvent.timestamp).toISOString().split('T')[0];
if (eventDate > filterToDate) return;
}
// Add to beginning of events (prepend new events)
if (!events.some(event => event.id === newEvent.id)) {
events = [newEvent, ...events];
total = total + 1;
// Add container to list if not already there
if (newEvent.containerName && !containers.includes(newEvent.containerName)) {
containers = [...containers, newEvent.containerName].sort();
}
}
} catch {
// Ignore parse errors
}
});
eventSource.addEventListener('heartbeat', () => {
sseConnected = true;
});
eventSource.onerror = () => {
sseConnected = false;
// Exponential backoff reconnection
if (reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
const delay = Math.min(BASE_RECONNECT_DELAY * Math.pow(2, reconnectAttempts), 60000);
reconnectAttempts++;
reconnectTimer = setTimeout(() => {
if (eventSource?.readyState === EventSource.CLOSED) {
connectSSE();
}
}, delay);
}
};
}
function disconnectSSE() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
if (eventSource) {
eventSource.close();
eventSource = null;
sseConnected = false;
}
}
// Handle tab visibility changes (e.g., user switches back from another tab)
function handleVisibilityChange() {
if (document.visibilityState === 'visible') {
// Tab became visible - check and restore connection
// Clear any pending reconnection timer
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
// Reset reconnection counter for fresh attempts
reconnectAttempts = 0;
// Reconnect SSE if it's closed or in error state
if (!eventSource || eventSource.readyState !== EventSource.OPEN) {
connectSSE();
}
}
}
onMount(() => {
// Listen for tab visibility changes to reconnect when user returns
document.addEventListener('visibilitychange', handleVisibilityChange);
// Chrome 77+ Page Lifecycle API - fires when frozen tab is resumed
document.addEventListener('resume', handleVisibilityChange);
loadFiltersFromStorage();
// Check for URL query param (env) - takes priority over localStorage
const urlEnvParam = $page.url.searchParams.get('env');
if (urlEnvParam) {
const envIdFromUrl = parseInt(urlEnvParam);
if (!isNaN(envIdFromUrl)) {
filterEnvironmentId = envIdFromUrl;
}
}
// Initialize in order - set initialized=true AFTER data is fetched
fetchEnvironments().then(() => {
// Validate filterEnvironmentId - reset if it doesn't exist
if (filterEnvironmentId !== null) {
const envExists = environments.some(e => e.id === filterEnvironmentId);
if (!envExists) {
filterEnvironmentId = null;
saveFiltersToStorage();
}
}
return fetchEvents();
}).then(() => {
initialized = true;
return fetchContainers();
}).then(() => {
connectSSE();
initialLoadDone = true;
});
return () => {
disconnectSSE();
};
});
onDestroy(() => {
document.removeEventListener('visibilitychange', handleVisibilityChange);
document.removeEventListener('resume', handleVisibilityChange);
disconnectSSE();
});
</script>
<svelte:head>
<title>Activity - Dockhand</title>
</svelte:head>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<!-- Header with inline filters -->
<div class="shrink-0 flex flex-wrap justify-between items-center gap-3">
<PageHeader icon={Activity} title="Activity" count={visibleEnd > 0 ? `${visibleStart}-${visibleEnd}` : undefined} total={total > 0 ? total : undefined} countClass="min-w-32" />
<div class="flex flex-wrap items-center gap-2">
<!-- Container name search -->
<div class="relative">
<Search class="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<Input
type="text"
placeholder="Container..."
bind:value={filterContainerName}
onkeydown={(e) => e.key === 'Escape' && (filterContainerName = '')}
class="pl-8 h-8 w-36 text-sm"
/>
</div>
<!-- Action filter -->
<MultiSelectFilter
bind:value={filterActions}
options={actionOptions}
placeholder="Action"
pluralLabel="actions"
width="w-36"
defaultIcon={Activity}
/>
<!-- Environment filter -->
{#if environments.length > 0}
{@const selectedEnv = environments.find(e => e.id === filterEnvironmentId)}
{@const SelectedEnvIcon = selectedEnv ? getIconComponent(selectedEnv.icon || 'globe') : Server}
<Select.Root
type="single"
value={filterEnvironmentId !== null ? String(filterEnvironmentId) : undefined}
onValueChange={(v) => filterEnvironmentId = v ? parseInt(v) : null}
>
<Select.Trigger size="sm" class="w-44 text-sm">
<SelectedEnvIcon class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
<span class="truncate">
{#if filterEnvironmentId === null}
Environment
{:else}
{selectedEnv?.name || 'Environment'}
{/if}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="">
<Server class="w-4 h-4 mr-2 text-muted-foreground" />
All environments
</Select.Item>
{#each environments as env}
{@const EnvIcon = getIconComponent(env.icon || 'globe')}
<Select.Item value={String(env.id)}>
<EnvIcon class="w-4 h-4 mr-2 text-muted-foreground" />
{env.name}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
{/if}
<!-- Date range filter -->
<Select.Root
type="single"
value={selectedDatePreset}
onValueChange={(v) => {
selectedDatePreset = v || '';
if (v !== 'custom') {
applyDatePreset(v || '');
}
}}
>
<Select.Trigger size="sm" class="w-32 text-sm">
<Calendar class="w-3.5 h-3.5 mr-1.5 text-muted-foreground shrink-0" />
<span class="truncate">
{#if selectedDatePreset === 'custom'}
Custom
{:else if selectedDatePreset}
{datePresets.find(d => d.value === selectedDatePreset)?.label || 'All time'}
{:else}
All time
{/if}
</span>
</Select.Trigger>
<Select.Content>
<Select.Item value="">All time</Select.Item>
{#each datePresets as preset}
<Select.Item value={preset.value}>{preset.label}</Select.Item>
{/each}
<Select.Item value="custom">Custom range...</Select.Item>
</Select.Content>
</Select.Root>
<!-- Custom date inputs -->
{#if selectedDatePreset === 'custom'}
<DatePicker bind:value={filterFromDate} placeholder="From" class="h-8 w-28" />
<DatePicker bind:value={filterToDate} placeholder="To" class="h-8 w-28" />
{/if}
<!-- Clear filters -->
<Button
variant="outline"
size="sm"
class="h-8 px-2"
onclick={clearFilters}
disabled={!hasActiveFilters}
title="Clear all filters"
>
<X class="w-3.5 h-3.5" />
</Button>
<Button variant="outline" size="sm" onclick={() => { hasMore = true; fetchEvents(false); }} disabled={loading}>
<RefreshCw class="w-3.5 h-3.5 {loading ? 'animate-spin' : ''}" />
</Button>
{#if $canAccess('activity', 'delete')}
<ConfirmPopover
bind:open={showClearConfirm}
action="Clear"
itemType="activity log"
title="Clear all"
onConfirm={clearActivity}
confirmText="Clear"
variant="destructive"
disabled={clearingActivity}
onOpenChange={(open) => showClearConfirm = open}
>
{#snippet children({ open })}
<Button variant="outline" size="sm" disabled={clearingActivity || total === 0}>
<Trash2 class="w-3.5 h-3.5" />
</Button>
{/snippet}
</ConfirmPopover>
{/if}
</div>
</div>
<!-- DataGrid with Virtual Scrolling -->
{#if $environmentsStore.length === 0}
<NoEnvironment />
{:else}
<DataGrid
data={events}
keyField="id"
gridId="activity"
virtualScroll
hasMore={hasMore}
onLoadMore={loadMoreEvents}
onVisibleRangeChange={handleVisibleRangeChange}
loading={loading || !initialized}
onRowClick={(event) => showDetails(event)}
class="border-none"
wrapperClass="border rounded-lg"
>
{#snippet cell(column, event, rowState)}
{#if column.id === 'timestamp'}
<span class="font-mono text-xs whitespace-nowrap">{formatTimestamp(event.timestamp)}</span>
{:else if column.id === 'environment'}
{#if event.environmentName}
{@const EventEnvIcon = getIconComponent(event.environmentIcon || 'globe')}
<div class="flex items-center gap-1 text-xs">
<EventEnvIcon class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="truncate">{event.environmentName}</span>
</div>
{:else}
<span class="text-muted-foreground text-xs">-</span>
{/if}
{:else if column.id === 'action'}
<div class="flex justify-center">
<Badge class="{getActionColor(event.action)} py-0.5 px-1" title={event.action.charAt(0).toUpperCase() + event.action.slice(1)}>
<svelte:component this={getActionIcon(event.action)} class="w-3 h-3" />
</Badge>
</div>
{:else if column.id === 'container'}
<div class="flex items-center gap-1 truncate text-xs">
<Box class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="truncate" title={event.containerName || event.containerId || 'Unknown'}>
{event.containerName || (event.containerId ? event.containerId.slice(0, 12) : 'Unknown')}
</span>
</div>
{:else if column.id === 'image'}
<span class="text-xs text-muted-foreground truncate" title={event.image || '-'}>
{event.image || '-'}
</span>
{:else if column.id === 'exitCode'}
{#if event.actorAttributes?.exitCode !== undefined}
{@const exitCode = parseInt(event.actorAttributes.exitCode)}
<span class="font-mono text-xs text-center block {exitCode === 0 ? 'text-green-600' : 'text-red-500'}">
{exitCode}
</span>
{:else}
<span class="text-muted-foreground text-xs text-center block">-</span>
{/if}
{:else if column.id === 'actions'}
<div class="flex items-center justify-end">
<Button variant="ghost" size="icon" class="h-6 w-6" onclick={(e) => { e.stopPropagation(); showDetails(event); }}>
<Eye class="w-3.5 h-3.5" />
</Button>
</div>
{/if}
{/snippet}
{#snippet emptyState()}
<div class="flex flex-col items-center justify-center py-16 text-muted-foreground">
<FileX class="w-10 h-10 mb-3 opacity-40" />
<p>No container events found</p>
<p class="text-xs mt-1">Events will appear here as containers start, stop, etc.</p>
</div>
{/snippet}
{#snippet loadingState()}
<div class="flex items-center justify-center py-16 text-muted-foreground">
<RefreshCw class="w-5 h-5 animate-spin mr-2" />
Loading...
</div>
{/snippet}
</DataGrid>
<!-- Loading more indicator -->
{#if loadingMore}
<div class="flex items-center justify-center py-2 text-muted-foreground border-t">
<Loader2 class="w-4 h-4 animate-spin mr-2" />
Loading more...
</div>
{/if}
<!-- End of results -->
{#if !hasMore && events.length > 0}
<div class="text-center py-2 text-sm text-muted-foreground border-t">
End of results ({total.toLocaleString()} events)
</div>
{/if}
{/if}
</div>
<!-- Detail Dialog -->
<Dialog.Root bind:open={showDetailDialog}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title>Event details</Dialog.Title>
</Dialog.Header>
{#if selectedEvent}
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="text-sm font-medium text-muted-foreground">Timestamp</label>
<p class="font-mono text-sm">{formatTimestamp(selectedEvent.timestamp)}</p>
</div>
<div>
<label class="text-sm font-medium text-muted-foreground">Action</label>
<p>
<Badge class="{getActionColor(selectedEvent.action)} gap-1">
<svelte:component this={getActionIcon(selectedEvent.action)} class="w-3 h-3" />
{selectedEvent.action}
</Badge>
</p>
</div>
<div>
<label class="text-sm font-medium text-muted-foreground">Container name</label>
<p class="flex items-center gap-1">
<Box class="w-4 h-4 text-muted-foreground" />
{selectedEvent.containerName || '-'}
</p>
</div>
<div>
<label class="text-sm font-medium text-muted-foreground">Container ID</label>
<p class="font-mono text-sm break-all">{selectedEvent.containerId}</p>
</div>
{#if selectedEvent.image}
<div class="col-span-2">
<label class="text-sm font-medium text-muted-foreground">Image</label>
<p class="font-mono text-sm break-all">{selectedEvent.image}</p>
</div>
{/if}
{#if selectedEvent.environmentName}
<div>
<label class="text-sm font-medium text-muted-foreground">Environment</label>
<p>{selectedEvent.environmentName}</p>
</div>
{/if}
</div>
{#if selectedEvent.actorAttributes && Object.keys(selectedEvent.actorAttributes).length > 0}
<div>
<label class="text-sm font-medium text-muted-foreground">Attributes</label>
<div class="mt-1 border rounded-md overflow-hidden max-h-[200px] overflow-y-auto">
<table class="w-full text-xs">
<tbody>
{#each Object.entries(selectedEvent.actorAttributes) as [key, value], i}
<tr class="{i % 2 === 0 ? 'bg-muted/50' : 'bg-background'}">
<td class="px-3 py-1.5 font-mono font-medium text-muted-foreground whitespace-nowrap align-top w-1/3">{key}</td>
<td class="px-3 py-1.5 font-mono break-all">{value}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</div>
{/if}
</div>
{/if}
<Dialog.Footer>
<Button variant="outline" onclick={() => showDetailDialog = false}>Close</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>