diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md
index da207ec62a..6788ac10be 100644
--- a/frontend/CLAUDE.md
+++ b/frontend/CLAUDE.md
@@ -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
+
+```
+
+**✅ 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
+
+```
+
+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
diff --git a/frontend/src/lib/components/ConcurrentJobsChart.svelte b/frontend/src/lib/components/ConcurrentJobsChart.svelte
index c401df4be6..d9d976bdb5 100644
--- a/frontend/src/lib/components/ConcurrentJobsChart.svelte
+++ b/frontend/src/lib/components/ConcurrentJobsChart.svelte
@@ -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: {
diff --git a/frontend/src/lib/components/HistoricInputs.svelte b/frontend/src/lib/components/HistoricInputs.svelte
index 2f3ac70672..e141400c4d 100644
--- a/frontend/src/lib/components/HistoricInputs.svelte
+++ b/frontend/src/lib/components/HistoricInputs.svelte
@@ -1,17 +1,17 @@
-{#if runnableId}
-
-{/if}
-
- {#if loading}
+ {#if jobsLoader?.loading}
{/if}
- {#if jobs?.length > 0}
+ {#if jobs.length > 0}
@@ -144,7 +145,7 @@
on:select={(e) => handleSelected(e.detail)}
/>
{/each}
- {#if jobs?.length == 5}
+ {#if jobs.length == 5}
| limited to 5 runs |
diff --git a/frontend/src/lib/components/RunChart.svelte b/frontend/src/lib/components/RunChart.svelte
index 78873a3537..73216c8284 100644
--- a/frontend/src/lib/components/RunChart.svelte
+++ b/frontend/src/lib/components/RunChart.svelte
@@ -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)
+ })
diff --git a/frontend/src/lib/components/RunsPage.svelte b/frontend/src/lib/components/RunsPage.svelte
index 5ad50f5eb0..fd9487ba1c 100644
--- a/frontend/src/lib/components/RunsPage.svelte
+++ b/frontend/src/lib/components/RunsPage.svelte
@@ -1,19 +1,12 @@
-
-
-
-
{
reset()
- loadFromQuery()
}}
/>
@@ -971,7 +575,7 @@
{
@@ -988,70 +592,52 @@
- {#if minTs || maxTs}
+ {#if filters.min_ts || filters.max_ts}
{/if}
(filters.min_ts = null)}
label="From"
- class={minTs || maxTs ? '' : 'relative top-0 bottom-0 left-0 right-0 h-[34px]'}
- on:change={async ({ detail }) => {
- minTs = new Date(detail).toISOString()
- calendarChangeTimeout && clearTimeout(calendarChangeTimeout)
- calendarChangeTimeout = setTimeout(() => {
- jobsLoader?.loadJobs(minTs, maxTs, true)
- }, 1000)
- }}
- on:clear={async () => {
- minTs = undefined
- calendarChangeTimeout && clearTimeout(calendarChangeTimeout)
- calendarChangeTimeout = setTimeout(() => {
- jobsLoader?.loadJobs(minTs, maxTs, true)
- }, 1000)
- }}
+ class={filters.min_ts || filters.max_ts
+ ? ''
+ : 'relative top-0 bottom-0 left-0 right-0 h-[34px]'}
/>
- {#if maxTs || minTs}
+ {#if filters.max_ts || filters.min_ts}
{/if}
(filters.max_ts = null)}
+ bind:date={filters.max_ts}
label="To"
- class={minTs || maxTs ? '' : 'relative top-0 bottom-0 left-0 right-0 h-[34px]'}
- on:change={async ({ detail }) => {
- maxTs = new Date(detail).toISOString()
- calendarChangeTimeout && clearTimeout(calendarChangeTimeout)
- calendarChangeTimeout = setTimeout(() => {
- jobsLoader?.loadJobs(minTs, maxTs, true)
- }, 1000)
- }}
- on:clear={async () => {
- maxTs = undefined
- calendarChangeTimeout && clearTimeout(calendarChangeTimeout)
- calendarChangeTimeout = setTimeout(() => {
- jobsLoader?.loadJobs(minTs, maxTs, true)
- }, 1000)
- }}
+ class={filters.min_ts || filters.max_ts
+ ? ''
+ : 'relative top-0 bottom-0 left-0 right-0 h-[34px]'}
/>
- {#if minTs || maxTs}
+ {#if filters.min_ts || filters.max_ts}
@@ -1061,28 +647,28 @@
{
- if (e.detail == 'running' && maxTs != undefined) {
- maxTs = undefined
+ if (e.detail == 'running' && filters.max_ts != undefined) {
+ filters.max_ts = null
}
}}
{usernames}
@@ -1090,7 +676,7 @@
{paths}
mobile={innerWidth < verySmallScreenWidth}
small={innerWidth < smallScreenWidth}
- calendarSmall={!minTs && !maxTs}
+ calendarSmall={!filters.min_ts && !filters.max_ts}
/>
@@ -1164,17 +750,17 @@
{lastFetchWentToEnd}
bind:selectedIds
canSelect={!selectionMode}
- minTimeSet={minTs}
- maxTimeSet={maxTs}
+ minTimeSet={filters.min_ts}
+ maxTimeSet={filters.max_ts}
totalRowsFetched={jobs?.length ?? 0}
- maxIsNow={maxTs == undefined}
+ maxIsNow={filters.max_ts == undefined}
onLoadExtra={loadExtra}
jobs={completedJobs}
onZoom={async (zoom) => {
- minTs = zoom.min.toISOString()
- maxTs = zoom.max.toISOString()
+ filters.min_ts = zoom.min.toISOString()
+ filters.max_ts = zoom.max.toISOString()
manualDatePicker?.resetChoice()
- jobsLoader?.loadJobs(minTs, maxTs, true)
+ jobsLoader?.loadJobs(true)
}}
onPointClicked={(ids) => {
runsTable?.scrollToRun(ids)
@@ -1182,14 +768,14 @@
/>
{:else if graph === 'ConcurrencyChart'}
{
- minTs = zoom.min.toISOString()
- maxTs = zoom.max.toISOString()
- jobsLoader?.loadJobs(minTs, maxTs, true)
+ filters.min_ts = zoom.min.toISOString()
+ filters.max_ts = zoom.max.toISOString()
+ jobsLoader?.loadJobs(true)
}}
/>
{/if}
@@ -1242,17 +828,11 @@
- {#if !jobTriggerKind}
+ {#if !filters.job_trigger_kind}
{
- localStorage.setItem(
- 'show_schedules_in_run',
- showSchedules ? 'true' : 'false'
- )
- }}
+ bind:checked={filters.show_schedules}
options={tableTopBarWidth < 800 || selectionMode
? {}
: { right: 'Schedules' }}
@@ -1266,10 +846,7 @@
{
- localStorage.setItem('show_future_jobs', showFutureJobs ? 'true' : 'false')
- }}
+ bind:checked={filters.show_future_jobs}
id="planned-later"
options={tableTopBarWidth < 800 || selectionMode
? {}
@@ -1282,15 +859,14 @@
{
- lastFetchWentToEnd = false
- jobsLoader?.loadJobs(minTs, maxTs, true)
+ jobsLoader?.loadJobs(true)
}}
- bind:minTs
- bind:maxTs
+ bind:minTs={filters.min_ts}
+ bind:maxTs={filters.max_ts}
bind:selectedManualDate
{loading}
bind:this={manualDatePicker}
- numberOfLastJobsToFetch={perPage}
+ numberOfLastJobsToFetch={filters.per_page}
/>
{:else}
@@ -1345,13 +921,13 @@
minTsManual ?? null, (v) => (minTsManual = v ?? undefined)}
+ bind:maxTs={() => maxTsManual ?? null, (v) => (maxTsManual = v ?? undefined)}
bind:this={manualPicker}
{loading}
on:loadJobs={() => {
diff --git a/frontend/src/lib/components/common/calendarPicker/CalendarPicker.svelte b/frontend/src/lib/components/common/calendarPicker/CalendarPicker.svelte
index a228e207f1..0a3a89a3a7 100644
--- a/frontend/src/lib/components/common/calendarPicker/CalendarPicker.svelte
+++ b/frontend/src/lib/components/common/calendarPicker/CalendarPicker.svelte
@@ -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
diff --git a/frontend/src/lib/components/runs/ManuelDatePicker.svelte b/frontend/src/lib/components/runs/ManuelDatePicker.svelte
index aaf0d60063..ceb393799b 100644
--- a/frontend/src/lib/components/runs/ManuelDatePicker.svelte
+++ b/frontend/src/lib/components/runs/ManuelDatePicker.svelte
@@ -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')
}
diff --git a/frontend/src/lib/components/runs/RunsFilter.svelte b/frontend/src/lib/components/runs/RunsFilter.svelte
index 2c7ea5251b..89fc83422b 100644
--- a/frontend/src/lib/components/runs/RunsFilter.svelte
+++ b/frontend/src/lib/components/runs/RunsFilter.svelte
@@ -1,3 +1,37 @@
+
+
@@ -407,7 +450,7 @@
{
- schedulePath = undefined
+ schedulePath = null
dispatch('reset')
}}
>
@@ -426,7 +469,7 @@
}
tagTimeout = setTimeout(() => {
- schedulePath = displayedSchedule
+ schedulePath = displayedSchedule ?? null
}, 1000)
},
id: 'schedulePath'
@@ -580,7 +623,7 @@
{
- 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 @@
-
-
-
-
-
-
- {
- argFilter = ''
- resultFilter = ''
- }}
- >
- Clear
-
-
- {
- argFilter = copyArgFilter
- resultFilter = copyResultFilter
- }}
- >
- Set args/result filter
-
-
+
+
{/snippet}
diff --git a/frontend/src/lib/components/runs/RunsQueue.svelte b/frontend/src/lib/components/runs/RunsQueue.svelte
index 1a7f7ff410..b0c5ed5adb 100644
--- a/frontend/src/lib/components/runs/RunsQueue.svelte
+++ b/frontend/src/lib/components/runs/RunsQueue.svelte
@@ -8,7 +8,7 @@
interface Props {
queue_count?: Tweened | undefined
suspended_count?: Tweened | undefined
- success: string | undefined
+ success: string | null
small?: boolean
onJobsWaiting?: () => void
onJobsSuspended?: () => void
diff --git a/frontend/src/lib/components/runs/RunsTable.svelte b/frontend/src/lib/components/runs/RunsTable.svelte
index acb484ee90..f9051caa0e 100644
--- a/frontend/src/lib/components/runs/RunsTable.svelte
+++ b/frontend/src/lib/components/runs/RunsTable.svelte
@@ -34,7 +34,7 @@
selectedIds = $bindable([]),
selectedWorkspace = $bindable(undefined),
activeLabel = null,
- lastFetchWentToEnd = $bindable(false),
+ lastFetchWentToEnd = false,
perPage = 1000
}: Props = $props()
diff --git a/frontend/src/lib/components/runs/JobsLoader.svelte b/frontend/src/lib/components/runs/useJobsLoader.svelte.ts
similarity index 65%
rename from frontend/src/lib/components/runs/JobsLoader.svelte
rename to frontend/src/lib/components/runs/useJobsLoader.svelte.ts
index 2868390c9f..f719e2732e 100644
--- a/frontend/src/lib/components/runs/JobsLoader.svelte
+++ b/frontend/src/lib/components/runs/useJobsLoader.svelte.ts
@@ -1,102 +1,114 @@
-
+
+ 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
+ }
+ }
+}
diff --git a/frontend/src/lib/svelte5Utils.svelte.ts b/frontend/src/lib/svelte5Utils.svelte.ts
index 1b3300530f..8388a54fce 100644
--- a/frontend/src/lib/svelte5Utils.svelte.ts
+++ b/frontend/src/lib/svelte5Utils.svelte.ts
@@ -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: Component, props: Props) {
const ret = $state({
@@ -187,3 +189,54 @@ export class ChangeOnDeepInequality {
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(
+ schema: S,
+ options?: runed.SearchParamsOptions
+): runed.ReturnUseSearchParams {
+ 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 {
+ private _current: T | undefined = $state()
+ private _currentTimeout: ReturnType | 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
+ }
+}
diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts
index d548772fd0..b6587cdc70 100644
--- a/frontend/src/lib/utils.ts
+++ b/frontend/src/lib/utils.ts
@@ -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) {
diff --git a/frontend/src/routes/(root)/(logged)/runs/[...path]/+page.svelte b/frontend/src/routes/(root)/(logged)/runs/[...path]/+page.svelte
index 85c1d8367e..6a3bd00e27 100644
--- a/frontend/src/routes/(root)/(logged)/runs/[...path]/+page.svelte
+++ b/frontend/src/routes/(root)/(logged)/runs/[...path]/+page.svelte
@@ -3,13 +3,9 @@
-{#key perPage}
-
-{/key}
+