mirror of
https://github.com/khoaliber/dockhand.git
synced 2026-03-07 21:29:06 +00:00
Initial commit
This commit is contained in:
931
routes/activity/+page.svelte
Normal file
931
routes/activity/+page.svelte
Normal file
@@ -0,0 +1,931 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user