feat: card layout and load-more for the audit logs page (#11507)

* feat: card layout and load-more for the audit logs page

* fix: stream audit log load-more with stop control and show inline filters from fhd

* fix: fit audit log filters to measured header width and hold timeline during load-more

* fix: keep audit log load-more available when a trimmed short batch has rows left
This commit is contained in:
Guilhem
2026-10-05 10:34:05 +02:00
committed by GitHub
parent 4096c3da74
commit af1a29a3cb
7 changed files with 417 additions and 331 deletions
@@ -1,14 +1,16 @@
<script lang="ts">
import Button from '$lib/components/common/button/Button.svelte'
import { type AuditLog } from '$lib/gen'
import { ArrowRight } from 'lucide-svelte'
import { displayDate } from '$lib/utils'
import { ExternalLink, X } from 'lucide-svelte'
interface Props {
logs: AuditLog[]
selectedId?: number | undefined
onClose?: () => void
}
let { logs, selectedId = undefined }: Props = $props()
let { logs, selectedId = undefined, onClose }: Props = $props()
// `span` holds the caller's token prefix, except for job-minted worker tokens, which
// stamp the job they run for instead.
@@ -19,11 +21,27 @@
const ViewAppOp: AuditLog['operation'][] = ['apps.create', 'apps.update']
</script>
<div class="p-4 flex flex-col gap-2 border-t items-start">
<div class="flex flex-col items-start">
{#if selectedId}
{@const log = logs.find((e) => e.id === selectedId)}
{#if log}
<div class="flex flex-col gap-6 w-full">
<div class="flex flex-row items-center justify-between gap-2 w-full px-4 py-2 border-b">
<div class="flex flex-col min-w-0">
<span class="text-xs font-semibold text-emphasis truncate">{log.operation}</span>
<span class="text-2xs text-secondary">{displayDate(log.timestamp)}</span>
</div>
{#if onClose}
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: X }}
onClick={onClose}
aria-label="Close log details"
/>
{/if}
</div>
<div class="flex flex-col gap-6 w-full p-4">
<div class="flex flex-col gap-1">
<span class="font-semibold text-xs text-emphasis">ID</span>
<span class="text-xs">{log.id}</span>
@@ -49,9 +67,10 @@
{#if log?.parameters?.uuid}
<Button
href={`run/${log.parameters.uuid}`}
variant="accent"
variant="default"
unifiedSize="md"
target="_blank"
endIcon={{ icon: ExternalLink }}
wrapperClasses="w-fit"
>
View run
@@ -64,6 +83,7 @@
variant="default"
unifiedSize="md"
target="_blank"
endIcon={{ icon: ExternalLink }}
wrapperClasses="w-fit"
>
View script
@@ -76,7 +96,7 @@
variant="default"
unifiedSize="md"
target="_blank"
endIcon={{ icon: ArrowRight }}
endIcon={{ icon: ExternalLink }}
wrapperClasses="w-fit"
>
View flow
@@ -88,7 +108,7 @@
variant="default"
unifiedSize="md"
target="_blank"
endIcon={{ icon: ArrowRight }}
endIcon={{ icon: ExternalLink }}
wrapperClasses="w-fit"
>
View app
@@ -97,6 +117,6 @@
</div>
{/if}
{:else}
<span class="text-xs text-primary font-normal">No log selected</span>
<span class="p-4 text-xs text-primary font-normal">No log selected</span>
{/if}
</div>
@@ -44,7 +44,6 @@
interface Props {
logs?: AuditLog[]
username?: string
pageIndex?: number | undefined
before?: string | undefined
after?: string | undefined
perPage?: number | undefined
@@ -53,13 +52,14 @@
actionKind?: ActionKind | 'all'
scope?: undefined | 'all_workspaces' | 'instance'
loading?: boolean
/** Lay the filters out in a row (page header) rather than stacked (popover). */
inline?: boolean
onRefresh?: () => void
}
let {
logs = undefined,
username = $bindable('all'),
pageIndex = $bindable(1),
before = $bindable(undefined),
after = $bindable(undefined),
perPage = $bindable(100),
@@ -68,6 +68,7 @@
actionKind = $bindable(undefined),
scope = $bindable(undefined),
loading = false,
inline = false,
onRefresh
}: Props = $props()
@@ -100,7 +101,6 @@
}
addQueryParam('username', username)
addQueryParam('page', pageIndex)
addQueryParam('perPage', perPage)
addQueryParam('before', before)
addQueryParam('after', after)
@@ -120,7 +120,6 @@
function handlePopState() {
const urlSearchParams = new URLSearchParams(window.location.search)
username = urlSearchParams.get('username') ?? 'all'
pageIndex = Number(urlSearchParams.get('page')) || 1
before = urlSearchParams.get('before') ?? undefined
after = urlSearchParams.get('after') ?? undefined
perPage = Number(urlSearchParams.get('perPage')) || 100
@@ -263,12 +262,12 @@
// observe all the variables that should be reflected in the url
$effect(() => {
;[username, perPage, before, after, operation, resource, actionKind, scope, pageIndex]
;[username, perPage, before, after, operation, resource, actionKind, scope]
untrack(() => updateQueryParams())
})
</script>
<div class="flex flex-col gap-8 2xl:gap-2 2xl:flex-row mt-4 xl:mt-0 pr-2">
<div class={inline ? 'flex flex-row gap-2' : 'flex flex-col gap-8 mt-4 xl:mt-0'}>
{#if $workspaceStore == 'admins'}
<div class="flex gap-1 relative">
<span class="text-xs absolute font-semibold text-emphasis -top-4">Scope</span>
@@ -406,7 +405,6 @@
username = 'all'
operation = 'all'
actionKind = 'all'
pageIndex = 1
perPage = 100
resource = 'all'
scope = undefined
@@ -1,17 +1,16 @@
<script lang="ts">
import Badge from '$lib/components/common/badge/Badge.svelte'
import type { AuditLog } from '$lib/gen'
import { displayDate } from '$lib/utils'
import { onMount, tick } from 'svelte'
import { displayDate, pluralize } from '$lib/utils'
import Button from '../common/button/Button.svelte'
import { ChevronLeft, ChevronRight, ListFilterPlus, Loader2 } from 'lucide-svelte'
import Select from '../select/Select.svelte'
import { ListFilterPlus, Loader2 } from 'lucide-svelte'
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'
import { twMerge } from 'tailwind-merge'
import BatchLoadProgress from '../BatchLoadProgress.svelte'
interface Props {
logs?: AuditLog[]
pageIndex?: number | undefined
perPage?: number | undefined
hasMore?: boolean
actionKind?: string | undefined
@@ -21,6 +20,8 @@
resourceFilter?: string | undefined
showWorkspace?: boolean
loading?: boolean
loadingExtra?: boolean
onLoadMore?: () => void
batchProgress?: { loaded: number; total: number } | null
batchSize?: number | null
onBatchSizeChange?: (batchSize: number) => void
@@ -30,7 +31,6 @@
let {
logs = [],
pageIndex = $bindable(1),
perPage = $bindable(100),
hasMore = true,
actionKind = $bindable(),
@@ -44,7 +44,9 @@
onBatchSizeChange,
onStopLoading,
onselect,
loading
loading,
loadingExtra = false,
onLoadMore
}: Props = $props()
function groupLogsByDay(logs: AuditLog[]): Record<string, AuditLog[]> {
@@ -92,18 +94,7 @@
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 height: number = $state(0)
let groupedLogs = $derived(groupLogsByDay(logs))
let flatLogs = $derived(groupedLogs ? flattenLogs(groupedLogs) : undefined)
@@ -131,159 +122,165 @@
}
return 'gray'
}
let height = $derived(tableHeight - headerHeight - footerHeight)
</script>
<svelte:window onresize={() => computeHeight()} />
<div class="divide-y min-w-[640px] h-full" id="audit-logs-table-wrapper">
<div bind:clientHeight={headerHeight}>
<div
class="flex flex-row bg-surface-secondary sticky top-0 w-full p-2 pr-4 text-2xs font-normal text-primary"
>
<div class="w-1/12">ID</div>
<div class={showWorkspace ? 'w-2/12' : 'w-3/12'}>Timestamp</div>
<div class={showWorkspace ? 'w-2/12' : 'w-3/12'}>Username</div>
{#if showWorkspace}
<div class="w-2/12">Workspace</div>
{/if}
<div class={showWorkspace ? 'w-2/12' : 'w-3/12'}>Operation</div>
<div class="w-2/12">Resource</div>
</div>
<div class="flex flex-col min-w-[640px] h-full">
<!-- h-8 is mirrored by the detail pane's top offset on the audit logs page, so both cards
start at the same height. -->
<div
class="flex flex-row items-center h-8 shrink-0 w-full px-2 pr-4 text-xs font-semibold text-emphasis"
>
<div class="w-1/12">ID</div>
<div class={showWorkspace ? 'w-2/12' : 'w-3/12'}>Timestamp</div>
<div class={showWorkspace ? 'w-2/12' : 'w-3/12'}>Username</div>
{#if showWorkspace}
<div class="w-2/12">Workspace</div>
{/if}
<div class={showWorkspace ? 'w-2/12' : 'w-3/12'}>Operation</div>
<div class="w-2/12">Resource</div>
</div>
{#if loading}
<div style="height: {height}px;" class="flex justify-center items-center">
<Loader2 class="animate-spin" />
</div>
{:else if !logs?.length}
<div
class="text-xs text-secondary p-8 flex justify-center items-center"
style="height: {height}px;"
>
No logs found for the selected filters.
</div>
{:else}
<VirtualList
width="100%"
{height}
itemCount={flatLogs?.length ?? 0}
itemSize={(index) => {
if (flatLogs?.[index]?.type === 'date') {
return 33
}
return 42
}}
overscanCount={20}
{stickyIndices}
scrollToAlignment="center"
>
{#snippet header()}{/snippet}
{#snippet item({ index, style })}
<div {style} class="w-full">
{#if flatLogs}
{@const logOrDate = flatLogs[index]}
<div
class="flex-1 min-h-0 border rounded-t-md overflow-clip bg-surface-tertiary"
bind:clientHeight={height}
>
{#if loading}
<div style="height: {height}px;" class="flex justify-center items-center">
<Loader2 class="animate-spin" />
</div>
{:else if !logs?.length}
<div
class="text-xs text-secondary p-8 flex justify-center items-center"
style="height: {height}px;"
>
No logs found for the selected filters.
</div>
{:else}
<VirtualList
width="100%"
{height}
itemCount={flatLogs?.length ?? 0}
itemSize={(index) => {
if (flatLogs?.[index]?.type === 'date') {
return 33
}
return 42
}}
overscanCount={20}
{stickyIndices}
scrollToAlignment="center"
>
{#snippet header()}{/snippet}
{#snippet item({ 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-normal text-primary 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',
'text-primary text-xs',
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}
{#if logOrDate}
{#if logOrDate?.type === 'date'}
<div
class="bg-surface-secondary py-2 border-b font-normal text-primary text-xs pl-5"
>
{logOrDate.date}
</div>
<div class={showWorkspace ? 'w-2/12 text-xs' : 'w-3/12 text-xs'}>
{displayDate(logOrDate.log.timestamp)}
</div>
<div class={showWorkspace ? 'w-2/12 text-xs' : 'w-3/12 text-xs'}>
<div class="flex flex-row gap-2 items-center">
<!-- end_user can be an arbitrarily long token label; truncate it rather
{: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',
'text-primary text-xs',
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={showWorkspace ? 'w-2/12 text-xs' : 'w-3/12 text-xs'}>
{displayDate(logOrDate.log.timestamp)}
</div>
<div class={showWorkspace ? 'w-2/12 text-xs' : 'w-3/12 text-xs'}>
<div class="flex flex-row gap-2 items-center">
<!-- end_user can be an arbitrarily long token label; truncate it rather
than let it push the username out of the cell. -->
<div class="flex flex-row min-w-0 max-w-60 overflow-hidden">
<span class="whitespace-nowrap shrink-0" title={logOrDate.log.username}>
{logOrDate.log.username}
</span>
{#if logOrDate.log.parameters && 'end_user' in logOrDate.log.parameters}
<span
class="truncate pl-1"
title={String(logOrDate.log.parameters.end_user)}
>
({logOrDate.log.parameters.end_user})
<div class="flex flex-row min-w-0 max-w-60 overflow-hidden">
<span class="whitespace-nowrap shrink-0" title={logOrDate.log.username}>
{logOrDate.log.username}
</span>
{/if}
{#if logOrDate.log.parameters && 'end_user' in logOrDate.log.parameters}
<span
class="truncate pl-1"
title={String(logOrDate.log.parameters.end_user)}
>
({logOrDate.log.parameters.end_user})
</span>
{/if}
</div>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: ListFilterPlus }}
on:click={() => {
usernameFilter = logOrDate.log.username
}}
/>
</div>
</div>
{#if showWorkspace}
<div class="w-2/12 text-xs">
<div class="whitespace-nowrap overflow-x-auto no-scrollbar max-w-60">
{logOrDate.log.workspace_id}
</div>
</div>
{/if}
<div class={showWorkspace ? 'w-2/12 text-xs' : 'w-3/12 text-xs'}>
<div class="flex flex-row gap-1">
<Badge
clickable
onclick={() => {
actionKind = logOrDate.log.action_kind.toLocaleLowerCase()
}}
color={kindToBadgeColor(logOrDate.log.action_kind)}
>
{logOrDate.log.action_kind}
</Badge>
<Badge
clickable
onclick={() => {
operation = logOrDate.log.operation
}}
>
{logOrDate.log.operation}
</Badge>
</div>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: ListFilterPlus }}
on:click={() => {
usernameFilter = logOrDate.log.username
}}
/>
</div>
</div>
{#if showWorkspace}
<div class="w-2/12 text-xs">
<div class="whitespace-nowrap overflow-x-auto no-scrollbar max-w-60">
{logOrDate.log.workspace_id}
<div class="flex flex-row gap-2 items-center">
<div class="whitespace-nowrap overflow-x-auto no-scrollbar max-w-60">
{logOrDate.log.resource}
</div>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: ListFilterPlus }}
on:click={() => {
resourceFilter = logOrDate.log.resource
}}
/>
</div>
</div>
{/if}
<div class={showWorkspace ? 'w-2/12 text-xs' : 'w-3/12 text-xs'}>
<div class="flex flex-row gap-1">
<Badge
clickable
onclick={() => {
actionKind = logOrDate.log.action_kind.toLocaleLowerCase()
}}
color={kindToBadgeColor(logOrDate.log.action_kind)}
>
{logOrDate.log.action_kind}
</Badge>
<Badge
clickable
onclick={() => {
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-60">
{logOrDate.log.resource}
</div>
<Button
variant="subtle"
unifiedSize="sm"
iconOnly
startIcon={{ icon: ListFilterPlus }}
on:click={() => {
resourceFilter = logOrDate.log.resource
}}
/>
</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}
@@ -291,46 +288,29 @@
<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>
{/snippet}
{#snippet footer()}
{#if hasMore && !batchProgress}
<div class="flex justify-center py-1">
<Button
variant="subtle"
unifiedSize="sm"
loading={loadingExtra}
onClick={() => onLoadMore?.()}
>
Load next {perPage} logs
</Button>
</div>
{/if}
</div>
{/snippet}
{#snippet footer()}{/snippet}
</VirtualList>
{/if}
<!-- Pagination footer - always visible -->
{/snippet}
</VirtualList>
{/if}
</div>
<div
class="flex flex-row justify-between items-center p-2 bg-surface-primary border-t"
style:height={`${footerHeight}px`}
class="flex flex-row shrink-0 justify-between items-center h-12 px-2 py-1 bg-surface-tertiary border-x border-b rounded-b-md text-xs"
>
<div class="flex flex-row gap-2 items-center">
<Button
variant="subtle"
unifiedSize="sm"
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
variant="subtle"
unifiedSize="sm"
endIcon={{ icon: ChevronRight }}
on:click={() => {
pageIndex = (pageIndex ?? 1) + 1
}}
disabled={!hasMore}
>
Next
</Button>
</div>
<span class="text-secondary px-2">{pluralize(logs?.length ?? 0, 'log')}</span>
{#if batchProgress}
<div class="flex-1 min-w-0 px-4">
<BatchLoadProgress
@@ -343,14 +323,16 @@
/>
</div>
{/if}
<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 border rounded-md px-2 py-1">
<option value={25}>25</option>
<option value={100}>100</option>
<option value={1000}>1000</option>
</select>
</div>
<Select
class="w-28"
bind:value={perPage}
items={[
{ value: 25, label: '25' },
{ value: 100, label: '100' },
{ value: 1000, label: '1000' }
]}
transformInputSelectedText={(_, v) => `${v} / page`}
/>
</div>
</div>
@@ -550,7 +550,7 @@
)
</script>
<div class="p-4 bg-surface mb-4 h-full text-xs">
<div class="h-full text-xs">
{#if logs.length === 0}
<div class="text-center py-8 text-secondary"> No audit logs to display </div>
{:else if !groupedData || groupedData.status === 'loading'}
@@ -13,7 +13,6 @@ export interface AuditLogsLoaderArgs {
actionKind: ActionKind | 'all'
before: string | undefined
after: string | undefined
pageIndex: number
perPage: number
}
@@ -23,44 +22,32 @@ const SMALL_BATCH_SIZE = 25
const MAX_PER_PAGE = 1000
/**
* Where the first batch of a page starts. Rows are ordered by descending id, so the batches after
* it follow a `before_id` cursor and only this one needs an offset. `page` can only express
* offsets that are multiples of `batchSize`: land on the closest one at or below the page start,
* and report how many rows of that batch belong to the previous page.
*/
export function computeFirstBatch(
pageIndex: number,
perPage: number,
batchSize: number
): { firstPage: number; skipFirst: number } {
const startOffset = (Math.max(1, pageIndex) - 1) * perPage
const firstPage = Math.floor(startOffset / batchSize) + 1
return { firstPage, skipFirst: startOffset - (firstPage - 1) * batchSize }
}
/**
* Loads one page of audit logs, optionally streaming it in smaller batches so rows show up as
* they arrive on instances where a full page takes a long time to come back.
* Loads the newest audit logs, optionally streaming them in smaller batches so rows show up as
* they arrive on instances where a full load takes a long time to come back, and appends older
* ones on demand. Rows are ordered by descending id, so every batch after the first follows a
* `before_id` cursor.
*/
export function useAuditLogsLoader(args: () => AuditLogsLoaderArgs) {
let logs: AuditLog[] | undefined = $state()
let loading = $state(false)
let loadingExtra = $state(false)
let hasMore = $state(false)
let batchProgress = $state<{ loaded: number; total: number } | null>(null)
let currentBatchSize = $state<number | null>(null)
let pendingLoad: CancelablePromise<void> | undefined
let pendingLoadHasRows = false
let pendingExtra: CancelablePromise<void> | undefined
// The batch size the user last picked, so a load-more after a streamed load streams too.
let streamBatchSize: number | undefined
function fetchBatch(
a: AuditLogsLoaderArgs,
page: number,
limit: number,
beforeId: number | undefined
): CancelablePromise<AuditLog[]> {
return AuditService.listAuditLogs({
workspace: a.scope === 'instance' ? 'global' : a.workspace!,
page,
perPage: limit,
beforeId,
before: a.before,
@@ -96,6 +83,8 @@ export function useAuditLogsLoader(args: () => AuditLogsLoaderArgs) {
pendingLoad?.cancel()
pendingLoad = undefined
pendingLoadHasRows = false
cancelExtra()
streamBatchSize = batchSize
const a = args()
if (a.workspace == undefined && a.scope !== 'instance') {
@@ -106,7 +95,6 @@ export function useAuditLogsLoader(args: () => AuditLogsLoaderArgs) {
const total = Math.min(Math.max(1, Math.floor(a.perPage) || 1), MAX_PER_PAGE)
const size = Math.min(Math.max(1, batchSize ?? total), total)
const isBatched = size < total
const { firstPage, skipFirst } = computeFirstBatch(a.pageIndex, total, size)
loading = true
batchProgress = isBatched ? { loaded: 0, total } : null
@@ -115,8 +103,8 @@ export function useAuditLogsLoader(args: () => AuditLogsLoaderArgs) {
const acc: AuditLog[] = []
let slowBatchToastShown = false
function loadBatch(beforeId: number | undefined, skip: number): CancelablePromise<void> {
let fetch = fetchBatch(a, beforeId === undefined ? firstPage : 1, size, beforeId)
function loadBatch(beforeId: number | undefined): CancelablePromise<void> {
let fetch = fetchBatch(a, size, beforeId)
if (isBatched && size > 1) {
fetch = CancelablePromiseUtils.onTimeout(fetch, 4000, () => {
if (slowBatchToastShown) return
@@ -129,7 +117,7 @@ export function useAuditLogsLoader(args: () => AuditLogsLoaderArgs) {
})
}
return CancelablePromiseUtils.then(fetch, (rows) => {
acc.push(...(skip > 0 ? rows.slice(skip) : rows).slice(0, total - acc.length))
acc.push(...rows.slice(0, total - acc.length))
logs = [...acc]
loading = false
pendingLoadHasRows = true
@@ -137,12 +125,12 @@ export function useAuditLogsLoader(args: () => AuditLogsLoaderArgs) {
batchProgress = { loaded: acc.length, total }
}
if (rows.length < size || acc.length >= total) {
// Only once the page is complete: a half-streamed page says nothing about
// whether there is a next one.
// Only once the load is complete: a half-streamed one says nothing about
// whether older logs exist.
hasMore = acc.length >= total
return CancelablePromiseUtils.pure<void>(undefined)
}
return loadBatch(rows[rows.length - 1].id, 0)
return loadBatch(rows[rows.length - 1].id)
})
}
@@ -159,7 +147,7 @@ export function useAuditLogsLoader(args: () => AuditLogsLoaderArgs) {
}, 15000)
}
let promise = loadBatch(undefined, skipFirst)
let promise = loadBatch(undefined)
if (!isBatched) {
promise = CancelablePromiseUtils.onTimeout(promise, 4000, () => {
const smaller = total > SMALL_BATCH_SIZE ? SMALL_BATCH_SIZE : 1
@@ -213,6 +201,82 @@ export function useAuditLogsLoader(args: () => AuditLogsLoaderArgs) {
abandonLoad()
}
function cancelExtra() {
pendingExtra?.cancel()
pendingExtra = undefined
loadingExtra = false
}
function loadMore(batchSize: number | undefined = streamBatchSize) {
const last = logs?.[logs.length - 1]
if (!last || !hasMore || pendingLoad || pendingExtra) return
streamBatchSize = batchSize
const a = args()
const total = Math.min(Math.max(1, Math.floor(a.perPage) || 1), MAX_PER_PAGE)
const size = Math.min(Math.max(1, batchSize ?? total), total)
let appended = 0
loadingExtra = true
// Rows are appended as each batch lands, so stopping keeps them and the next load-more
// continues from the last one.
function extraBatch(beforeId: number): CancelablePromise<void> {
return CancelablePromiseUtils.then(fetchBatch(a, size, beforeId), (rows) => {
const kept = rows.slice(0, total - appended)
appended += kept.length
logs = [...(logs ?? []), ...kept]
// Checked before the short-batch end: a short batch trimmed to fit `total` still has
// rows left past the cut.
if (appended >= total) return CancelablePromiseUtils.pure<void>(undefined)
if (rows.length < size) {
hasMore = false
return CancelablePromiseUtils.pure<void>(undefined)
}
return extraBatch(rows[rows.length - 1].id)
})
}
let promise = CancelablePromiseUtils.onTimeout(extraBatch(last.id), 4000, () => {
const smaller = total > SMALL_BATCH_SIZE ? SMALL_BATCH_SIZE : 1
const offerBatches = size === total && total > 1
sendUserToast('Loading more audit logs is taking longer than expected...', 'warning', [
{
label: 'Stop loading',
callback: () => {
if (pendingExtra === thisExtra) cancelExtra()
}
},
...(offerBatches
? [
{
label: smaller === 1 ? 'Stream 1 by 1' : `Stream by batches of ${smaller}`,
callback: () => {
if (pendingExtra !== thisExtra) return
cancelExtra()
loadMore(smaller)
}
}
]
: [])
])
})
promise = CancelablePromiseUtils.catchErr(promise, (e) => {
if (e instanceof CancelError) return CancelablePromiseUtils.pure<void>(undefined)
sendUserToast(
'There was an issue loading more audit logs, see browser console for more details',
true
)
console.error(e)
return CancelablePromiseUtils.pure<void>(undefined)
})
const thisExtra = CancelablePromiseUtils.pipe(promise, () => {
if (pendingExtra === thisExtra) {
pendingExtra = undefined
loadingExtra = false
}
})
pendingExtra = thisExtra
}
$effect(() => {
// Building the args reads every filter, which is what registers this effect's dependencies.
args()
@@ -220,6 +284,7 @@ export function useAuditLogsLoader(args: () => AuditLogsLoaderArgs) {
return () => {
pendingLoad?.cancel()
pendingLoad = undefined
cancelExtra()
}
})
@@ -227,12 +292,16 @@ export function useAuditLogsLoader(args: () => AuditLogsLoaderArgs) {
reload: () => load(),
restreamWithBatchSize,
stopBatchLoading,
loadMore,
get logs() {
return logs
},
get loading() {
return loading
},
get loadingExtra() {
return loadingExtra
},
get hasMore() {
return hasMore
},
@@ -1,19 +0,0 @@
import { describe, expect, it } from 'vitest'
import { computeFirstBatch } from './useAuditLogsLoader.svelte'
describe('computeFirstBatch', () => {
it('starts at the page itself when nothing is batched', () => {
expect(computeFirstBatch(3, 100, 100)).toEqual({ firstPage: 3, skipFirst: 0 })
})
it('lands exactly on the page start when the batch size divides the offset', () => {
expect(computeFirstBatch(1, 100, 25)).toEqual({ firstPage: 1, skipFirst: 0 })
expect(computeFirstBatch(3, 100, 25)).toEqual({ firstPage: 9, skipFirst: 0 })
expect(computeFirstBatch(2, 100, 1)).toEqual({ firstPage: 101, skipFirst: 0 })
})
it('drops the rows before the page start when it does not', () => {
// page 2 of 100 starts at offset 100, batches of 30 can only land on offset 90
expect(computeFirstBatch(2, 100, 30)).toEqual({ firstPage: 4, skipFirst: 10 })
})
})
@@ -9,7 +9,7 @@
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'
import AnimatedPane from '$lib/components/splitPanes/AnimatedPane.svelte'
import type { AuditLog } from '$lib/gen'
import { AuditService } from '$lib/gen'
@@ -19,7 +19,6 @@
import { useAuditLogsLoader } from '$lib/components/auditLogs/useAuditLogsLoader.svelte'
let username: string = $state(page.url.searchParams.get('username') ?? 'all')
let pageIndex: number | undefined = $state(Number(page.url.searchParams.get('page')) || 1)
let before: string | undefined = $state(page.url.searchParams.get('before') ?? undefined)
let after: string | undefined = $state(page.url.searchParams.get('after') ?? undefined)
let perPage: number | undefined = $state(Number(page.url.searchParams.get('perPage')) || 100)
@@ -42,25 +41,35 @@
actionKind,
before,
after,
pageIndex: pageIndex ?? 1,
perPage: perPage ?? 100
}))
let logs: AuditLog[] | undefined = $derived(auditLogsLoader.logs)
let batchProgress = $derived(auditLogsLoader.batchProgress)
// Regrouping the timeline can fire extra requests to fill in missing job spans, so it gets the
// result of a batched load once it settles rather than every intermediate batch.
// result of a batched load or load-more once it settles rather than every intermediate batch.
let timelineLogs: AuditLog[] | undefined = $state()
$effect(() => {
const settledLogs = batchProgress ? undefined : auditLogsLoader.logs
const settledLogs =
batchProgress || auditLogsLoader.loadingExtra ? undefined : auditLogsLoader.logs
if (settledLogs) {
timelineLogs = settledLogs
}
})
let selectedId: number | undefined = $state(undefined)
// A selection the loaded rows no longer contain (a filter change or reload) closes the pane.
let detailOpen = $derived(
selectedId !== undefined && !!logs?.some((log) => log.id === selectedId)
)
let auditLogDrawer: Drawer | undefined = $state()
// The inline filters show only when their one-line width fits in the space the title leaves.
// Both are measured, so the sidebar, side panels, zoom and filter values are all accounted for.
let filtersSlotWidth = $state(0)
let filtersRowWidth = $state(0)
let inlineFilters = $derived(filtersRowWidth > 0 && filtersRowWidth <= filtersSlotWidth)
// Function to fetch missing job execution audit logs
async function fetchMissingJobSpan(jobId: string, jobLogs: AuditLog[]): Promise<AuditLog[]> {
if (jobLogs.length === 0) return []
@@ -109,16 +118,27 @@
</div>
{:else}
<div class="flex flex-col w-full h-screen">
<div class="flex items-center space-x-2 flex-row justify-between">
<div class="flex flex-row flex-wrap justify-between py-2 my-4 px-4 gap-1 items-center">
<div class="flex flex-row items-center justify-between gap-4 px-4 py-2 my-4">
<div class="flex flex-row shrink-0 gap-1 items-center">
<h1 class="text-2xl font-semibold text-emphasis">Audit logs</h1>
<Tooltip documentationLink="https://www.windmill.dev/docs/core_concepts/audit_logs">
You can only see your own audit logs unless you are an admin.
</Tooltip>
</div>
<div class="flex flex-row flex-wrap justify-between py-2 my-2 px-4 gap-1 items-center">
<div class="hidden 2xl:block">
<!-- flex-1 min-w-0: the slot's width is what the title leaves, whatever its content. -->
<div
class="relative flex flex-row flex-1 min-w-0 justify-end items-center"
bind:clientWidth={filtersSlotWidth}
>
<!-- Kept laid out (invisible, out of flow) while unused, so its width stays measurable. -->
<div
class={inlineFilters
? 'w-max shrink-0'
: 'w-max shrink-0 invisible absolute right-0 pointer-events-none'}
bind:clientWidth={filtersRowWidth}
>
<AuditLogsFilters
inline
{logs}
bind:username
bind:before
@@ -126,14 +146,13 @@
bind:actionKind
bind:operation
bind:resource
bind:pageIndex
bind:perPage
bind:scope
loading={auditLogsLoader.loading}
onRefresh={() => auditLogsLoader.reload()}
/>
</div>
<div class="2xl:hidden">
<div class:hidden={inlineFilters}>
<AuditLogMobileFilters>
{#snippet filters()}
<AuditLogsFilters
@@ -153,7 +172,15 @@
</div>
</div>
</div>
<div class="h-2/6">
{#if !$enterpriseLicense || $enterpriseLicense.endsWith('_pro')}
<div class="mx-4 mb-2">
<Alert title="Redacted audit logs" type="warning">
You need an enterprise license to see unredacted audit logs.
</Alert>
</div>
{/if}
<div class="h-2/6 shrink-0 p-2 px-4 bg-surface-tertiary mx-4 border rounded-md">
{#if timelineLogs}
<AuditLogsTimeline
logs={timelineLogs}
@@ -162,73 +189,82 @@
onZoom={({ min, max }) => {
before = max.toISOString()
after = min.toISOString()
console.log('zoom!')
}}
onMissingJobSpan={fetchMissingJobSpan}
onLogSelected={(log) => {
console.log('selected log ')
selectedId = log.id
}}
/>
{/if}
</div>
<div class="flex-grow w-full min-h-0">
<div class="px-2">
{#if !$enterpriseLicense || $enterpriseLicense.endsWith('_pro')}
<Alert title="Redacted audit logs" type="warning">
You need an enterprise license to see unredacted audit logs.
</Alert>
<div class="py-2"></div>
{/if}
</div>
<SplitPanesWrapper>
<Splitpanes>
<Pane size={70} minSize={50}>
<!-- Also while a batched load has yet to return its first rows: the table footer
carries the progress row and its Stop button. -->
{#if logs || batchProgress}
<AuditLogsTable
loading={auditLogsLoader.loading}
{logs}
{selectedId}
bind:pageIndex
bind:perPage
bind:actionKind
bind:operation
bind:usernameFilter={username}
bind:resourceFilter={resource}
hasMore={auditLogsLoader.hasMore}
{batchProgress}
batchSize={auditLogsLoader.currentBatchSize}
onBatchSizeChange={(size) => auditLogsLoader.restreamWithBatchSize(size)}
onStopLoading={() => auditLogsLoader.stopBatchLoading()}
showWorkspace={scope === 'instance' || scope === 'all_workspaces'}
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}
<AuditLogDetails {logs} {selectedId} />
{/if}
</Pane>
</Splitpanes>
</SplitPanesWrapper>
<div class="md:hidden">
<div
class="hidden md:block grow min-h-0 [&_.splitpanes\_\_splitter]:!bg-transparent [&_.splitpanes\_\_splitter]:!border-none"
>
<Splitpanes>
<Pane minSize={40}>
<div class="h-full flex flex-col p-4 pr-2">
<div class="grow min-h-0 overflow-y-hidden overflow-x-auto">
<!-- Also while a batched load has yet to return its first rows: the table footer
carries the progress row and its Stop button. -->
{#if logs || batchProgress}
<AuditLogsTable
loading={auditLogsLoader.loading}
{logs}
{selectedId}
bind:perPage
bind:actionKind
bind:operation
bind:usernameFilter={username}
bind:resourceFilter={resource}
hasMore={auditLogsLoader.hasMore}
loadingExtra={auditLogsLoader.loadingExtra}
onLoadMore={() => auditLogsLoader.loadMore()}
{batchProgress}
batchSize={auditLogsLoader.currentBatchSize}
onBatchSizeChange={(size) => auditLogsLoader.restreamWithBatchSize(size)}
onStopLoading={() => auditLogsLoader.stopBatchLoading()}
showWorkspace={scope === 'instance' || scope === 'all_workspaces'}
onselect={(id) => {
selectedId = selectedId === id ? undefined : id
}}
/>
{:else}
<div class="gap-1 flex flex-col p-2">
{#each new Array(8) as _}
<Skeleton layout={[[3]]} />
{/each}
</div>
{/if}
</div>
</div>
</Pane>
<AnimatedPane size={40} minSize={15} opened={detailOpen}>
<div class="h-full flex flex-col p-4 pl-2">
<div class="flex-1 min-h-0 mt-8 overflow-y-auto border rounded-md bg-surface-tertiary">
{#if logs}
<AuditLogDetails
{logs}
{selectedId}
onClose={() => {
selectedId = undefined
}}
/>
{/if}
</div>
</div>
</AnimatedPane>
</Splitpanes>
</div>
<div class="md:hidden grow min-h-0 flex flex-col p-4">
<div class="grow min-h-0 overflow-y-hidden overflow-x-auto">
<AuditLogsTable
{logs}
loading={auditLogsLoader.loading}
hasMore={auditLogsLoader.hasMore}
bind:pageIndex
loadingExtra={auditLogsLoader.loadingExtra}
onLoadMore={() => auditLogsLoader.loadMore()}
bind:perPage
bind:actionKind
bind:operation