Refactor runs page search params state logic (#7543)

* start refactoring runs page query params state

* more refactor

* more migration

* more refactor

* per_page migrate

* refactor

* char consistency layout

* runs filter wrong logic

* fix autosetFilter

* nit remove Default Email filter

* Revert "nit remove Default Email filter"

This reverts commit b46b3872a9.

* nit

* arg nits

* fix filters.arg reactivity

* RunsFilter pass 1

* Refactor JobsLoader into useJobsLoader

* fix lastFetchWentToEnd

* update claude.md

* debounce to avoid flicker

* debounce

* callback mistake

* missing return

* change to null

* dead code file (SavedInputs was replaced with V2)

* arg and result filter nits

* better flicker prevention

* avoid duplicates when loading more jobs

* fix broken type

* nit type

* improve date filter state mgmt + encode URI component on all params
This commit is contained in:
Diego Imbert
2026-01-15 08:20:04 +01:00
committed by GitHub
parent b59ef3efc6
commit cebb47ea20
17 changed files with 646 additions and 1413 deletions
+69
View File
@@ -7,6 +7,75 @@
- Keep components small and focused
- Always use keys in {#each} blocks
## Data Flow and State Management
### Prefer Unidirectional Data Flow with Composable State
When you can use unidirectional data flow with composable state, prefer that over two-way binding between components. Two-way binding between components creates confusing data flow - as the codebase grows, nothing guarantees that bound state won't be updated from multiple locations, leading to bugs and maintenance issues.
**❌ AVOID: Two-way binding when composable state would work**
```svelte
<Loader bind:loading bind:items {args} />
```
**✅ PREFER: Unidirectional data flow with composables**
```typescript
// loader.svelte.ts
function useLoader(argsGetter: () => Args) {
let args = $derived(argsGetter())
let items = $state([])
let loading = $state(false)
$effect(() => {
// Logic reactive to args changes
})
return {
get loading() { return loading },
get items() { return items }
}
}
// Component.svelte
<script>
let loader = useLoader(() => args)
let loading = $derived(loader.loading)
let items = $derived(loader.items)
</script>
```
This pattern ensures:
- State responsibility is clearly owned by `useLoader`
- Data flows in one direction (parent → child)
- No ambiguity about where state can be modified
- Better maintainability as the codebase scales
### Async Data Fetching with Runed
For async requests, **always use `resource()` from the Runed library** instead of manual state management:
```typescript
import { resource } from 'runed'
let items = resource(() => args, (args) => YourService.route(args))
// Access loading state
items.loading
// Access data
items.current
```
The `resource()` utility:
- Automatically handles loading states
- Manages async lifecycle
- Provides reactive updates when dependencies change
- Eliminates boilerplate for common async patterns
**Key Takeaway**: Prefer unidirectional data flow with composables over two-way binding between components. Two-way binding is acceptable for simple form inputs, but avoid it when composable state patterns can provide clearer state ownership.
## UI Guidelines
### Styling Guidelines
@@ -19,16 +19,16 @@
interface Props {
extendedJobs?: ExtendedJobs | undefined
maxIsNow?: boolean
minTimeSet?: string | undefined
maxTimeSet?: string | undefined
minTimeSet?: string | null
maxTimeSet?: string | null
onZoom: (zoom: { min: Date; max: Date }) => void
}
let {
extendedJobs = undefined,
maxIsNow = false,
minTimeSet = undefined,
maxTimeSet = undefined,
minTimeSet = null,
maxTimeSet = null,
onZoom
}: Props = $props()
@@ -157,8 +157,8 @@
}
function computeMinMaxTime(
intervals: AggregatedInterval[] | undefined,
minTimeSet: string | undefined,
maxTimeSet: string | undefined
minTimeSet: string | null,
maxTimeSet: string | null
) {
let minTimeSetDate = minTimeSet ? new Date(minTimeSet) : undefined
let maxTimeSetDate = maxTimeSet ? new Date(maxTimeSet) : undefined
@@ -235,7 +235,8 @@
display: false
},
min: minMaxTimes.min,
max: minMaxTimes.max
max: minMaxTimes.max,
ticks: { maxRotation: 0, minRotation: 0 }
},
y: {
grid: {
@@ -1,17 +1,17 @@
<script lang="ts">
import { type RunnableType, type Job } from '$lib/gen/index.js'
import { type RunnableType } from '$lib/gen/index.js'
import { sendUserToast } from '$lib/utils.js'
import RunningJobSchemaPicker from '$lib/components/schema/RunningJobSchemaPicker.svelte'
import { createEventDispatcher, onDestroy } from 'svelte'
import JobsLoader from './runs/JobsLoader.svelte'
import { useJobsLoader, type UseJobLoaderArgs } from './runs/useJobsLoader.svelte'
import { DataTable } from '$lib/components/table'
import HistoricList from './HistoricList.svelte'
import { Loader2 } from 'lucide-svelte'
import { workspaceStore } from '$lib/stores'
interface Props {
runnableId?: string | undefined
runnableType?: RunnableType | undefined
loading?: boolean
selected?: string | undefined
showAuthor?: boolean
placement?: 'bottom-start' | 'top-start' | 'bottom-end' | 'top-end'
@@ -22,7 +22,6 @@
let {
runnableId = undefined,
runnableType = undefined,
loading = $bindable(false),
selected = $bindable(undefined),
showAuthor = false,
placement = 'top-end',
@@ -33,7 +32,6 @@
let historicList: HistoricList | undefined = $state(undefined)
const dispatch = createEventDispatcher()
let jobs: Job[] = $state([])
let hasMoreCurrentRuns = false
let page = $state(1)
@@ -54,6 +52,10 @@
}
}
export function loading(): boolean {
return jobsLoader?.loading ?? false
}
onDestroy(() => {
resetSelected(false)
})
@@ -95,40 +97,39 @@
}
return ''
}
let jobsLoader = useJobsLoader(
() =>
({
filters: {
show_skipped: false,
path: runnableId,
success: 'running',
arg: searchArgs ? JSON.stringify(searchArgs) : '',
per_page: 5
},
jobKinds: getJobKinds(runnableType),
syncQueuedRunsCount: false,
refreshRate: 10000,
computeMinAndMax: undefined,
currentWorkspace: $workspaceStore ?? '',
skip: !runnableId
}) satisfies UseJobLoaderArgs
)
let jobs = $derived(jobsLoader?.jobs ?? [])
</script>
<svelte:window onkeydown={handleKeydown} />
{#if runnableId}
<JobsLoader
bind:jobs
path={runnableId}
showSkipped={false}
jobKinds={getJobKinds(runnableType)}
user={null}
label={null}
folder={null}
concurrencyKey={null}
tag={null}
success="running"
argFilter={searchArgs ? JSON.stringify(searchArgs) : undefined}
bind:loading
syncQueuedRunsCount={false}
refreshRate={10000}
computeMinAndMax={undefined}
perPage={5}
/>
{/if}
<div class="h-full max-h-full min-h-0 w-full flex flex-col gap-2 relative">
<div class="grow-0" data-schema-picker>
<DataTable size="xs" bind:currentPage={page} hasMore={hasMoreCurrentRuns} tableFixed={true}>
{#if loading}
{#if jobsLoader?.loading}
<div class="text-primary absolute top-2 right-2">
<Loader2 class="animate-spin" size={14} />
</div>
{/if}
{#if jobs?.length > 0}
{#if jobs.length > 0}
<colgroup>
<col class="w-8" />
<col class="w-16" />
@@ -144,7 +145,7 @@
on:select={(e) => handleSelected(e.detail)}
/>
{/each}
{#if jobs?.length == 5}
{#if jobs.length == 5}
<tr class="text-left text-primary text-xs w-full">
<td class="w-full px-2" colspan="3">limited to 5 runs</td>
</tr>
+17 -12
View File
@@ -12,7 +12,8 @@
LinearScale,
PointElement,
TimeScale,
LogarithmicScale
LogarithmicScale,
type ChartOptions
} from 'chart.js'
import type { CompletedJob } from '$lib/gen'
import { getDbClockNow } from '$lib/forLater'
@@ -23,8 +24,8 @@
interface Props {
jobs?: CompletedJob[] | undefined
maxIsNow?: boolean
minTimeSet?: string | undefined
maxTimeSet?: string | undefined
minTimeSet?: string | null
maxTimeSet?: string | null
selectedIds?: string[]
canSelect?: boolean
lastFetchWentToEnd?: boolean
@@ -37,8 +38,8 @@
let {
jobs = [],
maxIsNow = false,
minTimeSet = undefined,
maxTimeSet = undefined,
minTimeSet = null,
maxTimeSet = null,
selectedIds = $bindable([]),
canSelect = true,
lastFetchWentToEnd = false,
@@ -163,8 +164,8 @@
function computeMinMaxTime(
jobs: CompletedJob[] | undefined,
minTimeSet: string | undefined,
maxTimeSet: string | undefined
minTimeSet: string | null,
maxTimeSet: string | null
) {
let minTimeSetDate = minTimeSet ? new Date(minTimeSet) : undefined
let maxTimeSetDate = maxTimeSet ? new Date(maxTimeSet) : undefined
@@ -244,7 +245,7 @@
const minMaxTime = $derived.by(() => computeMinMaxTime(jobs, minTimeSet, maxTimeSet))
let scatterOptions = $derived({
let scatterOptions: ChartOptions<'scatter'> = $derived({
responsive: true,
maintainAspectRatio: false,
plugins: {
@@ -274,8 +275,9 @@
grid: {
display: false
},
min: minMaxTime.minTime,
max: minMaxTime.maxTime
min: minMaxTime.minTime.getTime(),
max: minMaxTime.maxTime.getTime(),
ticks: { maxRotation: 0, minRotation: 0 }
},
y: {
grid: {
@@ -285,11 +287,14 @@
display: true,
text: 'job duration (ms)'
},
type: 'logarithmic'
type: 'logarithmic',
afterFit: function (axis) {
axis.width = Math.max(axis.width, 65) // min width to prevent layout flickering
}
}
},
animation: false
} as any)
})
</script>
<DarkModeObserver bind:darkMode />
File diff suppressed because it is too large Load Diff
@@ -1,460 +0,0 @@
<script lang="ts">
import { Button } from '$lib/components/common'
import {
InputService,
type Input,
type RunnableType,
type Job,
JobService
} from '$lib/gen/index.js'
import { userStore, workspaceStore } from '$lib/stores.js'
import { base } from '$lib/base'
import { classNames, displayDateOnly, sendUserToast } from '$lib/utils.js'
import { createEventDispatcher, untrack } from 'svelte'
import { Pane, Splitpanes } from 'svelte-splitpanes'
import ObjectViewer from './propertyPicker/ObjectViewer.svelte'
import { ArrowLeftIcon, Edit, ExternalLink, X } from 'lucide-svelte'
import Toggle from './Toggle.svelte'
import Tooltip from './Tooltip.svelte'
import TimeAgo from './TimeAgo.svelte'
import JobsLoader from './runs/JobsLoader.svelte'
import Skeleton from './common/skeleton/Skeleton.svelte'
import SaveInputsButton from './SaveInputsButton.svelte'
interface Props {
scriptHash?: string | null
scriptPath?: string | null
flowPath?: string | null
canSaveInputs?: boolean
// Are the current Inputs valid and able to be saved?
isValid: boolean
args: object
}
let {
scriptHash = null,
scriptPath = null,
flowPath = null,
canSaveInputs = true,
isValid,
args
}: Props = $props()
interface EditableInput extends Input {
isEditing?: boolean
isSaving?: boolean
}
let previousInputs: Input[] | undefined = $state(undefined)
let savedInputs: EditableInput[] | undefined = $state(undefined)
let selectedInput = $state() as Input | null
let jobs: Job[] = $state([])
let loading: boolean = $state(false)
const dispatch = createEventDispatcher()
let runnableId = $derived(scriptHash || scriptPath || flowPath || undefined)
let runnableType: RunnableType | undefined = $derived(
scriptHash ? 'ScriptHash' : scriptPath ? 'ScriptPath' : flowPath ? 'FlowPath' : undefined
)
let hasAlreadyFailed = false
async function loadInputHistory() {
try {
previousInputs = await InputService.getInputHistory({
workspace: $workspaceStore!,
runnableId,
runnableType,
perPage: 10
})
} catch (e) {
console.error(e)
if (hasAlreadyFailed) return
hasAlreadyFailed = true
sendUserToast(`Failed to load input history: ${e}`, true)
}
}
async function loadSavedInputs() {
savedInputs = await InputService.listInputs({
workspace: $workspaceStore!,
runnableId,
runnableType,
perPage: 10
})
}
async function updateInput(input: EditableInput) {
input.isSaving = true
try {
await InputService.updateInput({
workspace: $workspaceStore!,
requestBody: {
id: input.id,
name: input.name,
is_public: input.is_public
}
})
} catch (err) {
console.error(err)
sendUserToast(`Failed to update Input: ${err}`, true)
}
input.isSaving = false
}
async function deleteInput(input: Input) {
try {
await InputService.deleteInput({
workspace: $workspaceStore!,
input: input.id
})
savedInputs = (savedInputs ?? []).filter((i) => i.id !== input.id)
if (selectedInput === input) {
selectedInput = null
}
} catch (err) {
console.error(err)
sendUserToast(`Failed to delete Input: ${err}`, true)
}
}
$effect(() => {
if ($workspaceStore && (scriptHash || scriptPath || flowPath)) {
untrack(() => {
loadInputHistory()
loadSavedInputs()
})
}
})
let previewArgs: any = $state(undefined)
function selectArgs(selected_args: any) {
previewArgs = selected_args
}
async function loadLargeArgs(
id: string | undefined,
input: boolean | undefined,
allowLarge: boolean
): Promise<any> {
if (!id) return
return await InputService.getArgsFromHistoryOrSavedInput({
jobOrInputId: id,
workspace: $workspaceStore!,
input,
allowLarge
})
}
</script>
{#if runnableId}
<JobsLoader
bind:jobs
path={runnableId}
showSkipped={false}
jobKindsCat="all"
user={null}
label={null}
folder={null}
concurrencyKey={null}
tag={null}
success="running"
argFilter={undefined}
bind:loading
syncQueuedRunsCount={false}
refreshRate={10000}
computeMinAndMax={undefined}
perPage={5}
/>
{/if}
<div class="min-w-[300px] h-full">
<Splitpanes horizontal={true}>
<Pane>
<div class="w-full flex flex-col gap-4 p-2">
<div class="w-full flex justify-between items-center gap-4 flex-wrap">
<span class="text-sm font-semibold flex-shrink-0"
>Saved Inputs<Tooltip
>Shared inputs are available to anyone with access to the script</Tooltip
></span
>
{#if canSaveInputs}
<SaveInputsButton
{args}
disabled={!isValid}
{runnableId}
{runnableType}
on:update={() => {
loadSavedInputs()
}}
/>
{/if}
</div>
<div class="w-full flex flex-col gap-1 h-full overflow-y-auto p">
{#if savedInputs === undefined}
<Skeleton layout={[[8]]} />
{:else if savedInputs?.length > 0}
{#each savedInputs as i}
<button
class={classNames(
`w-full flex items-center text-sm group justify-between gap-4 py-1.5 px-4 text-left border rounded-sm hover:bg-surface-hover transition-all`,
selectedInput === i ? 'border-blue-500 bg-blue-50 dark:bg-blue-900' : ''
)}
onclick={async () => {
if (!i.isEditing) {
if (selectedInput === i) {
selectedInput = null
} else {
selectedInput = i
}
}
selectArgs(await loadLargeArgs(i.id, true, false))
}}
>
<div class="w-full h-full items-center justify-between flex gap-1 min-w-0">
{#if i.isEditing}
<form
onsubmit={() => {
updateInput(i)
i.isEditing = false
i.isSaving = false
}}
class="w-full"
>
<input type="text" bind:value={i.name} class="text-secondary" />
</form>
{:else}
<small
class="whitespace-nowrap overflow-hidden text-ellipsis flex-shrink text-left"
>
{i.name}
</small>
{/if}
{#if i.created_by == $userStore?.username || $userStore?.is_admin || $userStore?.is_super_admin}
<div class="items-center flex gap-2">
{#if !i.isEditing}
<div class="group-hover:block hidden -my-2">
<Toggle
size="xs"
options={{ right: 'shared' }}
bind:checked={i.is_public}
on:change={() => {
updateInput(i)
}}
/>
</div>
{/if}
<Button
loading={i.isSaving}
size="xs"
variant="default"
spacingSize="xs2"
btnClasses={'group-hover:block hidden -my-2'}
on:click={(e) => {
e.stopPropagation()
i.isEditing = !i.isEditing
if (!i.isEditing) {
updateInput(i)
i.isSaving = false
}
}}
>
<Edit class="w-4 h-4" />
</Button>
<Button
size="xs"
spacingSize="xs2"
variant="default"
btnClasses={i.isEditing ? 'block' : 'group-hover:block hidden -my-2'}
on:click={() => deleteInput(i)}
>
<X class="w-4 h-4" />
</Button>
</div>
{:else}
<span class="text-xs text-primary">By {i.created_by}</span>
{/if}
</div>
</button>
{/each}
{:else}
<div class="text-center text-primary">No saved Inputs</div>
{/if}
</div>
</div>
</Pane>
<Pane>
<div class="w-full flex flex-col gap-4 p-2">
<span class="text-sm font-semibold">Previous runs</span>
<div class="w-full flex flex-col gap-1 p-0 h-full overflow-y-auto">
{#if loading && (jobs == undefined || jobs?.length == 0)}
<div class="text-left text-primary text-xs">Loading current runs...</div>
{:else if jobs?.length > 0}
{#each jobs as i (i.id)}
<button
class={classNames(
`w-full flex items-center justify-between gap-4 py-2 px-4 text-left border rounded-sm hover:bg-surface-hover transition-a`,
'border-orange-400'
)}
onclick={async () => {
if (!$workspaceStore) {
return
}
const args = await JobService.getJobArgs({
workspace: $workspaceStore,
id: i.id
})
selectedInput = {
id: i.id,
name: 'Running job: ' + i.id,
created_at: i.created_at ?? '',
created_by: i.created_by ?? '',
is_public: true
}
selectArgs(args)
}}
>
<div
class="w-full h-full items-center text-xs font-normal grid grid-cols-8 gap-4 min-w-0"
>
<div class="">
<div class="rounded-full w-2 h-2 bg-orange-400 animate-pulse"></div>
</div>
<div class="col-span-2 truncate">
{i.created_by}
</div>
<div
class="whitespace-nowrap col-span-3 !text-primary !text-2xs overflow-hidden text-ellipsis flex-shrink text-center"
>
<TimeAgo noDate date={i.started_at ?? ''} />
</div>
<div class="col-span-2">
<a
target="_blank"
href="{base}/run/{i.id}?workspace={$workspaceStore}"
class="text-right float-right text-secondary"
title="See run detail in a new tab"
>
<ExternalLink size={16} />
</a>
</div>
</div>
</button>
{/each}
{#if jobs?.length == 5}
<div class="text-left text-primary text-xs"
>... there may be more runs not displayed here as the limit is 5</div
>
{/if}
{:else}
<div class="text-left text-primary text-xs">No job currently running</div>
{/if}
</div>
<div class="w-full flex flex-col gap-1 p-0 h-full overflow-y-auto">
{#if previousInputs === undefined}
<Skeleton layout={[[8]]} />
{:else if previousInputs?.length > 0}
{#each previousInputs as i (i.id)}
<button
class={classNames(
`w-full flex items-center justify-between gap-4 py-2 px-4 text-left border rounded-sm hover:bg-surface-hover transition-a`,
selectedInput === i ? 'border-blue-500 bg-blue-50 dark:bg-blue-900' : ''
)}
onclick={async () => {
if (selectedInput === i) {
selectedInput = null
} else {
selectedInput = i
}
selectArgs(await loadLargeArgs(i.id, false, false))
}}
>
<div
class="w-full h-full items-center text-xs font-normal grid grid-cols-8 gap-4 min-w-0"
>
<div class="">
<div class="rounded-full w-2 h-2 {i.success ? 'bg-green-400' : 'bg-red-400'}"
></div>
</div>
<div class="col-span-2 truncate" title={i.created_by}>
{i.created_by}
</div>
<div
class="whitespace-nowrap col-span-2 !text-primary !text-2xs overflow-hidden text-ellipsis flex-shrink text-center"
>
{displayDateOnly(new Date(i.created_at))}
</div>
<div
class="whitespace-nowrap col-span-2 !text-primary !text-2xs overflow-hidden text-ellipsis flex-shrink text-center"
>
<TimeAgo noDate date={i.created_at ?? ''} />
</div>
<div class="col-span-1">
<a
target="_blank"
href="{base}/run/{i.id}?workspace={$workspaceStore}"
class="text-right float-right text-secondary"
title="See run detail in a new tab"
>
<ExternalLink size={16} />
</a>
</div>
</div>
</button>
{/each}
{:else}
<div class="text-center text-primary">No previous Runs</div>
{/if}
</div>
</div>
</Pane>
<Pane>
<div class="h-full overflow-hidden min-h-0 flex flex-col justify-between">
<div class="w-full flex flex-col min-h-0 gap-2 px-2 py-2 grow">
<div class="w-full flex flex-col">
<Button
color="blue"
btnClasses="w-full"
size="sm"
spacingSize="xl"
on:click={async () => {
dispatch('selected_args', await loadLargeArgs(selectedInput?.id, undefined, false))
}}
disabled={!selectedInput}
>
<ArrowLeftIcon class="w-4 h-4 mr-2" />
Use input
</Button>
</div>
<div class="w-full min-h-0 grow overflow-auto">
{#if typeof previewArgs == 'string' && previewArgs == 'WINDMILL_TOO_BIG'}
<div class="text-secondary mt-2">
Payload too big to preview but can still be loaded</div
>
{:else if Object.keys(previewArgs || {}).length > 0}
<div class=" overflow-auto h-full p-2">
<ObjectViewer json={previewArgs} />
</div>
{:else}
<div class="text-center text-primary">
Select an Input to preview scripts arguments
</div>
{/if}
</div>
</div>
</div></Pane
>
</Splitpanes>
</div>
@@ -83,7 +83,6 @@
let rightPanelOpen = false
let savedInputsPicker: SavedInputsPicker | undefined = undefined
let loading = false
let captureTable: CaptureTable | undefined = undefined
let historicInputs: HistoricInputs | undefined = undefined
$: (selectedTab, (dropdownItems = getDropdownItems()))
@@ -111,11 +110,13 @@
<FlowInputEditor title="History">
<svelete:fragment slot="action">
<div class="center-center">
<RefreshButton {loading} onClick={() => historicInputs?.refresh()} />
<RefreshButton
loading={historicInputs?.loading() ?? false}
onClick={() => historicInputs?.refresh()}
/>
</div>
</svelete:fragment>
<HistoricInputs
bind:loading
bind:this={historicInputs}
{runnableId}
{runnableType}
@@ -153,8 +153,8 @@
if (searchTerm !== '') return
let minMax = manualPicker?.computeMinMax()
if (minMax) {
maxTsManual = minMax?.maxTs
minTsManual = minMax?.minTs
maxTsManual = minMax?.maxTs ?? undefined
minTsManual = minMax?.minTs ?? undefined
}
let maxTsPlus1 = maxTs ? new Date(new Date(maxTs).getTime() + 1000) : undefined
getAllLogs(maxTsPlus1?.toISOString(), undefined)
@@ -504,8 +504,8 @@
/>
</div>
<ManuelDatePicker
bind:minTs={minTsManual}
bind:maxTs={maxTsManual}
bind:minTs={() => minTsManual ?? null, (v) => (minTsManual = v ?? undefined)}
bind:maxTs={() => maxTsManual ?? null, (v) => (maxTsManual = v ?? undefined)}
bind:this={manualPicker}
{loading}
on:loadJobs={() => {
@@ -6,7 +6,7 @@
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
import { twMerge } from 'tailwind-merge'
export let date: string | undefined
export let date: string | null | undefined
export let label: string
export let useDropdown: boolean = false
export let clearable: boolean = false
@@ -4,8 +4,8 @@
import { createEventDispatcher } from 'svelte'
interface Props {
minTs: string | undefined
maxTs: string | undefined
minTs: string | null
maxTs: string | null
loading?: boolean
selectedManualDate?: number
loadText?: string | undefined
@@ -23,7 +23,7 @@
numberOfLastJobsToFetch = 1000
}: Props = $props()
export function computeMinMax(): { minTs: string; maxTs: string | undefined } | undefined {
export function computeMinMax(): { minTs: string; maxTs: string | null } | undefined {
return manualDates[selectedManualDate].computeMinMax()
}
@@ -33,13 +33,13 @@
function computeMinMaxInc(inc: number) {
let minTs = new Date(new Date().getTime() - inc).toISOString()
let maxTs = undefined
let maxTs = null
return { minTs, maxTs }
}
const fixedManualDates: {
label: string
computeMinMax: () => { minTs: string; maxTs: string | undefined } | undefined
computeMinMax: () => { minTs: string; maxTs: string | null } | undefined
}[] = [
...(!serviceLogsChoices
? [
@@ -110,8 +110,8 @@
minTs = ts.minTs
maxTs = ts.maxTs
} else {
minTs = undefined
maxTs = undefined
minTs = null
maxTs = null
}
dispatch('loadJobs')
}
@@ -1,3 +1,37 @@
<script module lang="ts">
export const runsFiltersSchema = z.object({
path: z.string().nullable().default(null),
worker: z.string().nullable().default(null),
user: z.string().nullable().default(null),
folder: z.string().nullable().default(null),
label: z.string().nullable().default(null),
concurrency_key: z.string().nullable().default(null),
tag: z.string().nullable().default(null),
allow_wildcards: z.boolean().default(false),
show_future_jobs: z.boolean().default(true),
success: z
.enum(['running', 'suspended', 'waiting', 'success', 'failure'])
.nullable()
.default(null),
show_skipped: z.boolean().default(false),
show_schedules: z.boolean().default(true),
min_ts: z.string().nullable().default(null),
max_ts: z.string().nullable().default(null),
schedule_path: z.string().nullable().default(null),
job_kinds: z.string().default('runs'),
all_workspaces: z.boolean().default(false),
arg: z.string().default(''),
result: z.string().default(''),
job_trigger_kind: z
.string()
.transform((s) => s as JobTriggerKind)
.nullable()
.default(null),
per_page: z.number().default(1000)
})
export type RunsFilters = z.infer<typeof runsFiltersSchema>
</script>
<script lang="ts">
import { Button } from '../common'
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
@@ -8,7 +42,7 @@
import Label from '../Label.svelte'
import Section from '../Section.svelte'
import { enterpriseLicense, workspaceStore } from '$lib/stores'
import { createEventDispatcher, untrack } from 'svelte'
import { createEventDispatcher } from 'svelte'
import ToggleButtonMore from '../common/toggleButton-v2/ToggleButtonMore.svelte'
import Popover from '$lib/components/meltComponents/Popover.svelte'
import Select from '../select/Select.svelte'
@@ -19,6 +53,8 @@
import TextInput from '../text_input/TextInput.svelte'
import { jobTriggerKinds, triggerDisplayNamesMap } from '../triggers/utils'
import type { JobTriggerKind } from '$lib/gen'
import { Debounced, watch } from 'runed'
import z from 'zod'
interface Props {
// Filters
@@ -27,18 +63,18 @@
concurrencyKey?: string | null
worker?: string | null
tag?: string | null
success?: 'running' | 'waiting' | 'suspended' | 'queued' | 'success' | 'failure' | undefined
success?: 'running' | 'waiting' | 'suspended' | 'queued' | 'success' | 'failure' | null
showSkipped?: boolean | undefined
argFilter: string
argError: string
resultFilter: string
jobTriggerKind: JobTriggerKind | undefined
jobTriggerKind: JobTriggerKind | null
resultError: string
jobKindsCat: string
user?: string | null
folder?: string | null
mobile?: boolean
schedulePath: string | undefined
schedulePath: string | null
allowWildcards?: boolean
// Autocomplete data
paths?: string[]
@@ -59,74 +95,81 @@
}
let {
path = $bindable(null),
label = $bindable(null),
concurrencyKey = $bindable(null),
worker = $bindable(null),
tag = $bindable(null),
success = $bindable(undefined),
showSkipped = $bindable(undefined),
path = $bindable(),
label = $bindable(),
concurrencyKey = $bindable(),
worker = $bindable(),
tag = $bindable(),
success = $bindable(),
showSkipped = $bindable(),
argFilter = $bindable(),
argError = $bindable(),
resultFilter = $bindable(),
jobTriggerKind = $bindable(),
resultError = $bindable(),
jobKindsCat = $bindable(),
user = $bindable(null),
folder = $bindable(null),
user = $bindable(),
folder = $bindable(),
mobile = false,
schedulePath = $bindable(),
allowWildcards = $bindable(false),
allowWildcards = $bindable(),
paths = [],
usernames = [],
folders = [],
allWorkspaces = $bindable(false),
filterBy = $bindable('path'),
allWorkspaces = $bindable(),
filterBy = $bindable(),
small = false,
calendarSmall = false
}: Props = $props()
let copyArgFilter = $state(argFilter)
let copyResultFilter = $state(resultFilter)
const dispatch = createEventDispatcher()
function autosetFilter() {
if (path !== null && path !== '' && filterBy !== 'path') {
if (path && filterBy !== 'path') {
filterBy = 'path'
} else if (user !== null && user !== '' && filterBy !== 'user') {
} else if (user && filterBy !== 'user') {
filterBy = 'user'
} else if (folder !== null && folder !== '' && filterBy !== 'folder') {
} else if (folder && filterBy !== 'folder') {
filterBy = 'folder'
} else if (label !== null && label !== '' && filterBy !== 'label') {
} else if (label && filterBy !== 'label') {
filterBy = 'label'
} else if (concurrencyKey !== null && concurrencyKey !== '' && filterBy !== 'concurrencyKey') {
} else if (concurrencyKey && filterBy !== 'concurrencyKey') {
filterBy = 'concurrencyKey'
} else if (tag !== null && tag !== '' && filterBy !== 'tag') {
} else if (tag && filterBy !== 'tag') {
filterBy = 'tag'
} else if (schedulePath !== undefined && schedulePath !== '' && filterBy !== 'schedulePath') {
} else if (schedulePath && filterBy !== 'schedulePath') {
filterBy = 'schedulePath'
} else if (worker !== null && worker !== '' && filterBy !== 'worker') {
} else if (worker && filterBy !== 'worker') {
filterBy = 'worker'
}
}
let labelTimeout: number | undefined = $state(undefined)
let concurrencyKeyTimeout: number | undefined = $state(undefined)
let tagTimeout: number | undefined = $state(undefined)
let workerTimeout: number | undefined = $state(undefined)
let labelTimeout: ReturnType<typeof setInterval> | undefined = $state()
let concurrencyKeyTimeout: ReturnType<typeof setInterval> | undefined = $state(undefined)
let tagTimeout: ReturnType<typeof setInterval> | undefined = $state(undefined)
let workerTimeout: ReturnType<typeof setInterval> | undefined = $state(undefined)
let allWorkspacesValue = $state(allWorkspaces ? 'all' : 'admins')
let displayedLabel = $derived(label)
let displayedConcurrencyKey = $derived(concurrencyKey)
let displayedTag = $derived(tag)
let displayedSchedule = $derived(schedulePath)
let displayedSchedule = $derived(schedulePath ?? undefined)
let displayedWorker = $derived(worker)
$effect(() => {
;(path || user || folder || label || worker || concurrencyKey || tag || schedulePath) &&
untrack(() => autosetFilter())
watch([() => [path, user, folder, label, worker, concurrencyKey, tag, schedulePath]], () => {
autosetFilter()
})
let [copyArgFilter, copyResultFilter] = $state([argFilter, resultFilter])
let debouncedArgAndResultFilters = new Debounced(() => [copyArgFilter, copyResultFilter], 500)
watch(
() => debouncedArgAndResultFilters.current,
() => {
if (!argError) argFilter = copyArgFilter || ''
if (!resultError) resultFilter = copyResultFilter || ''
}
)
function resetFilter() {
path = null
user = null
@@ -134,7 +177,7 @@
label = null
concurrencyKey = null
tag = null
schedulePath = undefined
schedulePath = null
worker = null
}
</script>
@@ -407,7 +450,7 @@
<button
class="absolute top-2 right-2 z-50"
onclick={() => {
schedulePath = undefined
schedulePath = null
dispatch('reset')
}}
>
@@ -426,7 +469,7 @@
}
tagTimeout = setTimeout(() => {
schedulePath = displayedSchedule
schedulePath = displayedSchedule ?? null
}, 1000)
},
id: 'schedulePath'
@@ -580,7 +623,7 @@
<ToggleButtonGroup
selected={success ?? 'all'}
on:selected={({ detail }) => {
success = detail === 'all' ? undefined : detail
success = detail === 'all' ? null : detail
dispatch('successChange', success)
}}
id="status"
@@ -687,7 +730,7 @@
label = null
concurrencyKey = null
tag = null
schedulePath = undefined
schedulePath = null
}
}}
>
@@ -924,7 +967,7 @@
<Label label="Status">
<ToggleButtonGroup
selected={success ?? 'all'}
on:selected={({ detail }) => (success = detail === 'all' ? undefined : detail)}
on:selected={({ detail }) => (success = detail === 'all' ? null : detail)}
>
{#snippet children({ item })}
<ToggleButton value={'all'} label="All" {item} />
@@ -969,49 +1012,23 @@
label: triggerDisplayNamesMap[value],
value
}))}
bind:value={jobTriggerKind}
bind:value={() => jobTriggerKind ?? undefined, (v) => (jobTriggerKind = v ?? null)}
clearable
/>
</Label>
<div class="flex flex-col gap-6">
<Label label="Filter by args">
<span class="text-2xs text-secondary">
{`Filter by a json being a subset of the args/result. Try '\{"foo": "bar"\}'`}
</span>
<JsonEditor bind:error={argError} bind:code={copyArgFilter} />
</Label>
<Label label="Filter by result">
<span class="text-2xs text-secondary">
{`Filter by a json being a subset of the args/result. Try '\{"foo": "bar"\}'`}
</span>
<JsonEditor bind:error={resultError} bind:code={copyResultFilter} />
</Label>
</div>
<div class="flex flex-row gap-2 justify-between">
<Button
unifiedSize="md"
variant="default"
on:click={() => {
argFilter = ''
resultFilter = ''
}}
>
Clear
</Button>
<Button
unifiedSize="md"
variant="accent"
on:click={() => {
argFilter = copyArgFilter
resultFilter = copyResultFilter
}}
>
Set args/result filter
</Button>
</div>
<Label label="Filter by args">
<span class="text-2xs text-secondary">
{`Filter by a json being a subset of the args/result. Try '\{"foo": "bar"\}'`}
</span>
<JsonEditor bind:error={argError} bind:code={copyArgFilter} />
</Label>
<Label label="Filter by result">
<span class="text-2xs text-secondary">
{`Filter by a json being a subset of the args/result. Try '\{"foo": "bar"\}'`}
</span>
<JsonEditor bind:error={resultError} bind:code={copyResultFilter} />
</Label>
</div>
</Section>
{/snippet}
@@ -8,7 +8,7 @@
interface Props {
queue_count?: Tweened<number> | undefined
suspended_count?: Tweened<number> | undefined
success: string | undefined
success: string | null
small?: boolean
onJobsWaiting?: () => void
onJobsSuspended?: () => void
@@ -34,7 +34,7 @@
selectedIds = $bindable([]),
selectedWorkspace = $bindable(undefined),
activeLabel = null,
lastFetchWentToEnd = $bindable(false),
lastFetchWentToEnd = false,
perPage = 1000
}: Props = $props()
@@ -1,102 +1,114 @@
<script lang="ts">
import { onDestroy, onMount, untrack } from 'svelte'
import {
JobService,
type Job,
type CompletedJob,
type ExtendedJobs,
ConcurrencyGroupsService,
type ObscuredJob,
CancelablePromise,
CancelError,
type JobTriggerKind
} from '$lib/gen'
import { onDestroy, onMount, untrack } from 'svelte'
import {
JobService,
type Job,
type CompletedJob,
type ExtendedJobs,
ConcurrencyGroupsService,
type ObscuredJob,
CancelablePromise,
CancelError
} from '$lib/gen'
import { sendUserToast } from '$lib/toast'
import { workspaceStore } from '$lib/stores'
import { sendUserToast } from '$lib/toast'
import { tweened, type Tweened } from 'svelte/motion'
import { subtractDaysFromDateString } from '$lib/utils'
import { CancelablePromiseUtils } from '$lib/cancelable-promise-utils'
import { tweened, type Tweened } from 'svelte/motion'
import { subtractDaysFromDateString } from '$lib/utils'
import { CancelablePromiseUtils } from '$lib/cancelable-promise-utils'
import type { RunsFilters } from './RunsFilter.svelte'
interface Props {
jobs: Job[] | undefined
user: string | null
label?: string | null
worker?: string | null
folder: string | null
path: string | null
success?: 'success' | 'suspended' | 'waiting' | 'failure' | 'running' | undefined
showSchedules?: boolean
showFutureJobs?: boolean
argFilter: string | undefined
resultFilter?: string | undefined
jobTriggerKind?: JobTriggerKind | undefined
schedulePath?: string | undefined
jobKindsCat?: string | undefined
minTs?: string | undefined
maxTs?: string | undefined
jobKinds?: string
queue_count?: Tweened<number> | undefined
suspended_count?: Tweened<number> | undefined
autoRefresh?: boolean
completedJobs?: CompletedJob[] | undefined
externalJobs?: Job[] | undefined
concurrencyKey: string | null
tag: string | null
showSkipped?: boolean
extendedJobs?: ExtendedJobs | undefined
argError?: string
resultError?: string
loading?: boolean
refreshRate?: number
syncQueuedRunsCount?: boolean
allWorkspaces?: boolean
computeMinAndMax: (() => { minTs: string; maxTs: string | undefined } | undefined) | undefined
lookback?: number
perPage?: number
allowWildcards?: boolean
export function computeJobKinds(jobKindsCat: string | null): string {
if (jobKindsCat == 'all') {
return ''
} else if (jobKindsCat == 'dependencies') {
let kinds: CompletedJob['job_kind'][] = ['dependencies', 'flowdependencies', 'appdependencies']
return kinds.join(',')
} else if (jobKindsCat == 'previews') {
let kinds: CompletedJob['job_kind'][] = ['preview', 'flowpreview']
return kinds.join(',')
} else if (jobKindsCat == 'deploymentcallbacks') {
let kinds: CompletedJob['job_kind'][] = ['deploymentcallback']
return kinds.join(',')
} else if (jobKindsCat == 'runs') {
let kinds: CompletedJob['job_kind'][] = ['script', 'flow', 'singlestepflow']
return kinds.join(',')
} else {
let kinds: CompletedJob['job_kind'][] = [
'script',
'flow',
'flowscript',
'flownode',
'appscript'
]
return kinds.join(',')
}
}
let {
jobs = $bindable(),
user,
label = null,
worker = null,
folder,
path,
success = undefined,
showSkipped = false,
showSchedules = true,
showFutureJobs = true,
argFilter,
resultFilter = undefined,
jobTriggerKind = undefined,
schedulePath = undefined,
jobKindsCat = undefined,
minTs = $bindable(undefined),
maxTs = $bindable(undefined),
jobKinds = $bindable(undefined),
queue_count = $bindable(undefined),
suspended_count = $bindable(undefined),
autoRefresh = true,
completedJobs = $bindable(undefined),
externalJobs = $bindable(undefined),
concurrencyKey,
tag,
extendedJobs = $bindable(undefined),
argError = '',
resultError = '',
loading = $bindable(false),
refreshRate = 5000,
syncQueuedRunsCount = true,
allWorkspaces = false,
computeMinAndMax,
lookback = 0,
perPage = $bindable(1000),
allowWildcards = false
}: Props = $props()
let intervalId: number | undefined = $state()
export interface UseJobLoaderArgs {
currentWorkspace: string
filters?: Partial<RunsFilters>
jobKinds?: string
autoRefresh?: boolean
argError?: string
resultError?: string
refreshRate?: number
syncQueuedRunsCount?: boolean
skip?: boolean
computeMinAndMax?: (() => { minTs: string; maxTs: string | null } | undefined) | undefined
lookback?: number
onSetMinMaxTs?: (minTs: string | null, maxTs: string | null) => void
onSetPerPage?: (perPage: number) => void
}
export function useJobsLoader(args: () => UseJobLoaderArgs) {
let _args = $derived(args())
let currentWorkspace = $derived(_args.currentWorkspace)
let filters = $derived(_args.filters)
let jobKinds = $derived(_args.jobKinds)
let autoRefresh = $derived(_args.autoRefresh ?? true)
let argError = $derived(_args.argError ?? '')
let resultError = $derived(_args.resultError ?? '')
let refreshRate = $derived(_args.refreshRate ?? 5000)
let syncQueuedRunsCount = $derived(_args.syncQueuedRunsCount ?? true)
let computeMinAndMax = $derived(_args.computeMinAndMax)
let lookback = $derived(_args.lookback ?? 0)
let onSetMinMaxTs = $derived(_args.onSetMinMaxTs)
let onSetPerPage = $derived(_args.onSetPerPage)
let label = $derived(filters?.label ?? null)
let worker = $derived(filters?.worker ?? null)
let success = $derived(filters?.success ?? null)
let showSkipped = $derived(filters?.show_skipped ?? false)
let showSchedules = $derived(filters?.show_schedules ?? true)
let showFutureJobs = $derived(filters?.show_future_jobs ?? true)
let resultFilter = $derived(filters?.result)
let jobTriggerKind = $derived(filters?.job_trigger_kind ?? null)
let schedulePath = $derived(filters?.schedule_path ?? null)
let jobKindsCat = $derived(filters?.job_kinds ?? null)
let allWorkspaces = $derived(filters?.all_workspaces ?? false)
let allowWildcards = $derived(filters?.allow_wildcards ?? false)
let concurrencyKey = $derived(filters?.concurrency_key)
let tag = $derived(filters?.tag)
let user = $derived(filters?.user)
let folder = $derived(filters?.folder)
let path = $derived(filters?.path)
let argFilter = $derived(filters?.arg)
let minTs = $derived(filters?.min_ts ?? null)
let maxTs = $derived(filters?.max_ts ?? null)
let perPage = $derived(filters?.per_page ?? 100)
let queue_count: Tweened<number> | undefined = $state()
let suspended_count: Tweened<number> | undefined = $state()
let loading = $state(false)
let lastFetchWentToEnd = $state(false)
let completedJobs: CompletedJob[] | undefined = $state()
let externalJobs: Job[] | undefined = $state()
let extendedJobs: ExtendedJobs | undefined = $state()
let jobs: Job[] | undefined = $state()
let intervalId: ReturnType<typeof setInterval> | undefined = $state()
let sync = true
function onParamChanges() {
@@ -106,8 +118,8 @@
sendUserToast(
'Loading jobs is taking longer than expected...',
true,
perPage > 25
? [{ label: 'Reduce to 25 items per page', callback: () => (perPage = 25) }]
perPage > 25 && onSetPerPage
? [{ label: 'Reduce to 25 items per page', callback: () => onSetPerPage(25) }]
: []
)
})
@@ -120,43 +132,9 @@
return promise
}
function computeJobKinds(jobKindsCat: string | undefined): string {
if (jobKindsCat == undefined && jobKinds != undefined) {
return jobKinds
}
if (jobKindsCat == 'all') {
return ''
} else if (jobKindsCat == 'dependencies') {
let kinds: CompletedJob['job_kind'][] = [
'dependencies',
'flowdependencies',
'appdependencies'
]
return kinds.join(',')
} else if (jobKindsCat == 'previews') {
let kinds: CompletedJob['job_kind'][] = ['preview', 'flowpreview']
return kinds.join(',')
} else if (jobKindsCat == 'deploymentcallbacks') {
let kinds: CompletedJob['job_kind'][] = ['deploymentcallback']
return kinds.join(',')
} else if (jobKindsCat == 'runs') {
let kinds: CompletedJob['job_kind'][] = ['script', 'flow', 'singlestepflow']
return kinds.join(',')
} else {
let kinds: CompletedJob['job_kind'][] = [
'script',
'flow',
'flowscript',
'flownode',
'appscript'
]
return kinds.join(',')
}
}
let loadingFetch = false
export async function loadExtraJobs(): Promise<boolean> {
async function loadExtraJobs(): Promise<void> {
if (jobs && jobs.length > 0) {
let minQueueTs: string | undefined = undefined
let minCompletedTs: string | undefined = undefined
@@ -178,33 +156,36 @@
if (!ts) {
sendUserToast('No jobs to load from')
return false
lastFetchWentToEnd = false
return
}
// const minCreated = lastJob?.created_at
const minCreated = new Date(new Date(ts).getTime() - 1).toISOString()
let olderJobs = await fetchJobs(minCreated, minTs, undefined)
jobs = jobs?.concat(olderJobs)
jobs = updateWithNewJobs(olderJobs ?? [], jobs ?? [])
computeCompletedJobs()
return olderJobs?.length < perPage
lastFetchWentToEnd = olderJobs?.length < perPage
} else {
lastFetchWentToEnd = false
}
return false
}
function fetchJobs(
completedBefore: string | undefined,
completedAfter: string | undefined,
completedBefore: string | null,
completedAfter: string | null,
createdAfterQueue: string | undefined
): CancelablePromise<Job[]> {
if (_args.skip) return CancelablePromiseUtils.pure<Job[]>([])
loadingFetch = true
let scriptPathStart = folder === null || folder === '' ? undefined : `f/${folder}/`
let scriptPathExact = path === null || path === '' ? undefined : path
let promise = JobService.listJobs({
workspace: $workspaceStore!,
completedBefore,
completedAfter,
workspace: currentWorkspace,
completedBefore: completedBefore ?? undefined,
completedAfter: completedAfter ?? undefined,
createdAfterQueue,
schedulePath,
schedulePath: schedulePath ?? undefined,
scriptPathExact,
createdBy: user === null || user === '' ? undefined : user,
scriptPathStart: scriptPathStart,
@@ -234,7 +215,7 @@
resultFilter && resultFilter != '{}' && resultFilter != '' && resultError == ''
? resultFilter
: undefined,
triggerKind: jobTriggerKind,
triggerKind: jobTriggerKind ?? undefined,
allWorkspaces: allWorkspaces ? true : undefined,
perPage,
allowWildcards: allowWildcards ? true : undefined
@@ -243,7 +224,7 @@
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 CancelablePromiseUtils.pure([] as Job[])
return CancelablePromiseUtils.pure<Job[]>([])
})
CancelablePromiseUtils.pipe(promise, () => {
loadingFetch = false
@@ -253,20 +234,22 @@
function fetchExtendedJobs(
concurrencyKey: string | null,
createdBeforeQueue: string | undefined,
completedAfter: string | undefined
createdBeforeQueue: string | null,
completedAfter: string | null
): CancelablePromise<ExtendedJobs> {
if (_args.skip)
return CancelablePromiseUtils.pure<ExtendedJobs>({ jobs: [], obscured_jobs: [] })
loadingFetch = true
let promise = ConcurrencyGroupsService.listExtendedJobs({
rowLimit: perPage,
concurrencyKey: concurrencyKey == null || concurrencyKey == '' ? undefined : concurrencyKey,
workspace: $workspaceStore!,
completedAfter,
createdBeforeQueue: createdBeforeQueue,
workspace: currentWorkspace,
completedAfter: completedAfter ?? undefined,
createdBeforeQueue: createdBeforeQueue ?? undefined,
// createdOrStartedBefore: startedBefore,
// createdOrStartedAfter: startedAfter,
// createdOrStartedAfterCompletedJobs: startedAfterCompletedJobs,
schedulePath,
schedulePath: schedulePath ?? undefined,
scriptPathExact: path === null || path === '' ? undefined : path,
createdBy: user === null || user === '' ? undefined : user,
scriptPathStart: folder === null || folder === '' ? undefined : `f/${folder}/`,
@@ -285,7 +268,7 @@
resultFilter && resultFilter != '{}' && resultFilter != '' && resultError == ''
? resultFilter
: undefined,
triggerKind: jobTriggerKind,
triggerKind: jobTriggerKind ?? undefined,
allWorkspaces: allWorkspaces ? true : undefined,
perPage,
allowWildcards
@@ -301,17 +284,8 @@
return promise
}
export async function loadJobs(
nMinTs: string | undefined,
nMaxTs: string | undefined,
reset: boolean,
shouldGetCount?: boolean
): Promise<void> {
minTs = nMinTs
maxTs = nMaxTs
if (reset) {
resetJobs()
}
async function loadJobs(reset: boolean, shouldGetCount?: boolean): Promise<void> {
if (reset) resetJobs()
await loadJobsIntern(shouldGetCount)
}
@@ -320,6 +294,7 @@
completedJobs = undefined
externalJobs = undefined
extendedJobs = undefined
lastFetchWentToEnd = false
intervalId && clearInterval(intervalId)
intervalId = setInterval(syncer, refreshRate)
}
@@ -334,7 +309,7 @@
const extendedMinTs = subtractDaysFromDateString(minTs, lookback)
if (concurrencyKey == null || concurrencyKey === '') {
return CancelablePromiseUtils.map(fetchJobs(maxTs, undefined, extendedMinTs), (newJobs) => {
return CancelablePromiseUtils.map(fetchJobs(maxTs, null, extendedMinTs), (newJobs) => {
extendedJobs = { jobs: newJobs, obscured_jobs: [] } as ExtendedJobs
// Filter on minTs here and not in the backend
@@ -346,7 +321,7 @@
})
} else {
return CancelablePromiseUtils.map(
fetchExtendedJobs(concurrencyKey, maxTs, extendedMinTs),
fetchExtendedJobs(concurrencyKey, maxTs, extendedMinTs ?? null),
(newExtendedJobs) => {
extendedJobs = newExtendedJobs
const newJobs = newExtendedJobs.jobs
@@ -378,8 +353,9 @@
}
async function getCount() {
if (_args.skip) return
const { database_length, suspended } = await JobService.getQueueCount({
workspace: $workspaceStore!,
workspace: currentWorkspace,
allWorkspaces
})
@@ -399,8 +375,7 @@
async function syncer() {
if (success == 'waiting') {
minTs = undefined
maxTs = undefined
onSetMinMaxTs?.(null, null)
}
if (loadingFetch) {
return
@@ -410,14 +385,11 @@
getCount()
}
if (computeMinAndMax) {
const ts = computeMinAndMax()
if (ts) {
minTs = ts.minTs
maxTs = ts.maxTs
if (maxTs != undefined) {
loadJobsIntern(false)
}
const ts = computeMinAndMax?.()
if (ts) {
onSetMinMaxTs?.(ts.minTs, ts.maxTs)
if (maxTs != undefined) {
loadJobsIntern(false)
}
}
@@ -426,7 +398,7 @@
loadJobsIntern(false)
} else {
let minQueueCreatedAt: string | undefined = undefined
let completedTs: string | undefined = undefined
let completedTs: string | null = null
let cursor = 0
@@ -491,8 +463,8 @@
return ret
}
function sortMinDate(minTs: string | undefined, jobs: Job[]) {
if (minTs != undefined) {
function sortMinDate(minTs: string | null, jobs: Job[]) {
if (minTs) {
const minDate = new Date(minTs)
return jobs.filter((x) =>
x.started_at
@@ -559,32 +531,7 @@
}
})
$effect(() => {
jobKinds = computeJobKinds(jobKindsCat)
})
$effect(() => {
;[
$workspaceStore,
path,
label,
success,
worker,
showSkipped,
jobKinds,
concurrencyKey,
tag,
lookback,
user,
folder,
allowWildcards,
schedulePath,
showFutureJobs,
showSchedules,
allWorkspaces,
argFilter,
resultFilter,
jobTriggerKind,
perPage
]
Object.keys(filters ?? {}).map((k) => filters?.[k as keyof RunsFilters])
let p = untrack(() => onParamChanges())
return () => p.cancel()
})
@@ -605,4 +552,33 @@
}
})
})
</script>
return {
loadExtraJobs,
loadJobs,
get queue_count() {
return queue_count
},
get suspended_count() {
return suspended_count
},
get loading() {
return loading
},
get completedJobs() {
return completedJobs
},
get externalJobs() {
return externalJobs
},
get extendedJobs() {
return extendedJobs
},
get jobs() {
return jobs
},
get lastFetchWentToEnd() {
return lastFetchWentToEnd
}
}
}
+54 -1
View File
@@ -3,7 +3,9 @@
import { untrack } from 'svelte'
import { deepEqual } from 'fast-equals'
import { type StateStore } from './utils'
import { resource, type ResourceReturn } from 'runed'
import { resource, watch, type ResourceReturn } from 'runed'
import * as runed from 'runed/kit'
import type z from 'zod'
export function withProps<Component, Props>(component: Component, props: Props) {
const ret = $state({
@@ -187,3 +189,54 @@ export class ChangeOnDeepInequality<T> {
return this._cached!
}
}
// The original from runed has a weird behavior with dedup reads causing duplicate effect runs
// (Every field has to be derived to avoid it : https://runed.dev/docs/utilities/use-search-params)
export function useSearchParams<S extends z.ZodType>(
schema: S,
options?: runed.SearchParamsOptions
): runed.ReturnUseSearchParams<S> {
let params = runed.useSearchParams(schema, options)
let keys = Object.keys((schema as any).shape ?? {})
let obj = { ...params }
for (const key of keys) {
// Somehow using $derived does not trigger reactivity sometimes ...
// (e.g: filters.arg in RunsPage.svelte updates in the URL but does not trigger reactivity)
let derivedVal = $state(params[key])
Object.defineProperty(obj, key, {
get: () => {
if (typeof derivedVal === 'string') return decodeURIComponent(derivedVal)
return derivedVal
},
set: (v) => {
const val = typeof v === 'string' ? encodeURIComponent(v) : v
params[key] = val
derivedVal = val
},
enumerable: true,
configurable: true
})
}
return obj
}
// Prevents flickering when data is unloaded (undefined) then reloaded quickly
// But still becomes undefined if data is not reloaded within the timeout
// so the user has feedback that the data is not available anymore.
export class StaleWhileLoading<T> {
private _current: T | undefined = $state()
private _currentTimeout: ReturnType<typeof setTimeout> | undefined
constructor(getter: () => T, timeout = 400) {
watch(getter, (value) => {
if (this._currentTimeout) clearTimeout(this._currentTimeout)
if (value === undefined) {
this._currentTimeout = setTimeout(() => (this._current = undefined), timeout)
} else {
this._current = value
}
})
}
get current(): T | undefined {
return this._current
}
}
+1 -1
View File
@@ -146,7 +146,7 @@ export function retrieveCommonWorkerPrefix(workerName: string): string {
}
export function subtractDaysFromDateString(
dateString: string | undefined,
dateString: string | null,
days: number
): string | undefined {
if (dateString == undefined) {
@@ -3,13 +3,9 @@
<script lang="ts">
import { page } from '$app/state'
import { onMount } from 'svelte'
import RunsPage, { DEFAULT_RUNS_PER_PAGE } from '../../../../../lib/components/RunsPage.svelte'
import RunsPage from '../../../../../lib/components/RunsPage.svelte'
import RunsTutorial from '$lib/components/tutorials/RunsTutorial.svelte'
let perPage = $state(
parseInt(page.url.searchParams.get('per_page') ?? DEFAULT_RUNS_PER_PAGE.toString())
)
let runsTutorial: RunsTutorial
onMount(() => {
@@ -24,8 +20,6 @@
})
</script>
{#key perPage}
<RunsPage bind:perPage />
{/key}
<RunsPage />
<RunsTutorial bind:this={runsTutorial} index={7} />