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:
Diego Imbert
2025-11-13 10:51:15 +01:00
committed by GitHub
parent f0644611e6
commit 83055148f4
16 changed files with 1769 additions and 1594 deletions
+4 -4
View File
@@ -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"
+1 -1
View File
@@ -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
}
}
+10 -7
View File
@@ -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'}
+157 -137
View File
@@ -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