mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-22 16:02:24 +00:00
fix: send alternatives on timeout toast (#6920)
* Fix broken batch reruns InputTransformForm * send alternatives on timeout toast * Toast when runs page query is slow + throttle toasts spamming * configurable perPage in runs page * store perPage in query params * subtle border * nit fix * reduce to 25 actions on runs page * Fix annoying scrollbar due to AIChatLayout * nit animated pane * don't kill AI Chat Manager on pane close * button shrink 0 * nist * Cancelable Promise Utils * migrate to CancelablePromiseUtils * CancelablePromise onTimeout + update usage * CancelablePromise onTimeout + update usage * Loading spinner + fix per page bug in audit logs * Fix .cancel() not behaving as expected * fix nits * audit logs nits * auditlog filter fix selects * fix wrong number of jobs when switching perPage * default 1000 * Fix breaking merge conflict * Fix missing computeCompletedJobs * change audit logs default perPage to 100
This commit is contained in:
Generated
+4
-4
@@ -24,7 +24,7 @@
|
||||
"@redocly/json-to-json-schema": "^0.0.1",
|
||||
"@scalar/openapi-parser": "^0.15.0",
|
||||
"@tanstack/svelte-table": "npm:tanstack-table-8-svelte-5@^0.1",
|
||||
"@tutorlatin/svelte-tiny-virtual-list": "^3.0.2",
|
||||
"@tutorlatin/svelte-tiny-virtual-list": "^3.0.16",
|
||||
"@windmill-labs/svelte-dnd-action": "^0.9.44",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xyflow/svelte": "^1.0.0",
|
||||
@@ -3020,9 +3020,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tutorlatin/svelte-tiny-virtual-list": {
|
||||
"version": "3.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@tutorlatin/svelte-tiny-virtual-list/-/svelte-tiny-virtual-list-3.0.15.tgz",
|
||||
"integrity": "sha512-ew61aZNXGf0b5X+UjbOAhiNwzI21vijhB/mtBs8bpNOVYQ50TG6Qx00t+fR5C72eGnmdzguewZ2WP6QPNOTQJg==",
|
||||
"version": "3.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@tutorlatin/svelte-tiny-virtual-list/-/svelte-tiny-virtual-list-3.0.16.tgz",
|
||||
"integrity": "sha512-JQSmhRDAFZbq2rTlzn+kFXJayi5VPLxeGjD01ZVyV2ti7PlQE/ov6rQFR1c8s7Y3B1OiTcv3oEWGi3ib69V8eQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.17.0"
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
"@redocly/json-to-json-schema": "^0.0.1",
|
||||
"@scalar/openapi-parser": "^0.15.0",
|
||||
"@tanstack/svelte-table": "npm:tanstack-table-8-svelte-5@^0.1",
|
||||
"@tutorlatin/svelte-tiny-virtual-list": "^3.0.2",
|
||||
"@tutorlatin/svelte-tiny-virtual-list": "^3.0.16",
|
||||
"@windmill-labs/svelte-dnd-action": "^0.9.44",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xyflow/svelte": "^1.0.0",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { CancelablePromise } from './gen'
|
||||
|
||||
export namespace CancelablePromiseUtils {
|
||||
export function then<T, U>(
|
||||
promise: CancelablePromise<T>,
|
||||
f: (value: T) => CancelablePromise<U>
|
||||
): CancelablePromise<U> {
|
||||
let promiseToBeCanceled: CancelablePromise<any> = promise
|
||||
let p = new CancelablePromise<U>((resolve, reject) => {
|
||||
promise
|
||||
.then((value1) => {
|
||||
let promise2 = f(value1)
|
||||
promiseToBeCanceled = promise2
|
||||
promise2.then((value2) => resolve(value2)).catch((err) => reject(err))
|
||||
})
|
||||
.catch((err) => reject(err))
|
||||
})
|
||||
p.cancel = () => promiseToBeCanceled.cancel()
|
||||
return p
|
||||
}
|
||||
|
||||
export function pure<T>(value: T): CancelablePromise<T> {
|
||||
return new CancelablePromise((resolve) => resolve(value))
|
||||
}
|
||||
|
||||
export function err<T>(error: any): CancelablePromise<T> {
|
||||
return new CancelablePromise((_, reject) => reject(error))
|
||||
}
|
||||
|
||||
export function map<T, U>(
|
||||
promise: CancelablePromise<T>,
|
||||
f: (value: T) => U
|
||||
): CancelablePromise<U> {
|
||||
return then(promise, (value) => pure(f(value)))
|
||||
}
|
||||
|
||||
export function pipe<T>(
|
||||
promise: CancelablePromise<T>,
|
||||
f: (value: T) => void
|
||||
): CancelablePromise<T> {
|
||||
promise.then((value) => {
|
||||
f(value)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
export function catchErr<T, U>(
|
||||
promise: CancelablePromise<T>,
|
||||
f: (error: any) => CancelablePromise<U>
|
||||
): CancelablePromise<T | U> {
|
||||
let promiseToBeCanceled: CancelablePromise<any> = promise
|
||||
let p = new CancelablePromise<T | U>((resolve, reject) => {
|
||||
promise
|
||||
.then((value) => resolve(value))
|
||||
.catch((err) => {
|
||||
let promise2 = f(err)
|
||||
promiseToBeCanceled = promise2
|
||||
return promise2.then((value2) => resolve(value2)).catch((err2) => reject(err2))
|
||||
})
|
||||
.catch((err) => reject(err))
|
||||
})
|
||||
p.cancel = () => promiseToBeCanceled.cancel()
|
||||
return p
|
||||
}
|
||||
|
||||
export function finallyDo<T>(promise: CancelablePromise<T>, f: () => void): CancelablePromise<T> {
|
||||
promise = map(promise, (value) => (f(), value))
|
||||
promise = catchErr(promise, (e) => (f(), err(e)))
|
||||
return promise
|
||||
}
|
||||
|
||||
// Calls onTimeout if the promise does not settle within timeoutMs milliseconds
|
||||
export function onTimeout<T>(
|
||||
promise: CancelablePromise<T>,
|
||||
timeoutMs: number,
|
||||
onTimeout: () => void
|
||||
): CancelablePromise<T> {
|
||||
let timeoutId: number | undefined = setTimeout(onTimeout, timeoutMs)
|
||||
promise = finallyDo(promise, () => {
|
||||
if (timeoutId !== undefined) clearTimeout(timeoutId)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
selectedIds?: string[]
|
||||
canSelect?: boolean
|
||||
lastFetchWentToEnd?: boolean
|
||||
totalRowsFetched: number
|
||||
onPointClicked: (ids: string[]) => void
|
||||
onLoadExtra: () => void
|
||||
onZoom: (zoom: { min: Date; max: Date }) => void
|
||||
@@ -41,6 +42,7 @@
|
||||
selectedIds = $bindable([]),
|
||||
canSelect = true,
|
||||
lastFetchWentToEnd = false,
|
||||
totalRowsFetched,
|
||||
onPointClicked,
|
||||
onLoadExtra,
|
||||
onZoom
|
||||
@@ -301,13 +303,14 @@
|
||||
<!-- {JSON.stringify(jobs?.map((x) => x.started_at))} -->
|
||||
<div class="relative max-h-40">
|
||||
{#if !lastFetchWentToEnd}
|
||||
<div class="absolute top-[-28px] left-[220px]"
|
||||
><Button size="xs" color="transparent" variant="contained" on:click={() => onLoadExtra()}
|
||||
>Load more <Tooltip2
|
||||
>There are more jobs to load but only the first 1000 were fetched</Tooltip2
|
||||
></Button
|
||||
></div
|
||||
>
|
||||
<div class="absolute top-[-28px] left-[220px]">
|
||||
<Button size="xs" color="transparent" variant="contained" on:click={() => onLoadExtra()}>
|
||||
Load more
|
||||
<Tooltip2>
|
||||
There are more jobs to load but only the first {totalRowsFetched} were fetched
|
||||
</Tooltip2>
|
||||
</Button>
|
||||
</div>
|
||||
{/if}
|
||||
<Scatter {data} options={scatterOptions} />
|
||||
</div>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -26,22 +26,23 @@
|
||||
UserService,
|
||||
ScriptService,
|
||||
FlowService,
|
||||
AppService
|
||||
AppService,
|
||||
CancelError
|
||||
} from '$lib/gen'
|
||||
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import { ChevronDown, Loader2, RefreshCcw } from 'lucide-svelte'
|
||||
import { onDestroy, tick, untrack } from 'svelte'
|
||||
import { onDestroy, 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()
|
||||
let resources = usePromise(() => loadResources($workspaceStore!), { loadInit: false })
|
||||
let loading: boolean = $state(false)
|
||||
let page: number | undefined = undefined
|
||||
|
||||
interface Props {
|
||||
logs?: AuditLog[]
|
||||
@@ -55,6 +56,7 @@
|
||||
resource?: string | undefined
|
||||
actionKind?: ActionKind | 'all'
|
||||
scope?: undefined | 'all_workspaces' | 'instance'
|
||||
loading?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -68,7 +70,8 @@
|
||||
operation = $bindable(),
|
||||
resource = $bindable() as string | undefined,
|
||||
actionKind = $bindable(undefined),
|
||||
scope = $bindable(undefined)
|
||||
scope = $bindable(undefined),
|
||||
loading = $bindable(false)
|
||||
}: Props = $props()
|
||||
|
||||
$effect.pre(() => {
|
||||
@@ -86,50 +89,45 @@
|
||||
}
|
||||
})
|
||||
|
||||
async function loadLogs(
|
||||
username: string | undefined,
|
||||
page: number | undefined,
|
||||
perPage: number | undefined,
|
||||
before: string | undefined,
|
||||
after: string | undefined,
|
||||
operation: string | undefined,
|
||||
resource: string | undefined,
|
||||
actionKind: ActionKind | undefined | 'all',
|
||||
scope: undefined | 'all_workspaces' | 'instance'
|
||||
): Promise<void> {
|
||||
function loadLogs() {
|
||||
loading = true
|
||||
|
||||
if (username == 'all') {
|
||||
username = undefined
|
||||
}
|
||||
if (operation == 'all' || operation == '') {
|
||||
operation = undefined
|
||||
}
|
||||
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
|
||||
|
||||
// @ts-ignore
|
||||
if (actionKind == 'all' || actionKind == '') {
|
||||
actionKind = undefined
|
||||
}
|
||||
|
||||
if (resource == 'all' || resource == '') {
|
||||
resource = undefined
|
||||
}
|
||||
|
||||
logs = await AuditService.listAuditLogs({
|
||||
let _promise = AuditService.listAuditLogs({
|
||||
workspace: scope === 'instance' ? 'global' : $workspaceStore!,
|
||||
page,
|
||||
page: pageIndex,
|
||||
perPage,
|
||||
before,
|
||||
after,
|
||||
username,
|
||||
operation,
|
||||
resource,
|
||||
actionKind,
|
||||
username: username_,
|
||||
operation: operation_,
|
||||
resource: resource_,
|
||||
actionKind: actionKind_,
|
||||
allWorkspaces: scope === 'all_workspaces'
|
||||
})
|
||||
hasMore = logs.length > 0 && logs.length === perPage
|
||||
|
||||
loading = false
|
||||
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...',
|
||||
true,
|
||||
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() {
|
||||
@@ -139,17 +137,7 @@
|
||||
: [$userStore?.username ?? '']
|
||||
}
|
||||
|
||||
let initialLoad = true
|
||||
function refreshLogs() {
|
||||
loadUsers()
|
||||
resources.refresh()
|
||||
loadLogs(username, page, perPage, before, after, operation, resource, actionKind, scope)
|
||||
tick().then(() => {
|
||||
initialLoad = false
|
||||
})
|
||||
}
|
||||
|
||||
function updateLogs() {
|
||||
function updateQueryParams() {
|
||||
const queryParams: string[] = []
|
||||
|
||||
function addQueryParam(key: string, value: string | number | undefined | null) {
|
||||
@@ -159,7 +147,7 @@
|
||||
}
|
||||
|
||||
addQueryParam('username', username)
|
||||
addQueryParam('page', page)
|
||||
addQueryParam('page', pageIndex)
|
||||
addQueryParam('perPage', perPage)
|
||||
addQueryParam('before', before)
|
||||
addQueryParam('after', after)
|
||||
@@ -172,25 +160,6 @@
|
||||
}
|
||||
const query = '?' + queryParams.join('&')
|
||||
goto(query, { replaceState: true, keepFocus: true })
|
||||
|
||||
loadLogs(username, page, perPage, before, after, operation, resource, actionKind, scope)
|
||||
}
|
||||
|
||||
function updateQueryParams() {
|
||||
if (initialLoad) {
|
||||
return
|
||||
}
|
||||
page = 1
|
||||
pageIndex = 1
|
||||
updateLogs()
|
||||
}
|
||||
|
||||
function updatePageQueryParams(pageIndex?: number | undefined) {
|
||||
if (initialLoad) {
|
||||
return
|
||||
}
|
||||
page = pageIndex
|
||||
updateLogs()
|
||||
}
|
||||
|
||||
window.addEventListener('popstate', handlePopState)
|
||||
@@ -307,18 +276,22 @@
|
||||
WORKSPACES_DELETE: 'workspaces.delete'
|
||||
}
|
||||
|
||||
let refresh = $state(1)
|
||||
$effect(() => {
|
||||
$workspaceStore && refresh && untrack(() => refreshLogs())
|
||||
})
|
||||
let refresh = $state(0)
|
||||
let lastRefresh = $state(-1)
|
||||
|
||||
// observe all the variables that should trigger an update
|
||||
$effect(() => {
|
||||
;[username, perPage, before, after, operation, resource, actionKind, scope]
|
||||
updateQueryParams()
|
||||
})
|
||||
// observe the pageIndex variable that should trigger an update
|
||||
$effect(() => {
|
||||
updatePageQueryParams(pageIndex)
|
||||
;[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()
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -390,25 +363,30 @@
|
||||
|
||||
<div class="flex gap-1 relative w-full">
|
||||
<span class="text-xs absolute font-semibold text-emphasis -top-4">Username</span>
|
||||
<select bind:value={username}>
|
||||
{#if usernames}
|
||||
{#if $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<option selected>all</option>
|
||||
{/if}
|
||||
{#each usernames as e}
|
||||
{#if e == username || $userStore?.is_admin || $userStore?.is_super_admin}
|
||||
<option>{e}</option>
|
||||
{:else}
|
||||
<option disabled>{e}</option>
|
||||
{/if}
|
||||
{/each}
|
||||
{/if}
|
||||
</select>
|
||||
<Select
|
||||
bind:value={username}
|
||||
class="w-full"
|
||||
disablePortal
|
||||
RightIcon={ChevronDown}
|
||||
items={usernames
|
||||
? [
|
||||
...($userStore?.is_admin || $userStore?.is_super_admin
|
||||
? [{ value: 'all', label: 'all' }]
|
||||
: []),
|
||||
...usernames.map((e) => ({
|
||||
value: e,
|
||||
label: e,
|
||||
disabled: e !== username && !$userStore?.is_admin && !$userStore?.is_super_admin
|
||||
}))
|
||||
]
|
||||
: []}
|
||||
/>
|
||||
</div>
|
||||
<div class="flex gap-1 relative w-full">
|
||||
<span class="text-xs absolute font-semibold text-emphasis -top-4">Resource</span>
|
||||
|
||||
<Select
|
||||
disablePortal
|
||||
onCreateItem={(r) => (resources.value?.push(r), (resource = r))}
|
||||
createText="Press enter to use this value"
|
||||
bind:value={resource}
|
||||
@@ -423,6 +401,7 @@
|
||||
<span class="text-xs absolute font-semibold text-emphasis -top-4">Operation</span>
|
||||
|
||||
<Select
|
||||
disablePortal
|
||||
bind:value={operation}
|
||||
items={['all', ...Object.values(operations)].map((r) => ({ value: r, label: r }))}
|
||||
inputClass="dark:!bg-gray-700"
|
||||
@@ -434,12 +413,19 @@
|
||||
<div class="flex gap-1 relative w-full">
|
||||
<span class="text-xs absolute font-semibold text-emphasis -top-4">Action</span>
|
||||
|
||||
<select class="!truncate" bind:value={actionKind}>
|
||||
<option selected value="all">all</option>
|
||||
{#each ['Create', 'Update', 'Delete', 'Execute'] as e}
|
||||
<option value={e.toLocaleLowerCase()}>{e}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<Select
|
||||
class="w-full"
|
||||
bind:value={actionKind}
|
||||
disablePortal
|
||||
RightIcon={ChevronDown}
|
||||
items={[
|
||||
{ value: 'all', label: 'all' },
|
||||
{ value: 'create', label: 'Create' },
|
||||
{ value: 'update', label: 'Update' },
|
||||
{ value: 'delete', label: 'Delete' },
|
||||
{ value: 'execute', label: 'Execute' }
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-1">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
import { displayDate } from '$lib/utils'
|
||||
import { onMount, tick } from 'svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import { ChevronLeft, ChevronRight, ListFilterPlus } from 'lucide-svelte'
|
||||
import { ChevronLeft, ChevronRight, ListFilterPlus, Loader2 } from 'lucide-svelte'
|
||||
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
@@ -19,6 +19,7 @@
|
||||
usernameFilter?: string | undefined
|
||||
resourceFilter?: string | undefined
|
||||
showWorkspace?: boolean
|
||||
loading?: boolean
|
||||
onselect?: (id: number) => void
|
||||
}
|
||||
|
||||
@@ -33,7 +34,8 @@
|
||||
usernameFilter = $bindable(),
|
||||
resourceFilter = $bindable(),
|
||||
showWorkspace = false,
|
||||
onselect
|
||||
onselect,
|
||||
loading
|
||||
}: Props = $props()
|
||||
|
||||
function groupLogsByDay(logs: AuditLog[]): Record<string, AuditLog[]> {
|
||||
@@ -120,6 +122,7 @@
|
||||
}
|
||||
return 'gray'
|
||||
}
|
||||
let height = $derived(tableHeight - headerHeight - footerHeight)
|
||||
</script>
|
||||
|
||||
<svelte:window onresize={() => computeHeight()} />
|
||||
@@ -139,12 +142,22 @@
|
||||
<div class="w-2/12">Resource</div>
|
||||
</div>
|
||||
</div>
|
||||
{#if logs?.length == 0}
|
||||
<div class="text-xs text-secondary p-8"> No logs found for the selected filters. </div>
|
||||
|
||||
{#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={tableHeight - headerHeight - footerHeight}
|
||||
{height}
|
||||
itemCount={flatLogs?.length ?? 0}
|
||||
itemSize={(index) => {
|
||||
if (flatLogs?.[index]?.type === 'date') {
|
||||
|
||||
@@ -550,7 +550,7 @@
|
||||
)
|
||||
</script>
|
||||
|
||||
<div class="p-4 bg-surface mb-4 h-full">
|
||||
<div class="p-4 bg-surface mb-4 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'}
|
||||
|
||||
@@ -6,7 +6,9 @@
|
||||
type CompletedJob,
|
||||
type ExtendedJobs,
|
||||
ConcurrencyGroupsService,
|
||||
type ObscuredJob
|
||||
type ObscuredJob,
|
||||
CancelablePromise,
|
||||
CancelError
|
||||
} from '$lib/gen'
|
||||
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -14,6 +16,7 @@
|
||||
|
||||
import { tweened, type Tweened } from 'svelte/motion'
|
||||
import { subtractDaysFromDateString } from '$lib/utils'
|
||||
import { CancelablePromiseUtils } from '$lib/cancelable-promise-utils'
|
||||
|
||||
interface Props {
|
||||
jobs: Job[] | undefined
|
||||
@@ -49,7 +52,7 @@
|
||||
allWorkspaces?: boolean
|
||||
computeMinAndMax: (() => { minTs: string; maxTs: string | undefined } | undefined) | undefined
|
||||
lookback?: number
|
||||
perPage?: number | undefined
|
||||
perPage?: number
|
||||
allowWildcards?: boolean
|
||||
}
|
||||
|
||||
@@ -87,7 +90,7 @@
|
||||
allWorkspaces = false,
|
||||
computeMinAndMax,
|
||||
lookback = 0,
|
||||
perPage = undefined,
|
||||
perPage = $bindable(1000),
|
||||
allowWildcards = false
|
||||
}: Props = $props()
|
||||
let intervalId: number | undefined = $state()
|
||||
@@ -95,7 +98,23 @@
|
||||
|
||||
function onParamChanges() {
|
||||
resetJobs()
|
||||
loadJobsIntern(true)
|
||||
let promise = loadJobsIntern(true)
|
||||
promise = CancelablePromiseUtils.onTimeout(promise, 4000, () => {
|
||||
sendUserToast(
|
||||
'Loading jobs is taking longer than expected...',
|
||||
true,
|
||||
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(e)
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
function computeJobKinds(jobKindsCat: string | undefined): string {
|
||||
@@ -162,123 +181,119 @@
|
||||
const minCreated = new Date(new Date(ts).getTime() - 1).toISOString()
|
||||
|
||||
let olderJobs = await fetchJobs(minCreated, minTs, undefined)
|
||||
jobs = jobs.concat(olderJobs)
|
||||
jobs = jobs?.concat(olderJobs)
|
||||
computeCompletedJobs()
|
||||
return olderJobs?.length < 1000
|
||||
return olderJobs?.length < perPage
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async function fetchJobs(
|
||||
function fetchJobs(
|
||||
completedBefore: string | undefined,
|
||||
completedAfter: string | undefined,
|
||||
createdAfterQueue: string | undefined
|
||||
): Promise<Job[]> {
|
||||
): CancelablePromise<Job[]> {
|
||||
loadingFetch = true
|
||||
try {
|
||||
let scriptPathStart = folder === null || folder === '' ? undefined : `f/${folder}/`
|
||||
let scriptPathExact = path === null || path === '' ? undefined : path
|
||||
return JobService.listJobs({
|
||||
workspace: $workspaceStore!,
|
||||
completedBefore,
|
||||
completedAfter,
|
||||
createdAfterQueue,
|
||||
schedulePath,
|
||||
scriptPathExact,
|
||||
createdBy: user === null || user === '' ? undefined : user,
|
||||
scriptPathStart: scriptPathStart,
|
||||
jobKinds: jobKindsCat == 'all' || jobKinds == '' ? undefined : jobKinds,
|
||||
success: success == 'success' ? true : success == 'failure' ? false : undefined,
|
||||
running:
|
||||
success == 'running' || success == 'suspended'
|
||||
? true
|
||||
: success == 'waiting'
|
||||
? false
|
||||
: undefined,
|
||||
isSkipped: showSkipped ? undefined : false,
|
||||
// isFlowStep: jobKindsCat != 'all' ? false : undefined,
|
||||
hasNullParent: jobKindsCat != 'all' ? true : undefined,
|
||||
label: label === null || label === '' ? undefined : label,
|
||||
tag: tag === null || tag === '' ? undefined : tag,
|
||||
worker: worker === null || worker === '' ? undefined : worker,
|
||||
isNotSchedule: showSchedules == false ? true : undefined,
|
||||
suspended: success == 'waiting' ? false : success == 'suspended' ? true : undefined,
|
||||
scheduledForBeforeNow:
|
||||
showFutureJobs == false || success == 'waiting' || success == 'suspended'
|
||||
? true
|
||||
let scriptPathStart = folder === null || folder === '' ? undefined : `f/${folder}/`
|
||||
let scriptPathExact = path === null || path === '' ? undefined : path
|
||||
let promise = JobService.listJobs({
|
||||
workspace: $workspaceStore!,
|
||||
completedBefore,
|
||||
completedAfter,
|
||||
createdAfterQueue,
|
||||
schedulePath,
|
||||
scriptPathExact,
|
||||
createdBy: user === null || user === '' ? undefined : user,
|
||||
scriptPathStart: scriptPathStart,
|
||||
jobKinds: jobKindsCat == 'all' || jobKinds == '' ? undefined : jobKinds,
|
||||
success: success == 'success' ? true : success == 'failure' ? false : undefined,
|
||||
running:
|
||||
success == 'running' || success == 'suspended'
|
||||
? true
|
||||
: success == 'waiting'
|
||||
? false
|
||||
: undefined,
|
||||
args:
|
||||
argFilter && argFilter != '{}' && argFilter != '' && argError == ''
|
||||
? argFilter
|
||||
: undefined,
|
||||
result:
|
||||
resultFilter && resultFilter != '{}' && resultFilter != '' && resultError == ''
|
||||
? resultFilter
|
||||
: undefined,
|
||||
allWorkspaces: allWorkspaces ? true : undefined,
|
||||
perPage,
|
||||
allowWildcards: allowWildcards ? true : undefined
|
||||
})
|
||||
} catch (e) {
|
||||
isSkipped: showSkipped ? undefined : false,
|
||||
// isFlowStep: jobKindsCat != 'all' ? false : undefined,
|
||||
hasNullParent: jobKindsCat != 'all' ? true : undefined,
|
||||
label: label === null || label === '' ? undefined : label,
|
||||
tag: tag === null || tag === '' ? undefined : tag,
|
||||
worker: worker === null || worker === '' ? undefined : worker,
|
||||
isNotSchedule: showSchedules == false ? true : undefined,
|
||||
suspended: success == 'waiting' ? false : success == 'suspended' ? true : undefined,
|
||||
scheduledForBeforeNow:
|
||||
showFutureJobs == false || success == 'waiting' || success == 'suspended'
|
||||
? true
|
||||
: undefined,
|
||||
args:
|
||||
argFilter && argFilter != '{}' && argFilter != '' && argError == '' ? argFilter : undefined,
|
||||
result:
|
||||
resultFilter && resultFilter != '{}' && resultFilter != '' && resultError == ''
|
||||
? resultFilter
|
||||
: undefined,
|
||||
allWorkspaces: allWorkspaces ? true : undefined,
|
||||
perPage,
|
||||
allowWildcards: allowWildcards ? true : undefined
|
||||
})
|
||||
promise = CancelablePromiseUtils.catchErr(promise, (e) => {
|
||||
if (e instanceof CancelError) return CancelablePromiseUtils.err(e)
|
||||
sendUserToast('There was an issue loading jobs, see browser console for more details', true)
|
||||
console.error(e)
|
||||
return []
|
||||
} finally {
|
||||
return CancelablePromiseUtils.pure([] as Job[])
|
||||
})
|
||||
CancelablePromiseUtils.pipe(promise, () => {
|
||||
loadingFetch = false
|
||||
}
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
async function fetchExtendedJobs(
|
||||
function fetchExtendedJobs(
|
||||
concurrencyKey: string | null,
|
||||
createdBeforeQueue: string | undefined,
|
||||
completedAfter: string | undefined
|
||||
): Promise<ExtendedJobs> {
|
||||
): CancelablePromise<ExtendedJobs> {
|
||||
loadingFetch = true
|
||||
try {
|
||||
return ConcurrencyGroupsService.listExtendedJobs({
|
||||
rowLimit: 1000,
|
||||
concurrencyKey: concurrencyKey == null || concurrencyKey == '' ? undefined : concurrencyKey,
|
||||
workspace: $workspaceStore!,
|
||||
completedAfter,
|
||||
createdBeforeQueue: createdBeforeQueue,
|
||||
// createdOrStartedBefore: startedBefore,
|
||||
// createdOrStartedAfter: startedAfter,
|
||||
// createdOrStartedAfterCompletedJobs: startedAfterCompletedJobs,
|
||||
schedulePath,
|
||||
scriptPathExact: path === null || path === '' ? undefined : path,
|
||||
createdBy: user === null || user === '' ? undefined : user,
|
||||
scriptPathStart: folder === null || folder === '' ? undefined : `f/${folder}/`,
|
||||
jobKinds: jobKindsCat == 'all' || jobKinds == '' ? undefined : jobKinds,
|
||||
success: success == 'success' ? true : success == 'failure' ? false : undefined,
|
||||
running: success == 'running' ? true : undefined,
|
||||
isSkipped: showSkipped ? undefined : false,
|
||||
isFlowStep: jobKindsCat != 'all' ? false : undefined,
|
||||
label: label === null || label === '' ? undefined : label,
|
||||
tag: tag === null || tag === '' ? undefined : tag,
|
||||
isNotSchedule: showSchedules == false ? true : undefined,
|
||||
scheduledForBeforeNow: showFutureJobs == false ? true : undefined,
|
||||
args:
|
||||
argFilter && argFilter != '{}' && argFilter != '' && argError == ''
|
||||
? argFilter
|
||||
: undefined,
|
||||
result:
|
||||
resultFilter && resultFilter != '{}' && resultFilter != '' && resultError == ''
|
||||
? resultFilter
|
||||
: undefined,
|
||||
allWorkspaces: allWorkspaces ? true : undefined,
|
||||
perPage,
|
||||
allowWildcards
|
||||
})
|
||||
} catch (e) {
|
||||
let promise = ConcurrencyGroupsService.listExtendedJobs({
|
||||
rowLimit: perPage,
|
||||
concurrencyKey: concurrencyKey == null || concurrencyKey == '' ? undefined : concurrencyKey,
|
||||
workspace: $workspaceStore!,
|
||||
completedAfter,
|
||||
createdBeforeQueue: createdBeforeQueue,
|
||||
// createdOrStartedBefore: startedBefore,
|
||||
// createdOrStartedAfter: startedAfter,
|
||||
// createdOrStartedAfterCompletedJobs: startedAfterCompletedJobs,
|
||||
schedulePath,
|
||||
scriptPathExact: path === null || path === '' ? undefined : path,
|
||||
createdBy: user === null || user === '' ? undefined : user,
|
||||
scriptPathStart: folder === null || folder === '' ? undefined : `f/${folder}/`,
|
||||
jobKinds: jobKindsCat == 'all' || jobKinds == '' ? undefined : jobKinds,
|
||||
success: success == 'success' ? true : success == 'failure' ? false : undefined,
|
||||
running: success == 'running' ? true : undefined,
|
||||
isSkipped: showSkipped ? undefined : false,
|
||||
isFlowStep: jobKindsCat != 'all' ? false : undefined,
|
||||
label: label === null || label === '' ? undefined : label,
|
||||
tag: tag === null || tag === '' ? undefined : tag,
|
||||
isNotSchedule: showSchedules == false ? true : undefined,
|
||||
scheduledForBeforeNow: showFutureJobs == false ? true : undefined,
|
||||
args:
|
||||
argFilter && argFilter != '{}' && argFilter != '' && argError == '' ? argFilter : undefined,
|
||||
result:
|
||||
resultFilter && resultFilter != '{}' && resultFilter != '' && resultError == ''
|
||||
? resultFilter
|
||||
: undefined,
|
||||
allWorkspaces: allWorkspaces ? true : undefined,
|
||||
perPage,
|
||||
allowWildcards
|
||||
})
|
||||
promise = CancelablePromiseUtils.catchErr(promise, (e) => {
|
||||
sendUserToast('There was an issue loading jobs, see browser console for more details', true)
|
||||
console.error(e)
|
||||
return {
|
||||
jobs: [],
|
||||
obscured_jobs: []
|
||||
}
|
||||
} finally {
|
||||
return CancelablePromiseUtils.pure({ jobs: [], obscured_jobs: [] } as ExtendedJobs)
|
||||
})
|
||||
promise = CancelablePromiseUtils.pipe(promise, () => {
|
||||
loadingFetch = false
|
||||
}
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
export async function loadJobs(
|
||||
@@ -303,54 +318,58 @@
|
||||
intervalId && clearInterval(intervalId)
|
||||
intervalId = setInterval(syncer, refreshRate)
|
||||
}
|
||||
async function loadJobsIntern(shouldGetCount?: boolean): Promise<void> {
|
||||
function loadJobsIntern(shouldGetCount?: boolean): CancelablePromise<void> {
|
||||
if (shouldGetCount) {
|
||||
getCount()
|
||||
}
|
||||
loading = true
|
||||
try {
|
||||
// Extend MinTs to fetch jobs mefore minTs and show a correct concurrency graph
|
||||
// TODO: when an ended_at column is created on the completed_job table,
|
||||
// lookback won't be needed anymore (just filter ended_at > minTs instead
|
||||
const extendedMinTs = subtractDaysFromDateString(minTs, lookback)
|
||||
if (concurrencyKey == null || concurrencyKey === '') {
|
||||
let newJobs = await fetchJobs(maxTs, undefined, extendedMinTs)
|
||||
// Extend MinTs to fetch jobs mefore minTs and show a correct concurrency graph
|
||||
// TODO: when an ended_at column is created on the completed_job table,
|
||||
// lookback won't be needed anymore (just filter ended_at > minTs instead
|
||||
const extendedMinTs = subtractDaysFromDateString(minTs, lookback)
|
||||
|
||||
if (concurrencyKey == null || concurrencyKey === '') {
|
||||
return CancelablePromiseUtils.map(fetchJobs(maxTs, undefined, extendedMinTs), (newJobs) => {
|
||||
extendedJobs = { jobs: newJobs, obscured_jobs: [] } as ExtendedJobs
|
||||
|
||||
// Filter on minTs here and not in the backend
|
||||
// to get enough data for the concurrency graph
|
||||
jobs = sortMinDate(minTs, newJobs)
|
||||
externalJobs = []
|
||||
} else {
|
||||
extendedJobs = await fetchExtendedJobs(concurrencyKey, maxTs, extendedMinTs)
|
||||
const newJobs = extendedJobs.jobs
|
||||
const newExternalJobs = extendedJobs.obscured_jobs
|
||||
computeCompletedJobs()
|
||||
loading = false
|
||||
})
|
||||
} else {
|
||||
return CancelablePromiseUtils.map(
|
||||
fetchExtendedJobs(concurrencyKey, maxTs, extendedMinTs),
|
||||
(newExtendedJobs) => {
|
||||
extendedJobs = newExtendedJobs
|
||||
const newJobs = newExtendedJobs.jobs
|
||||
const newExternalJobs = newExtendedJobs.obscured_jobs
|
||||
|
||||
// Filter on minTs here and not in the backend
|
||||
// to get enough data for the concurrency graph
|
||||
if (minTs != undefined) {
|
||||
const minDate = new Date(minTs)
|
||||
jobs = newJobs.filter((x) =>
|
||||
x.started_at
|
||||
? new Date(x.started_at) > minDate
|
||||
: x.created_at
|
||||
? new Date(x.created_at) > minDate
|
||||
: false
|
||||
)
|
||||
externalJobs = computeExternalJobs(
|
||||
newExternalJobs.filter((x) => x.started_at && new Date(x.started_at) > minDate)
|
||||
)
|
||||
} else {
|
||||
jobs = newJobs
|
||||
externalJobs = computeExternalJobs(newExternalJobs)
|
||||
// Filter on minTs here and not in the backend
|
||||
// to get enough data for the concurrency graph
|
||||
if (minTs != undefined) {
|
||||
const minDate = new Date(minTs)
|
||||
jobs = newJobs.filter((x) =>
|
||||
x.started_at
|
||||
? new Date(x.started_at) > minDate
|
||||
: x.created_at
|
||||
? new Date(x.created_at) > minDate
|
||||
: false
|
||||
)
|
||||
externalJobs = computeExternalJobs(
|
||||
newExternalJobs.filter((x) => x.started_at && new Date(x.started_at) > minDate)
|
||||
)
|
||||
} else {
|
||||
jobs = newJobs
|
||||
externalJobs = computeExternalJobs(newExternalJobs)
|
||||
}
|
||||
computeCompletedJobs()
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
computeCompletedJobs()
|
||||
} catch (err) {
|
||||
sendUserToast(`There was a problem fetching jobs: ${err}`, true)
|
||||
console.error(JSON.stringify(err))
|
||||
)
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function getCount() {
|
||||
@@ -557,10 +576,11 @@
|
||||
showSchedules,
|
||||
allWorkspaces,
|
||||
argFilter,
|
||||
resultFilter
|
||||
resultFilter,
|
||||
perPage
|
||||
]
|
||||
|
||||
untrack(() => onParamChanges())
|
||||
let p = untrack(() => onParamChanges())
|
||||
return () => p.cancel()
|
||||
})
|
||||
$effect(() => {
|
||||
;[autoRefresh, refreshRate]
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
minTs: string | undefined;
|
||||
maxTs: string | undefined;
|
||||
loading?: boolean;
|
||||
selectedManualDate?: number;
|
||||
loadText?: string | undefined;
|
||||
serviceLogsChoices?: boolean;
|
||||
minTs: string | undefined
|
||||
maxTs: string | undefined
|
||||
loading?: boolean
|
||||
selectedManualDate?: number
|
||||
loadText?: string | undefined
|
||||
serviceLogsChoices?: boolean
|
||||
numberOfLastJobsToFetch?: number
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -18,8 +19,9 @@
|
||||
loading = false,
|
||||
selectedManualDate = $bindable(0),
|
||||
loadText = undefined,
|
||||
serviceLogsChoices = false
|
||||
}: Props = $props();
|
||||
serviceLogsChoices = false,
|
||||
numberOfLastJobsToFetch = 1000
|
||||
}: Props = $props()
|
||||
|
||||
export function computeMinMax(): { minTs: string; maxTs: string | undefined } | undefined {
|
||||
return manualDates[selectedManualDate].computeMinMax()
|
||||
@@ -75,7 +77,7 @@
|
||||
|
||||
let manualDates = $derived([
|
||||
{
|
||||
label: loadText ?? 'Last 1000 runs',
|
||||
label: loadText ?? `Last ${numberOfLastJobsToFetch} runs`,
|
||||
computeMinMax: () => {
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
activeLabel?: string | null
|
||||
// const loadMoreQuantity: number = 100
|
||||
lastFetchWentToEnd?: boolean
|
||||
perPage?: number
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -33,7 +34,8 @@
|
||||
selectedIds = $bindable([]),
|
||||
selectedWorkspace = $bindable(undefined),
|
||||
activeLabel = null,
|
||||
lastFetchWentToEnd = $bindable(false)
|
||||
lastFetchWentToEnd = $bindable(false),
|
||||
perPage = 1000
|
||||
}: Props = $props()
|
||||
|
||||
function getTime(job: Job): string | undefined {
|
||||
@@ -116,7 +118,7 @@
|
||||
let tableHeight: number = $state(0)
|
||||
let headerHeight: number = $state(0)
|
||||
let containerWidth: number = $state(0)
|
||||
// const MAX_ITEMS = 1000
|
||||
// const MAX_ITEMS = perPage
|
||||
|
||||
/*
|
||||
function infiniteHandler({ detail: { loaded, error, complete } }) {
|
||||
@@ -139,7 +141,7 @@
|
||||
return ''
|
||||
}
|
||||
const jc = jobCount
|
||||
const isTruncated = jc >= 1000 && !lastFetchWentToEnd
|
||||
const isTruncated = jc >= perPage && !lastFetchWentToEnd
|
||||
|
||||
return `${jc}${isTruncated ? '+' : ''} job${jc != 1 ? 's' : ''}`
|
||||
}
|
||||
@@ -338,13 +340,15 @@
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
<div
|
||||
>{#if !lastFetchWentToEnd && jobs && jobs.length >= 1000}
|
||||
>{#if !lastFetchWentToEnd && jobs && jobs.length >= perPage}
|
||||
<button
|
||||
class="text-xs text-blue-600 text-center w-full pb-2"
|
||||
class="text-xs text-accent text-center w-full pb-2"
|
||||
onclick={() => {
|
||||
dispatch('loadExtra')
|
||||
}}>Load next 1000 jobs</button
|
||||
}}
|
||||
>
|
||||
Load next {perPage} jobs
|
||||
</button>
|
||||
{/if}</div
|
||||
>
|
||||
{/snippet}
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<script lang="ts" generics="Item extends { label?: string; value: any; subtitle?: string }">
|
||||
<script
|
||||
lang="ts"
|
||||
generics="Item extends { label?: string; value: any; subtitle?: string; disabled?: boolean }"
|
||||
>
|
||||
import { clickOutside } from '$lib/utils'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import CloseButton from '../common/CloseButton.svelte'
|
||||
|
||||
@@ -195,11 +195,12 @@
|
||||
: item.value === value
|
||||
? 'bg-surface-hover/60'
|
||||
: 'hover:bg-surface-hover',
|
||||
itemButtonWrapperClasses
|
||||
itemButtonWrapperClasses,
|
||||
item.disabled ? 'cursor-not-allowed text-disabled' : ''
|
||||
)}
|
||||
onclick={(e) => {
|
||||
e.stopImmediatePropagation()
|
||||
onSelectValue(item)
|
||||
if (!item.disabled) onSelectValue(item)
|
||||
}}
|
||||
>
|
||||
{@render startSnippet?.({ item, close: () => (open = false) })}
|
||||
|
||||
@@ -44,6 +44,7 @@ export type ProcessedItem<T> = {
|
||||
label: string
|
||||
value: T
|
||||
subtitle?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function getLabel<T>(item: { label?: string; value: T } | undefined): string | undefined {
|
||||
@@ -51,7 +52,7 @@ export function getLabel<T>(item: { label?: string; value: T } | undefined): str
|
||||
if (item.label) return item.label
|
||||
if (typeof item.value === 'string') return item.value
|
||||
if (typeof item.value == 'number' || typeof item.value == 'boolean') return item.value.toString()
|
||||
if (item.value == null) { return undefined }
|
||||
if (item.value == null) return undefined
|
||||
return JSON.stringify(item.value)
|
||||
}
|
||||
|
||||
|
||||
@@ -18,9 +18,10 @@
|
||||
import AuditLogsTimeline from '$lib/components/auditLogs/AuditLogsTimeline.svelte'
|
||||
|
||||
let username: string = $state(page.url.searchParams.get('username') ?? 'all')
|
||||
let pageIndex: number | undefined = $state(Number(page.url.searchParams.get('page')) || 0)
|
||||
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')
|
||||
@@ -107,6 +108,7 @@
|
||||
bind:perPage
|
||||
bind:scope
|
||||
bind:hasMore
|
||||
bind:loading
|
||||
/>
|
||||
</div>
|
||||
<div class="2xl:hidden">
|
||||
@@ -161,6 +163,7 @@
|
||||
<Pane size={70} minSize={50}>
|
||||
{#if logs}
|
||||
<AuditLogsTable
|
||||
{loading}
|
||||
{logs}
|
||||
{selectedId}
|
||||
bind:pageIndex
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user