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

1143 lines
36 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, tick, untrack } from 'svelte';
import * as Select from '$lib/components/ui/select';
import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input';
import { DatePicker } from '$lib/components/ui/date-picker';
import { Badge } from '$lib/components/ui/badge';
import * as Dialog from '$lib/components/ui/dialog';
import {
Search,
RefreshCw,
Download,
FileJson,
FileSpreadsheet,
FileText,
Calendar,
User,
Box,
Layers,
HardDrive,
Network,
Image,
Settings,
GitBranch,
Key,
Filter,
X,
Info,
Crown,
Server,
Database,
Shield,
Plus,
Pencil,
Trash2,
Play,
Square,
RotateCcw,
Pause,
CirclePlay,
ArrowDownToLine,
ArrowUpFromLine,
Scissors,
Terminal,
Link,
Unlink,
LogIn,
LogOut,
GitPullRequest,
Activity,
Loader2,
Wifi,
FileX
} from 'lucide-svelte';
import { licenseStore } from '$lib/stores/license';
import { currentEnvironment } from '$lib/stores/environment';
import { getIconComponent } from '$lib/utils/icons';
import {
auditSseConnected,
auditSseError,
connectAuditSSE,
disconnectAuditSSE,
onAuditEvent,
type AuditLogEntry as SSEAuditLogEntry
} from '$lib/stores/audit-events';
import { formatDateTime } from '$lib/stores/settings';
import PageHeader from '$lib/components/PageHeader.svelte';
interface AuditLogEntry {
id: number;
user_id: number | null;
username: string;
action: string;
entity_type: string;
entity_id: string | null;
entity_name: string | null;
environment_id: number | null;
environment_name: string | null;
environment_icon: string | null;
description: string | null;
details: any | null;
ip_address: string | null;
user_agent: string | null;
timestamp: string;
}
interface Environment {
id: number;
name: string;
icon: string;
}
// Constants
const ROW_HEIGHT = 33; // Height of each row in pixels
const BUFFER_ROWS = 10; // Extra rows to render above/below viewport
const FETCH_BATCH_SIZE = 100; // Number of rows to fetch per request
const SCROLL_THRESHOLD = 200; // Pixels from bottom to trigger fetch
// State
let logs = $state<AuditLogEntry[]>([]);
let total = $state(0);
let loading = $state(false);
let loadingMore = $state(false);
let users = $state<string[]>([]);
let environments = $state<Environment[]>([]);
let envId = $state<number | null>(null);
let hasMore = $state(true);
let initialized = $state(false); // Track if initial data fetch has started
let dataFetched = $state(false); // Track if data has been fetched at least once
// Virtual scroll state
let scrollContainer = $state<HTMLDivElement | null>(null);
let scrollTop = $state(0);
let containerHeight = $state(600);
// localStorage key for filters
const STORAGE_KEY = 'dockhand_audit_filters';
// Filters - now arrays for multi-select (initialized empty, loaded from localStorage in onMount)
let filterUsernames = $state<string[]>([]);
let filterEntityTypes = $state<string[]>([]);
let filterActions = $state<string[]>([]);
let filterEnvironmentId = $state<number | null>(null);
let filterFromDate = $state('');
let filterToDate = $state('');
// Load filters from localStorage (called in onMount)
function loadFiltersFromStorage() {
if (typeof window === 'undefined') return;
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored);
filterUsernames = parsed.usernames || [];
filterEntityTypes = parsed.entityTypes || [];
filterActions = parsed.actions || [];
filterEnvironmentId = parsed.environmentId || null;
filterFromDate = parsed.fromDate || '';
filterToDate = parsed.toDate || '';
}
} catch (e) {
console.error('Failed to load audit filters from localStorage:', e);
}
}
// Save filters to localStorage when they change
function saveFiltersToStorage() {
if (typeof window === 'undefined') return;
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify({
usernames: filterUsernames,
entityTypes: filterEntityTypes,
actions: filterActions,
environmentId: filterEnvironmentId,
fromDate: filterFromDate,
toDate: filterToDate
}));
} catch (e) {
console.error('Failed to save audit filters to localStorage:', e);
}
}
// Detail dialog
let showDetailDialog = $state(false);
let selectedLog = $state<AuditLogEntry | null>(null);
// Export dropdown
let showExportMenu = $state(false);
const entityTypes = [
{ value: 'container', label: 'Containers' },
{ value: 'image', label: 'Images' },
{ value: 'volume', label: 'Volumes' },
{ value: 'network', label: 'Networks' },
{ value: 'stack', label: 'Stacks' },
{ value: 'environment', label: 'Environments' },
{ value: 'registry', label: 'Registries' },
{ value: 'user', label: 'Users' },
{ value: 'role', label: 'Roles' },
{ value: 'settings', label: 'Settings' },
{ value: 'git_repository', label: 'Git repositories' },
{ value: 'git_credential', label: 'Git credentials' }
];
const actionTypes = [
{ value: 'create', label: 'Create' },
{ value: 'update', label: 'Update' },
{ value: 'delete', label: 'Delete' },
{ value: 'start', label: 'Start' },
{ value: 'stop', label: 'Stop' },
{ value: 'restart', label: 'Restart' },
{ value: 'pause', label: 'Pause' },
{ value: 'unpause', label: 'Unpause' },
{ value: 'pull', label: 'Pull' },
{ value: 'push', label: 'Push' },
{ value: 'prune', label: 'Prune' },
{ value: 'exec', label: 'Exec' },
{ value: 'connect', label: 'Connect' },
{ value: 'disconnect', label: 'Disconnect' },
{ value: 'login', label: 'Login' },
{ value: 'logout', label: 'Logout' },
{ value: 'sync', label: 'Sync' }
];
// Date filter preset
let selectedDatePreset = $state<string>('');
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;
}
}
// Set both dates atomically to avoid triggering effect twice
filterFromDate = from;
filterToDate = to;
return { from, to };
}
// Subscribe to environment
$effect(() => {
const env = $currentEnvironment;
envId = env?.id ?? null;
});
// Virtual scroll calculations
const totalHeight = $derived(logs.length * ROW_HEIGHT);
const startIndex = $derived(Math.max(0, Math.floor(scrollTop / ROW_HEIGHT) - BUFFER_ROWS));
const endIndex = $derived(Math.min(logs.length, Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT) + BUFFER_ROWS));
const visibleLogs = $derived(logs.slice(startIndex, endIndex));
const offsetY = $derived(startIndex * ROW_HEIGHT);
// Visible range for display (without buffer)
const visibleStart = $derived(Math.max(1, Math.floor(scrollTop / ROW_HEIGHT) + 1));
const visibleEnd = $derived(Math.max(1, Math.min(logs.length, Math.ceil((scrollTop + containerHeight) / ROW_HEIGHT))));
let refreshing = $state(false); // For silent background refreshes
// AbortController for canceling pending fetch requests
let fetchController: AbortController | null = null;
async function fetchLogs(append = false, silent = false) {
if (!$licenseStore.isEnterprise) return;
// For append/loadMore, don't allow concurrent requests
if (append && loadingMore) return;
// Cancel any pending request when starting a new filter request
if (!append && fetchController) {
fetchController.abort();
}
if (append) {
loadingMore = true;
} else if (silent) {
// Silent refresh - don't show spinner or clear logs
refreshing = true;
} else {
// Full refresh - show loading spinner but DON'T clear logs yet
// (they'll be replaced when new data arrives)
loading = true;
hasMore = true;
// Reset scroll position when fetching fresh
if (scrollContainer) {
scrollContainer.scrollTop = 0;
}
scrollTop = 0;
}
// Create new abort controller for this request
fetchController = new AbortController();
try {
const params = new URLSearchParams();
// Multi-select filters - join with comma
if (filterUsernames.length > 0) params.set('usernames', filterUsernames.join(','));
if (filterEntityTypes.length > 0) params.set('entity_types', filterEntityTypes.join(','));
if (filterActions.length > 0) params.set('actions', filterActions.join(','));
if (filterEnvironmentId !== null) params.set('environment_id', String(filterEnvironmentId));
if (filterFromDate) params.set('from_date', filterFromDate);
if (filterToDate) params.set('to_date', filterToDate + 'T23:59:59');
params.set('limit', String(FETCH_BATCH_SIZE));
params.set('offset', String(append ? logs.length : 0));
const response = await fetch(`/api/audit?${params.toString()}`, {
signal: fetchController.signal
});
if (!response.ok) {
throw new Error('Failed to fetch audit logs');
}
const data = await response.json();
if (append) {
logs = [...logs, ...data.logs];
} else {
logs = data.logs;
}
total = data.total;
hasMore = logs.length < total;
dataFetched = true;
// Reset loading state on success
loading = false;
loadingMore = false;
refreshing = false;
fetchController = null;
} catch (error: any) {
// Ignore abort errors (expected when canceling requests)
// Don't reset loading state since a new request is in flight
if (error?.name === 'AbortError') {
// Note: loading state will be managed by the new request
return;
}
console.error('Failed to fetch audit logs:', error);
if (!append && !silent) {
logs = [];
total = 0;
}
// Reset loading state on error (but not abort)
loading = false;
loadingMore = false;
refreshing = false;
fetchController = null;
hasMore = false;
}
}
async function fetchUsers() {
if (!$licenseStore.isEnterprise) return;
try {
const response = await fetch('/api/audit/users');
if (response.ok) {
users = await response.json();
}
} catch (error) {
console.error('Failed to fetch users:', 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() {
filterUsernames = [];
filterEntityTypes = [];
filterEnvironmentId = null;
filterActions = [];
filterFromDate = '';
filterToDate = '';
selectedDatePreset = '';
// Clear localStorage as well
if (typeof window !== 'undefined') {
localStorage.removeItem(STORAGE_KEY);
}
}
// Track if initial load is done
let initialLoadDone = $state(false);
// Auto-fetch when filters change and save to localStorage
$effect(() => {
// Access all filter values to track them
const _u = filterUsernames;
const _e = filterEntityTypes;
const _a = filterActions;
const _fd = filterFromDate;
const _td = filterToDate;
// Use untrack for initialLoadDone to prevent this effect from running
// when initialLoadDone changes (which would cause a double-fetch)
const isReady = untrack(() => initialLoadDone);
const isEnterprise = untrack(() => $licenseStore.isEnterprise);
// Only auto-fetch after initial load
if (isReady && isEnterprise) {
saveFiltersToStorage();
fetchLogs(false);
}
});
function handleScroll(event: Event) {
const target = event.target as HTMLDivElement;
scrollTop = target.scrollTop;
// Check if we need to load more
const scrollBottom = target.scrollHeight - target.scrollTop - target.clientHeight;
if (scrollBottom < SCROLL_THRESHOLD && hasMore && !loadingMore && !loading) {
fetchLogs(true);
}
}
function showDetails(log: AuditLogEntry) {
selectedLog = log;
showDetailDialog = true;
}
async function exportLogs(format: string) {
showExportMenu = false;
const params = new URLSearchParams();
if (filterUsernames.length > 0) params.set('usernames', filterUsernames.join(','));
if (filterEntityTypes.length > 0) params.set('entity_types', filterEntityTypes.join(','));
if (filterActions.length > 0) params.set('actions', filterActions.join(','));
if (filterFromDate) params.set('from_date', filterFromDate);
if (filterToDate) params.set('to_date', filterToDate + 'T23:59:59');
params.set('format', format);
window.location.href = `/api/audit/export?${params.toString()}`;
}
function formatTimestamp(ts: string): string {
return formatDateTime(ts, true);
}
function getEntityIcon(entityType: string) {
switch (entityType) {
case 'container': return Box;
case 'image': return Image;
case 'volume': return HardDrive;
case 'network': return Network;
case 'stack': return Layers;
case 'user': return User;
case 'role': return Shield;
case 'settings': return Settings;
case 'environment': return Server;
case 'registry': return Database;
case 'git_repository': return GitBranch;
case 'git_credential': return Key;
default: return Box;
}
}
function getActionIcon(action: string) {
switch (action) {
case 'create': return Plus;
case 'update': return Pencil;
case 'delete': return Trash2;
case 'start': return Play;
case 'stop': return Square;
case 'restart': return RotateCcw;
case 'pause': return Pause;
case 'unpause': return CirclePlay;
case 'pull': return ArrowDownToLine;
case 'push': return ArrowUpFromLine;
case 'prune': return Scissors;
case 'exec': return Terminal;
case 'connect': return Link;
case 'disconnect': return Unlink;
case 'login': return LogIn;
case 'logout': return LogOut;
case 'sync': return GitPullRequest;
default: return Activity;
}
}
function getActionColor(action: string): string {
switch (action) {
case 'create':
case 'start':
case 'login':
return 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-400';
case 'delete':
case 'stop':
case 'logout':
return 'bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-400';
case 'update':
case 'restart':
return 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400';
case 'pull':
case 'push':
case 'sync':
return 'bg-sky-100 text-sky-700 dark:bg-sky-900/30 dark:text-sky-400';
case 'exec':
case 'connect':
case 'disconnect':
return 'bg-violet-100 text-violet-700 dark:bg-violet-900/30 dark:text-violet-400';
default:
return 'bg-slate-100 text-slate-700 dark:bg-slate-800 dark:text-slate-400';
}
}
// SSE event listener cleanup function
let unsubscribeSSE: (() => void) | null = null;
// Handle new audit events from SSE
function handleNewAuditEvent(event: SSEAuditLogEntry) {
// Check if event matches current filters
if (filterUsernames.length > 0 && !filterUsernames.includes(event.username)) return;
if (filterEntityTypes.length > 0 && !filterEntityTypes.includes(event.entity_type)) return;
if (filterActions.length > 0 && !filterActions.includes(event.action)) return;
// Check date filters
if (filterFromDate) {
const eventDate = new Date(event.timestamp).toISOString().split('T')[0];
if (eventDate < filterFromDate) return;
}
if (filterToDate) {
const eventDate = new Date(event.timestamp).toISOString().split('T')[0];
if (eventDate > filterToDate) return;
}
// Add to beginning of logs (prepend new events)
// Check if already exists (avoid duplicates)
if (!logs.some(log => log.id === event.id)) {
logs = [event as AuditLogEntry, ...logs];
total = total + 1;
// Add user to list if not already there
if (!users.includes(event.username)) {
users = [...users, event.username].sort();
}
}
}
onMount(async () => {
// Load saved filters from localStorage first
loadFiltersFromStorage();
// Fetch environments list (needed for filter dropdown, regardless of license)
await fetchEnvironments();
// Wait for license store to finish loading
const licenseState = await licenseStore.waitUntilLoaded();
// Fetch data if enterprise license is active
if (licenseState.isEnterprise) {
initialized = true; // Mark as initialized before fetching
await fetchLogs();
await fetchUsers();
// Connect to SSE for real-time updates
connectAuditSSE();
unsubscribeSSE = onAuditEvent(handleNewAuditEvent);
} else {
initialized = true; // Also mark as initialized if not enterprise
}
// Mark initial load done AFTER fetching so the auto-fetch effect doesn't interfere
initialLoadDone = true;
// Update container height on resize
const updateHeight = () => {
if (scrollContainer) {
containerHeight = scrollContainer.clientHeight;
}
};
updateHeight();
window.addEventListener('resize', updateHeight);
return () => {
window.removeEventListener('resize', updateHeight);
// Disconnect SSE when component unmounts
disconnectAuditSSE();
if (unsubscribeSSE) {
unsubscribeSSE();
}
};
});
// Refetch when license changes (only after initial mount)
$effect(() => {
const isEnterprise = $licenseStore.isEnterprise;
// Use untrack to prevent loop - we only want to react to license changes
const fetched = untrack(() => dataFetched);
const ready = untrack(() => initialLoadDone);
const isLoading = untrack(() => loading);
if (isEnterprise && !fetched && ready && !isLoading) {
fetchLogs();
fetchUsers();
}
});
// Update container height when scrollContainer changes
$effect(() => {
if (scrollContainer) {
containerHeight = scrollContainer.clientHeight;
}
});
</script>
<svelte:head>
<title>Audit log - Dockhand</title>
</svelte:head>
<div class="flex-1 min-h-0 flex flex-col gap-3 overflow-hidden">
<!-- Header -->
<div class="flex items-center justify-between shrink-0">
<PageHeader icon={Crown} title="Audit log" iconClass="text-amber-500">
{#if $licenseStore.isEnterprise && total > 0}
<span class="text-xs text-muted-foreground tabular-nums">
Showing {visibleStart}-{visibleEnd} of {total}
</span>
{/if}
</PageHeader>
{#if $licenseStore.isEnterprise}
<div class="flex items-center gap-3">
<!-- Live indicator -->
<span
class="flex items-center gap-1.5 text-xs {$auditSseConnected ? 'text-emerald-500' : 'text-muted-foreground'}"
title={$auditSseConnected ? 'Live updates active' : 'Connecting...'}
>
<Wifi class="w-3.5 h-3.5" />
<span>{$auditSseConnected ? 'Live' : 'Connecting'}</span>
</span>
<Button variant="outline" size="sm" onclick={() => { hasMore = true; fetchLogs(false); }} disabled={loading}>
<RefreshCw class="w-4 h-4 mr-2 {loading ? 'animate-spin' : ''}" />
Refresh
</Button>
<div class="relative">
<Button variant="outline" size="sm" onclick={() => showExportMenu = !showExportMenu}>
<Download class="w-4 h-4 mr-2" />
Export
</Button>
{#if showExportMenu}
<div class="absolute right-0 mt-1 w-40 bg-popover border rounded-md shadow-lg z-50">
<button
type="button"
class="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-accent"
onclick={() => exportLogs('json')}
>
<FileJson class="w-4 h-4" />
JSON
</button>
<button
type="button"
class="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-accent"
onclick={() => exportLogs('csv')}
>
<FileSpreadsheet class="w-4 h-4" />
CSV
</button>
<button
type="button"
class="flex items-center gap-2 w-full px-3 py-2 text-sm hover:bg-accent"
onclick={() => exportLogs('md')}
>
<FileText class="w-4 h-4" />
Markdown
</button>
</div>
{/if}
</div>
</div>
{/if}
</div>
{#if $licenseStore.loading}
<!-- Loading license status -->
<div class="flex flex-col items-center justify-center py-16 text-center">
<Loader2 class="w-8 h-8 animate-spin text-muted-foreground mb-4" />
<p class="text-muted-foreground">Loading...</p>
</div>
{:else if !$licenseStore.isEnterprise}
<!-- Enterprise feature notice -->
<div class="flex flex-col items-center justify-center py-16 text-center">
<div class="w-16 h-16 rounded-full bg-amber-500/10 flex items-center justify-center mb-4">
<Crown class="w-8 h-8 text-amber-500" />
</div>
<h2 class="text-xl font-semibold mb-2">Enterprise feature</h2>
<p class="text-muted-foreground max-w-md mb-6">
Audit logging is an enterprise feature that tracks all user actions for compliance and security monitoring.
</p>
<Button variant="outline" href="/settings?tab=license">
<Key class="w-4 h-4 mr-2" />
Activate license
</Button>
</div>
{:else}
<!-- Filters -->
<div class="bg-card border rounded-lg p-4 shrink-0">
<div class="flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2 shrink-0">
<Filter class="w-4 h-4 text-muted-foreground" />
<span class="text-sm font-medium">Filters</span>
</div>
<!-- User filter (multi-select) -->
<Select.Root type="multiple" bind:value={filterUsernames}>
<Select.Trigger class="w-40">
<User class="w-4 h-4 mr-2 text-muted-foreground shrink-0" />
<span class="truncate">
{#if filterUsernames.length === 0}
All users
{:else if filterUsernames.length === 1}
{filterUsernames[0]}
{:else}
{filterUsernames.length} users
{/if}
</span>
</Select.Trigger>
<Select.Content>
{#if filterUsernames.length > 0}
<button
type="button"
class="w-full px-2 py-1 text-xs text-left text-muted-foreground/60 hover:text-muted-foreground"
onclick={() => filterUsernames = []}
>
Clear
</button>
{/if}
{#each users as user}
<Select.Item value={user}>
<User class="w-4 h-4 mr-2 text-muted-foreground" />
{user}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<!-- Entity type filter (multi-select) -->
<Select.Root type="multiple" bind:value={filterEntityTypes}>
<Select.Trigger class="w-40">
<Box class="w-4 h-4 mr-2 text-muted-foreground shrink-0" />
<span class="truncate">
{#if filterEntityTypes.length === 0}
All entities
{:else if filterEntityTypes.length === 1}
{entityTypes.find(e => e.value === filterEntityTypes[0])?.label || filterEntityTypes[0]}
{:else}
{filterEntityTypes.length} entities
{/if}
</span>
</Select.Trigger>
<Select.Content>
{#if filterEntityTypes.length > 0}
<button
type="button"
class="w-full px-2 py-1 text-xs text-left text-muted-foreground/60 hover:text-muted-foreground"
onclick={() => filterEntityTypes = []}
>
Clear
</button>
{/if}
{#each entityTypes as type}
<Select.Item value={type.value}>
<svelte:component this={getEntityIcon(type.value)} class="w-4 h-4 mr-2 text-muted-foreground" />
{type.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<!-- Action filter (multi-select) -->
<Select.Root type="multiple" bind:value={filterActions}>
<Select.Trigger class="w-40">
<Activity class="w-4 h-4 mr-2 text-muted-foreground shrink-0" />
<span class="truncate">
{#if filterActions.length === 0}
All actions
{:else if filterActions.length === 1}
{actionTypes.find(a => a.value === filterActions[0])?.label || filterActions[0]}
{:else}
{filterActions.length} actions
{/if}
</span>
</Select.Trigger>
<Select.Content>
{#if filterActions.length > 0}
<button
type="button"
class="w-full px-2 py-1 text-xs text-left text-muted-foreground/60 hover:text-muted-foreground"
onclick={() => filterActions = []}
>
Clear
</button>
{/if}
{#each actionTypes as action}
<Select.Item value={action.value}>
<svelte:component this={getActionIcon(action.value)} class="w-4 h-4 mr-2 text-muted-foreground" />
{action.label}
</Select.Item>
{/each}
</Select.Content>
</Select.Root>
<!-- 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 class="w-48">
<SelectedEnvIcon class="w-4 h-4 mr-2 text-muted-foreground shrink-0" />
<span class="truncate">
{#if filterEnvironmentId === null}
All environments
{: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 class="w-40">
<Calendar class="w-4 h-4 mr-2 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 (shown when "Custom" is selected) -->
{#if selectedDatePreset === 'custom'}
<DatePicker bind:value={filterFromDate} placeholder="From" />
<DatePicker bind:value={filterToDate} placeholder="To" />
{/if}
<!-- Clear all button -->
{#if filterUsernames.length > 0 || filterEntityTypes.length > 0 || filterActions.length > 0 || filterEnvironmentId !== null || selectedDatePreset}
<Button variant="ghost" size="sm" class="h-8 px-2 text-xs" onclick={clearFilters}>
<X class="w-3 h-3 mr-1" />
Clear all
</Button>
{/if}
</div>
</div>
<!-- Virtual Scroll Table -->
<div class="border rounded-lg overflow-hidden flex-1 flex flex-col min-h-0">
<!-- Fixed Header -->
<div class="bg-muted/50 border-b shrink-0">
<!-- Column headers -->
<div class="grid grid-cols-[185px_100px_120px_50px_120px_1fr_100px_50px] text-sm font-medium text-muted-foreground data-grid">
<div class="py-2 px-2 whitespace-nowrap">Timestamp</div>
<div class="py-2 px-2">Environment</div>
<div class="py-2 px-2">User</div>
<div class="py-2 px-2">Action</div>
<div class="py-2 px-2">Entity</div>
<div class="py-2 px-2">Name</div>
<div class="py-2 px-2">IP address</div>
<div class="py-2 px-2"></div>
</div>
</div>
<!-- Scrollable Body with Virtual Scroll -->
<div
bind:this={scrollContainer}
class="flex-1 overflow-auto"
onscroll={handleScroll}
>
{#if loading || !initialized}
<div class="flex items-center justify-center py-16 text-muted-foreground">
<RefreshCw class="w-5 h-5 animate-spin mr-2" />
Loading...
</div>
{:else if logs.length === 0}
<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 audit log entries found</p>
</div>
{:else}
<!-- Virtual scroll container -->
<div style="height: {totalHeight}px; position: relative;">
<div style="transform: translateY({offsetY}px);">
{#each visibleLogs as log (log.id)}
<div
class="grid grid-cols-[185px_100px_120px_50px_120px_1fr_100px_50px] items-center border-b hover:bg-muted/50 cursor-pointer data-grid"
style="height: {ROW_HEIGHT}px;"
onclick={() => showDetails(log)}
role="button"
tabindex="0"
onkeydown={(e) => e.key === 'Enter' && showDetails(log)}
>
<div class="px-2 font-mono whitespace-nowrap">
{formatTimestamp(log.timestamp)}
</div>
<div class="px-2">
{#if log.environment_name}
{@const LogEnvIcon = getIconComponent(log.environment_icon || 'globe')}
<div class="flex items-center gap-1 truncate">
<LogEnvIcon class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="truncate">{log.environment_name}</span>
</div>
{:else}
<span class="text-muted-foreground">-</span>
{/if}
</div>
<div class="px-2">
<div class="flex items-center gap-1 truncate">
<User class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="truncate">{log.username}</span>
</div>
</div>
<div class="px-2" title={log.action.charAt(0).toUpperCase() + log.action.slice(1)}>
<Badge class={getActionColor(log.action)}>
<svelte:component this={getActionIcon(log.action)} class="w-3.5 h-3.5" />
</Badge>
</div>
<div class="px-2">
<div class="flex items-center gap-1 truncate">
<svelte:component this={getEntityIcon(log.entity_type)} class="w-3 h-3 text-muted-foreground shrink-0" />
<span class="truncate">{log.entity_type}</span>
</div>
</div>
<div class="px-2">
<span class="truncate" title={log.entity_name || log.entity_id || '-'}>
{log.entity_name || log.entity_id || '-'}
</span>
</div>
<div class="px-2 font-mono text-muted-foreground">
{log.ip_address || '-'}
</div>
<div class="px-2 flex items-center justify-center">
<Button variant="ghost" size="sm" onclick={(e) => { e.stopPropagation(); showDetails(log); }}>
<Info class="w-4 h-4" />
</Button>
</div>
</div>
{/each}
</div>
</div>
<!-- Loading more indicator -->
{#if loadingMore}
<div class="flex items-center justify-center py-4 text-muted-foreground border-t">
<Loader2 class="w-4 h-4 animate-spin mr-2" />
Loading more...
</div>
{/if}
<!-- End of results -->
{#if !hasMore && logs.length > 0}
<div class="text-center py-4 text-sm text-muted-foreground border-t">
End of results ({total.toLocaleString()} entries)
</div>
{/if}
{/if}
</div>
</div>
{/if}
</div>
<!-- Detail Dialog -->
<Dialog.Root bind:open={showDetailDialog}>
<Dialog.Content class="max-w-2xl">
<Dialog.Header>
<Dialog.Title>Audit log details</Dialog.Title>
</Dialog.Header>
{#if selectedLog}
<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(selectedLog.timestamp)}</p>
</div>
<div>
<label class="text-sm font-medium text-muted-foreground">User</label>
<p class="flex items-center gap-1">
<User class="w-4 h-4 text-muted-foreground" />
{selectedLog.username}
</p>
</div>
<div>
<label class="text-sm font-medium text-muted-foreground">Action</label>
<p>
<Badge class="{getActionColor(selectedLog.action)} gap-1">
<svelte:component this={getActionIcon(selectedLog.action)} class="w-3 h-3" />
{selectedLog.action}
</Badge>
</p>
</div>
<div>
<label class="text-sm font-medium text-muted-foreground">Entity type</label>
<p class="flex items-center gap-1">
<svelte:component this={getEntityIcon(selectedLog.entity_type)} class="w-4 h-4 text-muted-foreground" />
{selectedLog.entity_type}
</p>
</div>
{#if selectedLog.entity_name}
<div>
<label class="text-sm font-medium text-muted-foreground">Entity name</label>
<p>{selectedLog.entity_name}</p>
</div>
{/if}
{#if selectedLog.entity_id}
<div>
<label class="text-sm font-medium text-muted-foreground">Entity ID</label>
<p class="font-mono text-sm break-all">{selectedLog.entity_id}</p>
</div>
{/if}
{#if selectedLog.environment_id}
<div>
<label class="text-sm font-medium text-muted-foreground">Environment ID</label>
<p>{selectedLog.environment_id}</p>
</div>
{/if}
{#if selectedLog.ip_address}
<div>
<label class="text-sm font-medium text-muted-foreground">IP address</label>
<p class="font-mono text-sm">{selectedLog.ip_address}</p>
</div>
{/if}
</div>
{#if selectedLog.description}
<div>
<label class="text-sm font-medium text-muted-foreground">Description</label>
<p>{selectedLog.description}</p>
</div>
{/if}
{#if selectedLog.user_agent}
<div>
<label class="text-sm font-medium text-muted-foreground">User agent</label>
<p class="text-xs text-muted-foreground break-all">{selectedLog.user_agent}</p>
</div>
{/if}
{#if selectedLog.details}
<div>
<label class="text-sm font-medium text-muted-foreground">Details</label>
<pre class="mt-1 p-3 bg-muted rounded-md text-xs overflow-auto max-h-[200px]">{JSON.stringify(selectedLog.details, null, 2)}</pre>
</div>
{/if}
</div>
{/if}
<Dialog.Footer>
<Button variant="outline" onclick={() => showDetailDialog = false}>Close</Button>
</Dialog.Footer>
</Dialog.Content>
</Dialog.Root>
<!-- Click outside to close export menu -->
{#if showExportMenu}
<button
type="button"
class="fixed inset-0 z-40"
onclick={() => showExportMenu = false}
aria-label="Close menu"
></button>
{/if}