// Copyright 2025 Stefan Prodan.
// SPDX-License-Identifier: AGPL-3.0
import { signal } from '@preact/signals'
import { useEffect } from 'preact/hooks'
import { useState } from 'preact/hooks'
import { fetchWithMock } from '../../utils/fetch'
import { formatTimestamp } from '../../utils/time'
import { getEventBadgeClass } from '../../utils/status'
import { usePageMeta } from '../../utils/meta'
import { reportData } from '../../app'
import { FilterForm } from './FilterForm'
import { useRestoreFiltersFromUrl, useSyncFiltersToUrl } from '../../utils/routing'
import { StatusChart } from './StatusChart'
import { useInfiniteScroll } from '../../utils/scroll'
// Events data signals
export const eventsData = signal([])
export const eventsLoading = signal(false)
export const eventsError = signal(null)
// Filter signals
export const selectedEventKind = signal('')
export const selectedEventName = signal('')
export const selectedEventNamespace = signal('')
export const selectedEventSeverity = signal('')
// Fetch events from API
export async function fetchEvents() {
eventsLoading.value = true
eventsError.value = null
const params = new URLSearchParams()
if (selectedEventKind.value) params.append('kind', selectedEventKind.value)
if (selectedEventName.value) params.append('name', selectedEventName.value)
if (selectedEventNamespace.value) params.append('namespace', selectedEventNamespace.value)
if (selectedEventSeverity.value) params.append('type', selectedEventSeverity.value)
try {
const data = await fetchWithMock({
endpoint: `/api/v1/events?${params.toString()}`,
mockPath: '../mock/events',
mockExport: 'getMockEvents'
})
eventsData.value = data.events || []
} catch (error) {
console.error('Failed to fetch events:', error)
eventsError.value = error.message
eventsData.value = []
} finally {
eventsLoading.value = false
}
}
/**
* EventCard - Individual card displaying a Kubernetes event
*
* @param {Object} props
* @param {Object} props.event - Event object with type, message, involvedObject, timestamp
*
* Features:
* - Shows resource kind and name from involvedObject
* - Displays event severity badge (Info for Normal, Warning for Warning)
* - Shows event message with expand/collapse for long messages
* - Displays namespace and timestamp
* - Clickable resource name that navigates to resources page with filters
*/
function EventCard({ event }) {
const [isExpanded, setIsExpanded] = useState(false)
// Parse involvedObject to get kind and name
const [kind, name] = event.involvedObject.split('/')
// Build resource URL
const resourceUrl = `/resource/${encodeURIComponent(kind)}/${encodeURIComponent(event.namespace)}/${encodeURIComponent(name)}`
// Map event type to display status
const displayStatus = event.type === 'Normal' ? 'Info' : 'Warning'
// Check if message is long or contains newlines
const isLongMessage = event.message.length > 150 || event.message.includes('\n')
const shouldTruncate = isLongMessage && !isExpanded
// Truncate to first line or 150 chars
const getTruncatedMessage = () => {
const firstLine = event.message.split('\n')[0]
if (firstLine.length > 150) {
return firstLine.substring(0, 150) + '...'
}
return firstLine
}
const displayMessage = shouldTruncate ? getTruncatedMessage() : event.message
return (
<div class="card p-4 hover:shadow-md transition-shadow">
{/* Header row: kind + status badge + timestamp */}
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<span class="text-xs font-semibold text-gray-500 dark:text-gray-400 uppercase">
{kind}
</span>
<span class={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getEventBadgeClass(event.type)}`}>
{displayStatus}
</span>
</div>
<span class="hidden sm:inline text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap ml-4">
{formatTimestamp(event.lastTimestamp)}
</span>
</div>
{/* Resource namespace/name - clickable link */}
<div class="mb-1 sm:mb-2">
<a
href={resourceUrl}
class="text-sm text-left hover:opacity-80 transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-flux-blue rounded inline-block group"
>
<span class="text-gray-500 dark:text-gray-400">{event.namespace}/</span><span class="font-semibold text-gray-900 dark:text-gray-100 group-hover:text-flux-blue dark:group-hover:text-blue-400">{name}</span><svg class="w-3.5 h-3.5 text-gray-400 group-hover:text-flux-blue dark:group-hover:text-blue-400 transition-colors ml-1 inline-block align-middle" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" /></svg>
</a>
</div>
{/* Mobile timestamp - below namespace/name */}
<div class="flex sm:hidden items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400 mb-2">
<svg class="w-3.5 h-3.5 text-gray-400 dark:text-gray-500 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
</svg>
{formatTimestamp(event.lastTimestamp)}
</div>
{/* Message */}
<div class="text-sm text-gray-700 dark:text-gray-300">
<pre class="whitespace-pre-wrap break-words font-sans">
{displayMessage}
</pre>
{isLongMessage && (
<button
onClick={() => setIsExpanded(!isExpanded)}
class="text-flux-blue dark:text-blue-400 text-xs mt-1 hover:underline focus:outline-none"
>
{isExpanded ? 'Show less' : 'Show more'}
</button>
)}
</div>
</div>
)
}
/**
* EventList component - Displays and filters Kubernetes events for Flux resources
*
* Features:
* - Fetches events from the API with optional filters (kind, name, namespace, severity)
* - Auto-refetches when filter signals change
* - Displays events in card format with expandable messages
* - Shows loading, error, and empty states
*/
export function EventList() {
usePageMeta('Events', 'Events dashboard')
// Restore filter signals from URL query params on mount
useRestoreFiltersFromUrl({
kind: selectedEventKind,
name: selectedEventName,
namespace: selectedEventNamespace,
type: selectedEventSeverity
})
// Sync filter signals to URL query params on change (debounced)
useSyncFiltersToUrl({
kind: selectedEventKind,
name: selectedEventName,
namespace: selectedEventNamespace,
type: selectedEventSeverity
})
// Fetch events on mount and when filters change
useEffect(() => {
fetchEvents()
}, [selectedEventKind.value, selectedEventName.value, selectedEventNamespace.value, selectedEventSeverity.value])
// Infinite scroll hook - reset when filters change or data refetches
const { visibleCount, sentinelRef, hasMore, loadMore } = useInfiniteScroll({
totalItems: eventsData.value.length,
pageSize: 100,
deps: [selectedEventKind.value, selectedEventName.value, selectedEventNamespace.value, selectedEventSeverity.value, eventsData.value.length]
})
// Get visible events (slice the array)
const visibleEvents = eventsData.value.slice(0, visibleCount)
const handleClearFilters = () => {
selectedEventKind.value = ''
selectedEventName.value = ''
selectedEventNamespace.value = ''
selectedEventSeverity.value = ''
}
return (
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8 flex-grow w-full">
<div class="space-y-6">
{/* Page Title */}
<div class="flex items-center justify-between">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Flux Events</h2>
{/* Event count */}
{!eventsLoading.value && eventsData.value.length > 0 && (
<span class="text-sm text-gray-600 dark:text-gray-400">
{eventsData.value.length} events
</span>
)}
</div>
{/* Filters */}
<div class="card p-4">
<FilterForm
kindSignal={selectedEventKind}
nameSignal={selectedEventName}
namespaceSignal={selectedEventNamespace}
severitySignal={selectedEventSeverity}
namespaces={reportData.value?.spec?.namespaces || []}
onClear={handleClearFilters}
/>
</div>
{/* Status Chart */}
<StatusChart items={eventsData.value} loading={eventsLoading.value} mode="events" />
{/* Error State */}
{eventsError.value && (
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-md p-4">
<div class="flex">
<svg class="w-5 h-5 text-red-400 dark:text-red-600" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd" />
</svg>
<div class="ml-3">
<p class="text-sm text-red-800 dark:text-red-200">
Failed to load events: {eventsError.value}
</p>
</div>
</div>
</div>
)}
{/* Events List */}
{/* Empty State */}
{!eventsLoading.value && eventsData.value.length === 0 && (
<div class="card py-12">
<div class="text-center">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">
No events found for the selected filters
</p>
</div>
</div>
)}
{/* Events Cards */}
{!eventsLoading.value && eventsData.value.length > 0 && (
<div class="space-y-4">
{visibleEvents.map((event, index) => (
<EventCard key={`${event.involvedObject}-${event.lastTimestamp}-${index}`} event={event} />
))}
{/* Sentinel element for infinite scroll */}
{hasMore && <div ref={sentinelRef} class="h-4" />}
{/* Load more button - fallback for browsers without IntersectionObserver */}
{hasMore && !window.IntersectionObserver && (
<div class="flex justify-center py-4">
<button
onClick={loadMore}
class="px-4 py-2 bg-flux-blue text-white rounded-md hover:bg-blue-600 transition-colors focus:outline-none focus:ring-2 focus:ring-flux-blue focus:ring-offset-2"
>
Load more events ({eventsData.value.length - visibleCount} remaining)
</button>
</div>
)}
</div>
)}
</div>
</main>
)
}