Compare commits

...

2 Commits

Author SHA1 Message Date
wendrul
1799046d00 Fix remaining virtual list issues 2025-06-26 15:23:36 +02:00
claude[bot]
b66d9e3e09 feat: migrate AuditLogsTable from DataTable to VirtualList for performance
- Replace DataTable component with VirtualList for handling thousands of rows
- Migrate to Svelte 5 runes ($props, $bindable, $derived, $state)
- Implement flattenLogs() for virtual scrolling with grouped date headers
- Add sticky indices and dynamic height calculation
- Update parent component to use callback prop pattern instead of events
- Preserve all existing functionality: filtering, selection, pagination
- Follows RunsTable.svelte implementation pattern

Resolves performance issues when displaying large audit log datasets.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: Ruben Fiszel <rubenfiszel@users.noreply.github.com>
2025-06-25 18:44:41 +00:00
3 changed files with 287 additions and 145 deletions

View File

@@ -353,24 +353,32 @@
<span class="text-xs absolute -top-4">After</span>
<input type="text" value={after ?? 'After'} disabled />
<CalendarPicker
clearable
date={after}
placement="bottom-end"
label="After"
on:change={async ({ detail }) => {
after = new Date(detail).toISOString()
}}
on:clear={async () => {
after = undefined
}}
/>
</div>
<div class="flex gap-1 relative w-full">
<span class="text-xs absolute -top-4">Before</span>
<input type="text" value={before ?? 'Before'} disabled />
<CalendarPicker
clearable
bind:date={before}
label="Before"
placement="bottom-end"
on:change={async ({ detail }) => {
before = new Date(detail).toISOString()
}}
on:clear={async () => {
after = undefined
}}
/>
</div>

View File

@@ -1,24 +1,38 @@
<script lang="ts">
import Badge from '$lib/components/common/badge/Badge.svelte'
import Cell from '$lib/components/table/Cell.svelte'
import DataTable from '$lib/components/table/DataTable.svelte'
import Head from '$lib/components/table/Head.svelte'
import Row from '$lib/components/table/Row.svelte'
import type { AuditLog } from '$lib/gen'
import { displayDate } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import { onMount, tick } from 'svelte'
import Button from '../common/button/Button.svelte'
import { ListFilter } from 'lucide-svelte'
import { ListFilter, ChevronLeft, ChevronRight } from 'lucide-svelte'
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'
import { twMerge } from 'tailwind-merge'
export let logs: AuditLog[] = []
export let pageIndex: number | undefined = 1
export let perPage: number | undefined = 100
export let hasMore: boolean = true
export let actionKind: string | undefined = undefined
export let operation: string | undefined = undefined
export let selectedId: number | undefined = undefined
export let usernameFilter: string | undefined = undefined
export let resourceFilter: string | undefined = undefined
interface Props {
logs?: AuditLog[]
pageIndex?: number | undefined
perPage?: number | undefined
hasMore?: boolean
actionKind?: string | undefined
operation?: string | undefined
selectedId?: number | undefined
usernameFilter?: string | undefined
resourceFilter?: string | undefined
onselect?: (id: number) => void
}
let {
logs = [],
pageIndex = $bindable(1),
perPage = $bindable(100),
hasMore = $bindable(true),
actionKind = $bindable(),
operation = $bindable(),
selectedId = undefined,
usernameFilter = $bindable(),
resourceFilter = $bindable(),
onselect
}: Props = $props()
function groupLogsByDay(logs: AuditLog[]): Record<string, AuditLog[]> {
const groupedLogs = {}
@@ -42,8 +56,55 @@
return groupedLogs
}
const dispatch = createEventDispatcher()
$: groupedLogs = groupLogsByDay(logs)
type FlatLogs =
| {
type: 'date'
date: string
}
| {
type: 'log'
log: AuditLog
}
function flattenLogs(groupedLogs: Record<string, AuditLog[]>): Array<FlatLogs> {
const flatLogs: Array<FlatLogs> = []
for (const [date, logsByDay] of Object.entries(groupedLogs)) {
flatLogs.push({ type: 'date', date })
for (const log of logsByDay) {
flatLogs.push({ type: 'log', log })
}
}
return flatLogs
}
let tableHeight: number = $state(0)
let headerHeight: number = $state(0)
let footerHeight: number = $state(48)
function computeHeight() {
tableHeight =
document.querySelector('#audit-logs-table-wrapper')!.parentElement?.clientHeight ?? 0
}
onMount(() => {
tick().then(computeHeight)
})
let groupedLogs = $derived(groupLogsByDay(logs))
let flatLogs = $derived(groupedLogs ? flattenLogs(groupedLogs) : undefined)
let stickyIndices = $derived.by(() => {
const nstickyIndices: number[] = []
let index = 0
for (const entry of flatLogs ?? []) {
if (entry.type === 'date') {
nstickyIndices.push(index)
}
index++
}
return nstickyIndices
})
function kindToBadgeColor(kind: string) {
if (kind == 'Execute') {
@@ -59,118 +120,178 @@
}
</script>
<DataTable
on:next={() => {
pageIndex = (pageIndex ?? 1) + 1
}}
on:previous={() => {
pageIndex = (pageIndex ?? 1) - 1
}}
currentPage={pageIndex}
paginated
rounded={false}
size="sm"
{hasMore}
bind:perPage
<svelte:window onresize={() => computeHeight()} />
<div
class="divide-y min-w-[640px] h-full"
id="audit-logs-table-wrapper"
>
<Head>
<Cell first head>ID</Cell>
<Cell head>Timestamp</Cell>
<Cell head>Username</Cell>
<Cell head>Operation</Cell>
<Cell head last>Resource</Cell>
</Head>
{#if logs?.length > 0}
<tbody class="divide-y">
{#each Object.entries(groupedLogs) as [date, logsByDay]}
<tr class="border-t">
<Cell
first
colspan="6"
scope="colgroup"
class="bg-surface-secondary/30 py-2 border-b font-semibold"
>
{date}
</Cell>
</tr>
{#each logsByDay as { id, timestamp, username, operation: op, action_kind, resource, parameters }}
<Row
hoverable
selected={id === selectedId}
on:click={() => {
dispatch('select', id)
}}
>
<Cell first>
{id}
</Cell>
<Cell>
{displayDate(timestamp)}
</Cell>
<Cell>
<div class="flex flex-row gap-2 items-center">
<div class="whitespace-nowrap overflow-x-auto no-scrollbar max-w-52">
{username}
{#if parameters && 'end_user' in parameters}
<span> ({parameters.end_user})</span>
{/if}
</div>
<Button
color="light"
size="xs2"
iconOnly
startIcon={{ icon: ListFilter }}
on:click={() => {
usernameFilter = username
}}
/>
</div>
</Cell>
<Cell>
<div class="flex flex-row gap-1">
<Badge
on:click={() => {
actionKind = action_kind.toLocaleLowerCase()
}}
color={kindToBadgeColor(action_kind)}>{action_kind}</Badge
>
<Badge
on:click={() => {
operation = op
}}
>
{op}
</Badge>
</div>
</Cell>
<Cell last>
<div class="flex flex-row gap-2 items-center">
<div class="whitespace-nowrap overflow-x-auto no-scrollbar w-48">
{resource}
</div>
<Button
color="light"
size="xs2"
iconOnly
startIcon={{ icon: ListFilter }}
on:click={() => {
resourceFilter = resource
}}
/>
</div>
</Cell>
</Row>
{/each}
{/each}
</tbody>
<div bind:clientHeight={headerHeight}>
<div
class="flex flex-row bg-surface-secondary sticky top-0 w-full p-2 pr-4 text-xs font-semibold"
>
<div class="w-1/12">ID</div>
<div class="w-3/12">Timestamp</div>
<div class="w-3/12">Username</div>
<div class="w-3/12">Operation</div>
<div class="w-2/12">Resource</div>
</div>
</div>
{#if logs?.length == 0}
<div class="text-xs text-secondary p-8"> No logs found for the selected filters. </div>
{:else}
<tr>
<td colspan="4" class="text-center py-8">
<div class="text-xs text-secondary"> No logs found for the selected filters. </div>
</td>
</tr>
<VirtualList
width="100%"
height={tableHeight - headerHeight - footerHeight}
itemCount={flatLogs?.length ?? 0}
itemSize={42}
overscanCount={20}
{stickyIndices}
scrollToAlignment="center"
>
{#snippet header()}{/snippet}
{#snippet children({ index, style })}
<div {style} class="w-full">
{#if flatLogs}
{@const logOrDate = flatLogs[index]}
{#if logOrDate}
{#if logOrDate?.type === 'date'}
<div class="bg-surface-secondary py-2 border-b font-semibold text-xs pl-5">
{logOrDate.date}
</div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'flex flex-row items-center h-full w-full px-2 py-1 hover:bg-surface-hover cursor-pointer',
logOrDate.log.id === selectedId ? 'bg-blue-50 dark:bg-blue-900/50' : ''
)}
role="button"
tabindex="0"
onclick={() => {
onselect?.(logOrDate.log.id)
}}
>
<div class="w-1/12 text-xs truncate">
{logOrDate.log.id}
</div>
<div class="w-3/12 text-xs">
{displayDate(logOrDate.log.timestamp)}
</div>
<div class="w-3/12 text-xs">
<div class="flex flex-row gap-2 items-center">
<div class="whitespace-nowrap overflow-x-auto no-scrollbar max-w-40">
{logOrDate.log.username}
{#if logOrDate.log.parameters && 'end_user' in logOrDate.log.parameters}
<span> ({logOrDate.log.parameters.end_user})</span>
{/if}
</div>
<Button
color="light"
size="xs2"
iconOnly
startIcon={{ icon: ListFilter }}
on:click={() => {
usernameFilter = logOrDate.log.username
}}
/>
</div>
</div>
<div class="w-3/12 text-xs">
<div class="flex flex-row gap-1">
<Badge
on:click={() => {
actionKind = logOrDate.log.action_kind.toLocaleLowerCase()
}}
color={kindToBadgeColor(logOrDate.log.action_kind)}
>
{logOrDate.log.action_kind}
</Badge>
<Badge
on:click={() => {
operation = logOrDate.log.operation
}}
>
{logOrDate.log.operation}
</Badge>
</div>
</div>
<div class="w-2/12 text-xs">
<div class="flex flex-row gap-2 items-center">
<div class="whitespace-nowrap overflow-x-auto no-scrollbar max-w-32">
{logOrDate.log.resource}
</div>
<Button
color="light"
size="xs2"
iconOnly
startIcon={{ icon: ListFilter }}
on:click={() => {
resourceFilter = logOrDate.log.resource
}}
/>
</div>
</div>
</div>
{/if}
{:else}
<div class="flex flex-row items-center h-full w-full px-2">
<div class="text-xs text-secondary">Loading...</div>
</div>
{/if}
{:else}
<div class="flex flex-row items-center h-full w-full px-2">
<div class="text-xs text-secondary">Loading...</div>
</div>
{/if}
</div>
{/snippet}
{#snippet footer()}{/snippet}
</VirtualList>
{/if}
</DataTable>
<!-- Pagination footer - always visible -->
<div class="flex flex-row justify-between items-center p-2 bg-surface-primary border-t">
<div class="flex flex-row gap-2 items-center">
<Button
color="light"
size="xs2"
startIcon={{ icon: ChevronLeft }}
on:click={() => {
pageIndex = (pageIndex ?? 1) - 1
}}
disabled={pageIndex <= 1}
>
Previous
</Button>
<span class="text-xs text-secondary px-2">Page {pageIndex}</span>
<Button
color="light"
size="xs2"
endIcon={{ icon: ChevronRight }}
on:click={() => {
pageIndex = (pageIndex ?? 1) + 1
}}
disabled={!hasMore}
>
Next
</Button>
</div>
<div class="flex flex-row gap-2 items-center">
<span class="text-xs text-secondary">Per page:</span>
<select
bind:value={perPage}
class="text-xs bg-transparent border border-gray-300 dark:border-gray-600 rounded px-2 py-1"
>
<option value={25}>25</option>
<option value={100}>100</option>
<option value={1000}>1000</option>
</select>
</div>
</div>
</div>
<style lang="postcss">
/* Hide scrollbar for Chrome, Safari and Opera */
@@ -183,4 +304,10 @@
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* VirtualList scrollbar styling */
:global(.virtual-list-wrapper:hover::-webkit-scrollbar) {
width: 8px !important;
height: 8px !important;
}
</style>

View File

@@ -6,7 +6,7 @@
import AuditLogsFilters from '$lib/components/auditLogs/AuditLogsFilters.svelte'
import AuditLogsTable from '$lib/components/auditLogs/AuditLogsTable.svelte'
import AuditLogMobileFilters from '$lib/components/auditLogs/AuditLogMobileFilters.svelte'
import { Alert, DrawerContent } from '$lib/components/common'
import { Alert, DrawerContent, Skeleton } from '$lib/components/common'
import Drawer from '$lib/components/common/drawer/Drawer.svelte'
import SplitPanesWrapper from '$lib/components/splitPanes/SplitPanesWrapper.svelte'
@@ -93,23 +93,31 @@
<div class="py-2"></div>
{/if}
</div>
<SplitPanesWrapper class="hidden md:block">
<SplitPanesWrapper>
<Splitpanes>
<Pane size={70} minSize={50}>
<AuditLogsTable
{logs}
{selectedId}
bind:pageIndex
bind:perPage
bind:actionKind
bind:operation
bind:usernameFilter={username}
bind:resourceFilter={resource}
bind:hasMore
on:select={(e) => {
selectedId = e.detail
}}
/>
{#if logs}
<AuditLogsTable
{logs}
{selectedId}
bind:pageIndex
bind:perPage
bind:actionKind
bind:operation
bind:usernameFilter={username}
bind:resourceFilter={resource}
bind:hasMore
onselect={(id) => {
selectedId = id
}}
/>
{:else}
<div class="gap-1 flex flex-col">
{#each new Array(8) as _}
<Skeleton layout={[[3]]} />
{/each}
</div>
{/if}
</Pane>
<Pane size={30} minSize={15}>
{#if logs}
@@ -129,9 +137,8 @@
bind:operation
bind:usernameFilter={username}
bind:resourceFilter={resource}
on:select={(e) => {
selectedId = e.detail
onselect={(id) => {
selectedId = id
auditLogDrawer?.openDrawer()
}}
/>