Files
windmill/frontend/src/lib/components/runs/TimeframeSelect.svelte
T
Diego Imbertandclaude[bot] 9b28c85469 feat: Unified filters and new runs page (#8027)
* RunsPage redesign v0

* nit

* Remove manualdatepicker

* remove shadow

* ui nits

* nit scrollbar bg

* prettier cards

* nit

* Remove code

* command/meta multi select

* Shift select

* RightClickPopover

* nit

* Ctrl A

* nit card

* DropdownMenu

* nit

* count hint

* fix stuck keys

* opacity UX

* error toasts pickhubscript

* Improve UX

* fix undefined error

* keyboard nav

* nit batch rerun fixes

* nit fix scroll / height

* Batch reruns actions + nits

* nit

* Cancel selected jobs

* Cancel / re-run all filtered jobs

* Go to job / flow / script action

* nit

* add batch actions back

* nit

* nit

* bar on splitpane hover

* nit

* New Timeframe system

* reset btn

* nit fixes

* dead code

* nits

* typecheck

* naming clarity

* Update frontend/src/lib/components/RightClickPopover.svelte

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>

* unnecessary json stringify

* dedup 'the'

* Code deletion to prepare for changes

* filter types

* ui

* fix bug with maxTs

* stuck with melt

* GenericDropdown

* filters onclick

* iterate

* iter

* add all filters

* Descriptions

* focus position

* stash

* TaggedTextInput works much much better

* placeholder

* currentTag suggestion

* improve

* nit

* Keyboard nav

* buildRunsFilterSearchbarSchema

* nit naming

* assignObjInPlace

* Escaping + pretty dates

* nit empty

* fix cursor

* nit space

* Filter filtering

* escape pasted value

* nit

* escape spaces

* nit undefined

* add space at end if right arrow

* escape all spaces

* arrow skips escape chars

* escape \ too

* delete whole escaped characters

* double space to escape tag

* code refactor

* Ensure cursor visible

* fix keyboard nav

* safety

* filterSchemaRecToZodSchema

* URL Sync

* fix readonly

* fix typing

* start replacing old filter logic

* use new filter impl

* nit

* nit reactivity

* nit fix

* no more localStorage

* Add back status and kind toggles

* Nit fix

* style nit

* focus at end on click

* clearn btn + fixes

* fix broken date uri

* nit

* useSyncedTimeframe

* negative filter button

* negative filters helpers rust

* Negated filters backed

* nit

* highlight

* New useSearchParams

* Accept comma separated list

* nit allowNegative

* openapi update

* Fix trigger kind list/negation not working

* nit oipenpai

* Presets

* DebouncedTempValue

* remove presets from list when already applied

* UI nit improvements

* allowMultiple

* hint

* validateFilterInstance fn

* nit fix

* error highlights

* nit ux selecting negative list

* nit

* on clear btn

* SimpleEditor for JSON

* nit

* flop

* Pass presets as param

* nit delete

* preventCursorMoveOnNextSync

* responsive layout

* Escape \n

* Inline calendar input

* mm/dd or dd/mm depending on US or not

* onClickBehavior

* infiniteRange

* other nits

* Wiring with runs filter

* formatDateRange better

* inits on right page

* style

* min hour support

* Time input

* use our components

* Improve SKILL.md

* dd mm yyyy numeric input

* TimeframeSelect with new date picker

* fixes

* ensure date is in view when value changes externally

* fixes

* nit select all on focus

* select year + nits

* nit layout shift

* nit negative when starting with !

* nit

* SelectDropdown uses GenericDropdown now

* Fix blank select dropdown rendering bug

* icons

* Reset btn + shorter date range formatting

* overflow fix

* unnecessary absolute

* fix clear btn overlap

* Update routes for new filters (assets, schedule, resource, variables)

* update openapi

* Impl for other pages

* ui nits

* nit fixes

* Fix columns filter

* super nits

---------

Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-02-23 17:53:09 +00:00

236 lines
7.2 KiB
Svelte

<script module lang="ts">
function computeMinMaxInc(inc: number) {
let minTs = new Date(new Date().getTime() - inc).toISOString()
let maxTs = new Date().toISOString()
return { minTs, maxTs }
}
export type Timeframe =
| {
label: string
computeMinMax: () => { minTs: string | null; maxTs: string | null }
type: 'dynamic'
}
| {
label: string
computeMinMax: () => { minTs: string | null; maxTs: string | null }
minTs: string | null
maxTs: string | null
type: 'manual'
}
export function buildManualTimeframe(minTs: string | null, maxTs: string | null): Timeframe {
return {
label: formatDateRange(minTs ?? undefined, maxTs ?? undefined),
minTs,
maxTs,
type: 'manual',
computeMinMax: () => ({ minTs, maxTs })
}
}
export const serviceLogsTimeframes: Timeframe[] = [
{ label: '1000 last service logs', computeMinMax: () => ({ minTs: null, maxTs: null }) },
{ label: 'Within last 5 minutes', computeMinMax: () => computeMinMaxInc(5 * 60 * 1000) },
{ label: 'Within last 30 minutes', computeMinMax: () => computeMinMaxInc(30 * 60 * 1000) },
{ label: 'Within last 24 hours', computeMinMax: () => computeMinMaxInc(24 * 60 * 60 * 1000) },
{ label: 'Within last 7 days', computeMinMax: () => computeMinMaxInc(7 * 24 * 60 * 60 * 1000) },
{ label: 'Within last month', computeMinMax: () => computeMinMaxInc(30 * 24 * 60 * 60 * 1000) }
].map((item) => ({ ...item, type: 'dynamic' }))
export const runsTimeframes: Timeframe[] = [
{ label: 'Latest runs', computeMinMax: () => ({ minTs: null, maxTs: null }) },
{ label: 'Within 30 seconds', computeMinMax: () => computeMinMaxInc(30 * 1000) },
{ label: 'Within last minute', computeMinMax: () => computeMinMaxInc(60 * 1000) },
{ label: 'Within last 5 minutes', computeMinMax: () => computeMinMaxInc(5 * 60 * 1000) },
{ label: 'Within last 30 minutes', computeMinMax: () => computeMinMaxInc(30 * 60 * 1000) },
{ label: 'Within last 24 hours', computeMinMax: () => computeMinMaxInc(24 * 60 * 60 * 1000) },
{ label: 'Within last 7 days', computeMinMax: () => computeMinMaxInc(7 * 24 * 60 * 60 * 1000) },
{ label: 'Within last month', computeMinMax: () => computeMinMaxInc(30 * 24 * 60 * 60 * 1000) }
].map((item) => ({ ...item, type: 'dynamic' }))
export function useUrlSyncedTimeframe(timeframes: Timeframe[]) {
let obj = $state({ timeframe: timeframes[0] })
let timeframe = $derived(obj.timeframe)
watch(
() => [page, timeframe],
() => {
const url = new URL(page.url)
if (timeframe.type === 'manual' && timeframe.minTs)
url.searchParams.set('min_ts', timeframe.minTs)
else url.searchParams.delete('min_ts')
if (timeframe.type === 'manual' && timeframe.maxTs)
url.searchParams.set('max_ts', timeframe.maxTs)
else url.searchParams.delete('max_ts')
if (timeframe.type === 'dynamic' && timeframe.label !== timeframes[0].label)
url.searchParams.set('timeframe', timeframe.label)
else url.searchParams.delete('timeframe')
history.replaceState(null, '', url)
}
)
if (page.url.searchParams.get('min_ts') || page.url.searchParams.get('max_ts')) {
obj.timeframe = buildManualTimeframe(
page.url.searchParams.get('min_ts') || null,
page.url.searchParams.get('max_ts') || null
)
} else {
const tfLabel = page.url.searchParams.get('timeframe')
const tf = timeframes.find((tf) => tf.label === tfLabel)
if (tf) obj.timeframe = { ...tf }
}
return obj
}
export function useSyncedTimeframe(
timeframes: Timeframe[],
getter: () => { minTs?: string | null; maxTs?: string | null; timeframe?: string | null },
setter: (v: { minTs?: string | null; maxTs?: string | null; timeframe?: string | null }) => void
) {
const val = $derived.by(() => {
const v = getter()
if (v.minTs || v.maxTs) {
return buildManualTimeframe(v.minTs ?? null, v.maxTs ?? null)
} else if (v.timeframe) {
const tf = timeframes.find((tf) => tf.label === v.timeframe)
if (tf) return { ...tf }
}
return timeframes[0]
})
return {
get val() {
return val
},
set val(v: Timeframe) {
if (v.type === 'manual') {
setter({ minTs: v.minTs, maxTs: v.maxTs, timeframe: null })
} else {
setter({ minTs: null, maxTs: null, timeframe: v.label })
}
}
}
}
</script>
<script lang="ts">
import { CalendarIcon, RefreshCw } from 'lucide-svelte'
import { Button } from '../common'
import Popover from '../meltComponents/Popover.svelte'
import { formatDateRange } from '$lib/utils'
import { watch } from 'runed'
import { page } from '$app/state'
import InlineCalendarInput, {
fromCalendarDate,
toCalendarDate
} from '../common/InlineCalendarInput.svelte'
interface Props {
loading?: boolean
items: Timeframe[]
value: Timeframe
wrapperClasses?: string
onClick?: () => void
}
let { loading = false, onClick, items, value = $bindable(), wrapperClasses }: Props = $props()
let isOpen = $state(false)
function onManualInput(input: { minTs?: string | null; maxTs?: string | null }) {
if (value.type !== 'manual')
value = buildManualTimeframe(input.minTs ?? null, input.maxTs ?? null)
else {
value = buildManualTimeframe(
'minTs' in input ? (input.minTs ?? null) : value.minTs,
'maxTs' in input ? (input.maxTs ?? null) : value.maxTs
)
}
if (value.type == 'manual' && value.minTs == null && value.maxTs == null) {
value = { ...items[0] }
}
}
</script>
<div class="relative flex {wrapperClasses}">
<Button
unifiedSize="md"
wrapperClasses="flex-1"
btnClasses="!rounded-r-none whitespace-nowrap"
onClick={() => onClick?.()}
>
<div class="flex flex-row items-center gap-2">
<RefreshCw size={14} class={loading ? 'animate-spin' : ''} />
{value.label}
</div>
</Button>
{#if value.type === 'manual'}
<Button
btnClasses="!rounded-none border-l-0"
unifiedSize="md"
onClick={() => (value = { ...items[0] })}
>
Reset
</Button>
{/if}
<Popover enableFlyTransition bind:isOpen>
{#snippet trigger()}
<Button
unifiedSize="md"
iconOnly
btnClasses="!rounded-l-none border-l-0"
endIcon={{ icon: CalendarIcon }}
/>
{/snippet}
{#snippet content()}
{@const range = {
end: toCalendarDate(
value.type === 'manual' && value.maxTs ? new Date(value.maxTs) : undefined
),
start: toCalendarDate(
value.type === 'manual' && value.minTs ? new Date(value.minTs) : undefined
)
}}
<div class="flex divide-x">
<div class="flex flex-col p-2">
{#each items as item}
<Button
onClick={() => (value = { ...item })}
variant="subtle"
unifiedSize="md"
selected={value.label === item.label}
btnClasses="justify-start text-nowrap"
>
{item.label}
</Button>
{/each}
</div>
<InlineCalendarInput
class="p-4 max-w-[18rem]"
infiniteRange
mode="range"
onClickBehavior="set-start"
bind:value={
() => range,
(v) => onManualInput({ minTs: fromCalendarDate(v.start)?.toISOString() ?? null })
}
/>
<InlineCalendarInput
class="p-4 max-w-[18rem]"
infiniteRange
mode="range"
onClickBehavior="set-end"
bind:value={
() => range,
(v) => onManualInput({ maxTs: fromCalendarDate(v.end)?.toISOString() ?? null })
}
/>
</div>
{/snippet}
</Popover>
</div>