mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-12 00:06:14 +00:00
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:
co-authored by
Claude Opus 5
windmill-internal-app[bot]
parent
44b7d97e36
commit
9334727d99
@@ -1 +1 @@
|
||||
a65162b22b127b54c0686095ee1b16b04e3111f7
|
||||
aab7da6e1f8b1fadacc2208913a5d6596f06f922
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user