mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-05 05:39:04 +00:00
1143 lines
36 KiB
Svelte
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}
|