mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 08:00:45 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
+180
-204
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user