mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-13 16:02:59 +00:00
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user