feat: stream audit logs in batches when a page is slow to load (#10695)

* feat: stream audit logs in batches when a page is slow to load

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: bound streamed page size and clear stale rows on stop

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: keep the runs batch cap and drop rows of a replaced query on failure

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: ignore stop once a load has settled and reset paging when one fails

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* chore: update ee-repo-ref to aab7da6e1f8b1fadacc2208913a5d6596f06f922

This commit updates the EE repository reference after PR #727 was merged in windmill-ee-private.

Previous ee-repo-ref: 59ba8d7ce9ce1de0814b159b3813c2ac2a49239a

New ee-repo-ref: aab7da6e1f8b1fadacc2208913a5d6596f06f922

Automated by sync-ee-ref workflow.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
This commit is contained in:
Ruben Fiszel
2026-08-14 13:31:26 +02:00
committed by GitHub
co-authored by Claude Opus 5 windmill-internal-app[bot]
parent 44b7d97e36
commit 9334727d99
10 changed files with 438 additions and 114 deletions
+1 -1
View File
@@ -1 +1 @@
a65162b22b127b54c0686095ee1b16b04e3111f7
aab7da6e1f8b1fadacc2208913a5d6596f06f922
+9
View File
@@ -268,6 +268,15 @@ paths:
type: string
- $ref: "#/components/parameters/ResourceName"
- $ref: "#/components/parameters/ActionKind"
- name: before_id
in: query
description: >
only return logs with an id strictly lower than this one. Logs are ordered by
descending id, so this is a keyset cursor to stream a page in several batches
without paying a growing offset.
schema:
type: integer
format: int64
- name: all_workspaces
in: query
description: get audit logs for all workspaces
+3
View File
@@ -37,5 +37,8 @@ pub struct ListAuditLogQuery {
pub resource: Option<String>,
pub before: Option<chrono::DateTime<chrono::Utc>>,
pub after: Option<chrono::DateTime<chrono::Utc>>,
// Keyset cursor on the `id DESC` ordering. Lets a client stream a page in small batches
// without paying a growing OFFSET on every batch.
pub before_id: Option<i64>,
pub all_workspaces: Option<bool>,
}
@@ -0,0 +1,62 @@
<script lang="ts">
import Button from './common/button/Button.svelte'
import TextInput from './text_input/TextInput.svelte'
interface Props {
loaded: number
total: number
itemsLabel: string
batchSize?: number | null
/** Largest batch the caller can fetch in one request, when that is below the page size. */
batchSizeCap?: number
onBatchSizeChange?: (batchSize: number) => void
onStop: () => void
}
let {
loaded,
total,
itemsLabel,
batchSize = null,
batchSizeCap,
onBatchSizeChange,
onStop
}: Props = $props()
let percent = $derived(total > 0 ? Math.round((Math.min(loaded, total) / total) * 100) : 0)
// A batch as large as the whole page is not a batch: it would end the streaming this row exists
// to drive, taking the row itself away mid-edit.
let maxBatchSize = $derived(Math.max(1, Math.min(total - 1, batchSizeCap ?? total - 1)))
</script>
<div class="flex items-center gap-3 text-xs text-secondary">
<span class="whitespace-nowrap shrink-0">Loading {itemsLabel}: {loaded} of {total}...</span>
<div class="flex-1 min-w-8 bg-surface-hover rounded-full h-1.5">
<div
class="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style="width: {percent}%"
></div>
</div>
{#if batchSize != null}
<span class="whitespace-nowrap shrink-0">Batch size:</span>
<TextInput
size="xs"
class="!w-14 shrink-0 text-center"
value={batchSize}
inputProps={{
type: 'number',
min: 1,
max: maxBatchSize,
onchange: (e) => {
const v = parseInt(e.currentTarget.value)
if (v >= 1 && v <= maxBatchSize) {
onBatchSizeChange?.(v)
} else {
e.currentTarget.value = String(batchSize)
}
}
}}
/>
{/if}
<Button size="xs" destructive onClick={onStop}>Stop</Button>
</div>
+11 -29
View File
@@ -48,6 +48,7 @@
import { twMerge } from 'tailwind-merge'
import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte'
import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte'
import BatchLoadProgress from '$lib/components/BatchLoadProgress.svelte'
import { pluralize, MAX_RESOLUTION_BATCH, MAX_RESOLUTION_NOTE_LEN } from '$lib/utils'
import BatchReRunOptionsPane, {
type BatchReRunOptions
@@ -934,35 +935,16 @@
<div class="h-full flex">
<div class="flex flex-col flex-1 m-4 mt-2 mr-2">
{#if batchProgress}
<div class="flex items-center gap-3 px-1 pb-2 text-xs text-secondary">
<span>Loading jobs: {batchProgress.loaded} of {batchProgress.total}...</span>
<div class="flex-1 bg-surface-hover rounded-full h-1.5">
<div
class="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
style="width: {Math.round(
(batchProgress.loaded / batchProgress.total) * 100
)}%"
></div>
</div>
{#if currentBatchSize != null}
<span class="whitespace-nowrap shrink-0">Batch size:</span>
<input
type="number"
min="1"
max="1000"
value={currentBatchSize}
class="!w-14 shrink-0 text-xs px-1 py-0.5 border rounded text-center"
onchange={(e) => {
const v = parseInt(e.currentTarget.value)
if (v >= 1 && v <= 1000) {
jobsLoader.restreamWithBatchSize(v)
}
}}
/>
{/if}
<Button size="xs" destructive onClick={() => jobsLoader.stopBatchLoading()}>
Stop
</Button>
<div class="px-1 pb-2">
<BatchLoadProgress
loaded={batchProgress.loaded}
total={batchProgress.total}
itemsLabel="jobs"
batchSize={currentBatchSize}
batchSizeCap={1000}
onBatchSizeChange={(v) => jobsLoader.restreamWithBatchSize(v)}
onStop={() => jobsLoader.stopBatchLoading()}
/>
</div>
{/if}
<!-- Runs table. Add overflow-hidden because scroll is handled inside the runs table based on this wrapper height -->
@@ -21,24 +21,21 @@
import CalendarPicker from '$lib/components/common/calendarPicker/CalendarPicker.svelte'
import {
type AuditLog,
AuditService,
ResourceService,
UserService,
ScriptService,
FlowService,
AppService,
CancelError
AppService
} from '$lib/gen'
import { userStore, workspaceStore } from '$lib/stores'
import { ChevronDown, Download, Loader2, RefreshCcw } from 'lucide-svelte'
import { onDestroy, untrack } from 'svelte'
import { onDestroy, onMount, untrack } from 'svelte'
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
import Select from '../select/Select.svelte'
import { usePromise } from '$lib/svelte5Utils.svelte'
import { safeSelectItems } from '../select/utils.svelte'
import { CancelablePromiseUtils } from '$lib/cancelable-promise-utils'
import { sendUserToast } from '$lib/toast'
let usernames: string[] | undefined = $state()
@@ -48,7 +45,6 @@
logs?: AuditLog[]
username?: string
pageIndex?: number | undefined
hasMore?: boolean
before?: string | undefined
after?: string | undefined
perPage?: number | undefined
@@ -57,13 +53,13 @@
actionKind?: ActionKind | 'all'
scope?: undefined | 'all_workspaces' | 'instance'
loading?: boolean
onRefresh?: () => void
}
let {
logs = $bindable(undefined),
logs = undefined,
username = $bindable('all'),
pageIndex = $bindable(1),
hasMore = $bindable(false),
before = $bindable(undefined),
after = $bindable(undefined),
perPage = $bindable(100),
@@ -71,13 +67,11 @@
resource = $bindable() as string | undefined,
actionKind = $bindable(undefined),
scope = $bindable(undefined),
loading = $bindable(false)
loading = false,
onRefresh
}: Props = $props()
$effect.pre(() => {
if (logs == undefined) {
logs = []
}
if (operation == undefined) {
operation = 'all'
}
@@ -89,47 +83,6 @@
}
})
function loadLogs() {
loading = true
let username_ = username == 'all' ? undefined : username
let operation_ = operation == 'all' || operation == '' ? undefined : operation
let actionKind_ = actionKind == 'all' ? undefined : actionKind
let resource_ = resource == 'all' || resource == '' ? undefined : resource
let _promise = AuditService.listAuditLogs({
workspace: scope === 'instance' ? 'global' : $workspaceStore!,
page: pageIndex,
perPage,
before,
after,
username: username_,
operation: operation_,
resource: resource_,
actionKind: actionKind_,
allWorkspaces: scope === 'all_workspaces'
})
let promise = CancelablePromiseUtils.map(_promise, (value) => {
logs = value
hasMore = !logs || (logs.length > 0 && logs.length === perPage)
loading = false
})
promise = CancelablePromiseUtils.onTimeout(promise, 4000, () => {
sendUserToast(
'Loading audit logs is taking longer than expected...',
'warning',
perPage > 25
? [{ label: 'Reduce to 25 items per page', callback: () => (perPage = 25) }]
: []
)
})
promise = CancelablePromiseUtils.catchErr(promise, (e) => {
if (e instanceof CancelError) return CancelablePromiseUtils.pure<void>(undefined)
return CancelablePromiseUtils.err<void>(e)
})
return promise
}
async function loadUsers() {
usernames =
$userStore?.is_admin || $userStore?.is_super_admin
@@ -277,9 +230,6 @@
WORKSPACES_DELETE: 'workspaces.delete'
}
let refresh = $state(0)
let lastRefresh = $state(-1)
function downloadAuditLogsAsJson() {
if (!logs || logs.length === 0) {
sendUserToast('No audit logs to download', true)
@@ -302,19 +252,15 @@
URL.revokeObjectURL(url)
}
// observe all the variables that should trigger an update
onMount(() => {
loadUsers()
resources.refresh()
})
// observe all the variables that should be reflected in the url
$effect(() => {
;[refresh, username, perPage, before, after, operation, resource, actionKind, scope, pageIndex]
return untrack(() => {
if (refresh !== lastRefresh) {
loadUsers()
resources.refresh()
lastRefresh = refresh
}
updateQueryParams()
let promise = loadLogs()
return () => promise?.cancel()
})
;[username, perPage, before, after, operation, resource, actionKind, scope, pageIndex]
untrack(() => updateQueryParams())
})
</script>
@@ -476,7 +422,9 @@
<Button
variant="accent"
on:click={() => {
refresh++
loadUsers()
resources.refresh()
onRefresh?.()
}}
unifiedSize="md"
wrapperClasses="ml-auto"
@@ -7,6 +7,7 @@
import { ChevronLeft, ChevronRight, 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[]
@@ -20,6 +21,10 @@
resourceFilter?: string | undefined
showWorkspace?: boolean
loading?: boolean
batchProgress?: { loaded: number; total: number } | null
batchSize?: number | null
onBatchSizeChange?: (batchSize: number) => void
onStopLoading?: () => void
onselect?: (id: number) => void
}
@@ -27,13 +32,17 @@
logs = [],
pageIndex = $bindable(1),
perPage = $bindable(100),
hasMore = $bindable(true),
hasMore = true,
actionKind = $bindable(),
operation = $bindable(),
selectedId = undefined,
usernameFilter = $bindable(),
resourceFilter = $bindable(),
showWorkspace = false,
batchProgress = null,
batchSize = null,
onBatchSizeChange,
onStopLoading,
onselect,
loading
}: Props = $props()
@@ -322,6 +331,18 @@
Next
</Button>
</div>
{#if batchProgress}
<div class="flex-1 min-w-0 px-4">
<BatchLoadProgress
loaded={batchProgress.loaded}
total={batchProgress.total}
itemsLabel="logs"
{batchSize}
onBatchSizeChange={(size) => onBatchSizeChange?.(size)}
onStop={() => onStopLoading?.()}
/>
</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">
@@ -0,0 +1,246 @@
import { untrack } from 'svelte'
import { AuditService, CancelError, CancelablePromise, type AuditLog } from '$lib/gen'
import { CancelablePromiseUtils } from '$lib/cancelable-promise-utils'
import { sendUserToast } from '$lib/toast'
import type { ActionKind } from '$lib/common'
export interface AuditLogsLoaderArgs {
workspace: string | undefined
scope: undefined | 'all_workspaces' | 'instance'
username: string
operation: string
resource: string | undefined
actionKind: ActionKind | 'all'
before: string | undefined
after: string | undefined
pageIndex: number
perPage: number
}
const SMALL_BATCH_SIZE = 25
// The page size comes from the url, and a batched load turns it into one request per batch, so it
// has to be capped at the largest size the page itself offers.
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.
*/
export function useAuditLogsLoader(args: () => AuditLogsLoaderArgs) {
let logs: AuditLog[] | undefined = $state()
let loading = $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
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,
after: a.after,
username: a.username === 'all' ? undefined : a.username,
operation: a.operation === 'all' || a.operation === '' ? undefined : a.operation,
resource: a.resource === 'all' || a.resource === '' ? undefined : a.resource,
actionKind: a.actionKind === 'all' ? undefined : a.actionKind,
allWorkspaces: a.scope === 'all_workspaces'
})
}
function clearBatchState() {
batchProgress = null
currentBatchSize = null
}
/**
* A load that stops or fails never completed its page: it says nothing about whether a next
* page exists, and with no rows of its own the rows of the query it replaced would stand in
* for its result.
*/
function abandonLoad() {
if (!pendingLoadHasRows) {
logs = []
}
hasMore = false
clearBatchState()
loading = false
}
function load(batchSize?: number): CancelablePromise<void> {
pendingLoad?.cancel()
pendingLoad = undefined
pendingLoadHasRows = false
const a = args()
if (a.workspace == undefined && a.scope !== 'instance') {
loading = false
clearBatchState()
return CancelablePromiseUtils.pure<void>(undefined)
}
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
currentBatchSize = isBatched ? size : null
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)
if (isBatched && size > 1) {
fetch = CancelablePromiseUtils.onTimeout(fetch, 4000, () => {
if (slowBatchToastShown) return
slowBatchToastShown = true
sendUserToast(
`Streaming by batches of ${size} is slow, try loading one at a time`,
'warning',
[{ label: 'Stream 1 by 1', callback: () => restreamWithBatchSize(1) }]
)
})
}
return CancelablePromiseUtils.then(fetch, (rows) => {
acc.push(...(skip > 0 ? rows.slice(skip) : rows).slice(0, total - acc.length))
logs = [...acc]
loading = false
pendingLoadHasRows = true
if (isBatched) {
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.
hasMore = acc.length >= total
return CancelablePromiseUtils.pure<void>(undefined)
}
return loadBatch(rows[rows.length - 1].id, 0)
})
}
let slowLoadIntervalId: ReturnType<typeof setInterval> | undefined
if (isBatched) {
slowLoadIntervalId = setInterval(() => {
sendUserToast(
'Loading is taking a long time...',
'warning',
[{ label: 'Stop loading', callback: () => stopBatchLoading() }],
undefined,
8000
)
}, 15000)
}
let promise = loadBatch(undefined, skipFirst)
if (!isBatched) {
promise = CancelablePromiseUtils.onTimeout(promise, 4000, () => {
const smaller = total > SMALL_BATCH_SIZE ? SMALL_BATCH_SIZE : 1
sendUserToast(
'Loading audit logs is taking longer than expected...',
'warning',
total > 1
? [
{
label: smaller === 1 ? 'Stream 1 by 1' : `Stream by batches of ${smaller}`,
callback: () => restreamWithBatchSize(smaller)
}
]
: []
)
})
}
promise = CancelablePromiseUtils.finallyDo(promise, () => {
if (slowLoadIntervalId) clearInterval(slowLoadIntervalId)
})
// Only on success: a cancel means another load already owns these.
promise = CancelablePromiseUtils.pipe(promise, clearBatchState)
promise = CancelablePromiseUtils.catchErr(promise, (e) => {
if (e instanceof CancelError) return CancelablePromiseUtils.pure<void>(undefined)
abandonLoad()
sendUserToast(
'There was an issue loading audit logs, see browser console for more details',
true
)
console.error(e)
return CancelablePromiseUtils.pure<void>(undefined)
})
const thisLoad = promise
// The "Stop loading" toast outlives the load it was raised for, so a settled load has to
// stop being the pending one.
CancelablePromiseUtils.pipe(thisLoad, () => {
if (pendingLoad === thisLoad) pendingLoad = undefined
})
pendingLoad = thisLoad
return thisLoad
}
function restreamWithBatchSize(batchSize: number) {
load(batchSize)
}
function stopBatchLoading() {
if (!pendingLoad) return
pendingLoad.cancel()
pendingLoad = undefined
abandonLoad()
}
$effect(() => {
// Building the args reads every filter, which is what registers this effect's dependencies.
args()
untrack(() => load())
return () => {
pendingLoad?.cancel()
pendingLoad = undefined
}
})
return {
reload: () => load(),
restreamWithBatchSize,
stopBatchLoading,
get logs() {
return logs
},
get loading() {
return loading
},
get hasMore() {
return hasMore
},
get batchProgress() {
return batchProgress
},
get currentBatchSize() {
return currentBatchSize
}
}
}
@@ -0,0 +1,19 @@
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 })
})
})
@@ -16,12 +16,11 @@
import { enterpriseLicense, userStore, workspaceStore, userWorkspaces } from '$lib/stores'
import { Splitpanes, Pane } from 'svelte-splitpanes'
import AuditLogsTimeline from '$lib/components/auditLogs/AuditLogsTimeline.svelte'
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 hasMore: boolean = $state(false)
let loading: boolean = $state(false)
let after: string | undefined = $state(page.url.searchParams.get('after') ?? undefined)
let perPage: number | undefined = $state(Number(page.url.searchParams.get('perPage')) || 100)
let operation: string = $state(page.url.searchParams.get('operation') ?? 'all')
@@ -34,7 +33,30 @@
(page.url.searchParams.get('actionKind') as ActionKind) ?? 'all'
)
let logs: AuditLog[] | undefined = $state()
let auditLogsLoader = useAuditLogsLoader(() => ({
workspace: $workspaceStore,
scope,
username,
operation,
resource,
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.
let timelineLogs: AuditLog[] | undefined = $state()
$effect(() => {
const settledLogs = batchProgress ? undefined : auditLogsLoader.logs
if (settledLogs) {
timelineLogs = settledLogs
}
})
let selectedId: number | undefined = $state(undefined)
let auditLogDrawer: Drawer | undefined = $state()
@@ -97,7 +119,7 @@
<div class="flex flex-row flex-wrap justify-between py-2 my-2 px-4 gap-1 items-center">
<div class="hidden 2xl:block">
<AuditLogsFilters
bind:logs
{logs}
bind:username
bind:before
bind:after
@@ -107,15 +129,15 @@
bind:pageIndex
bind:perPage
bind:scope
bind:hasMore
bind:loading
loading={auditLogsLoader.loading}
onRefresh={() => auditLogsLoader.reload()}
/>
</div>
<div class="2xl:hidden">
<AuditLogMobileFilters>
{#snippet filters()}
<AuditLogsFilters
bind:logs
{logs}
bind:username
bind:before
bind:after
@@ -123,7 +145,8 @@
bind:operation
bind:resource
bind:scope
bind:hasMore
loading={auditLogsLoader.loading}
onRefresh={() => auditLogsLoader.reload()}
/>
{/snippet}
</AuditLogMobileFilters>
@@ -131,9 +154,9 @@
</div>
</div>
<div class="h-2/6">
{#if logs}
{#if timelineLogs}
<AuditLogsTimeline
{logs}
logs={timelineLogs}
minTimeSet={after}
maxTimeSet={before}
onZoom={({ min, max }) => {
@@ -161,9 +184,11 @@
<SplitPanesWrapper>
<Splitpanes>
<Pane size={70} minSize={50}>
{#if logs}
<!-- 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}
loading={auditLogsLoader.loading}
{logs}
{selectedId}
bind:pageIndex
@@ -172,7 +197,11 @@
bind:operation
bind:usernameFilter={username}
bind:resourceFilter={resource}
bind:hasMore
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
@@ -197,13 +226,18 @@
<div class="md:hidden">
<AuditLogsTable
{logs}
bind:hasMore
loading={auditLogsLoader.loading}
hasMore={auditLogsLoader.hasMore}
bind:pageIndex
bind:perPage
bind:actionKind
bind:operation
bind:usernameFilter={username}
bind:resourceFilter={resource}
{batchProgress}
batchSize={auditLogsLoader.currentBatchSize}
onBatchSizeChange={(size) => auditLogsLoader.restreamWithBatchSize(size)}
onStopLoading={() => auditLogsLoader.stopBatchLoading()}
showWorkspace={scope === 'instance' || scope === 'all_workspaces'}
onselect={(id) => {
selectedId = id