mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-10-22 08:03:38 +00:00
New Timeframe system
This commit is contained in:
@@ -184,13 +184,13 @@
|
||||
unifiedSize="md"
|
||||
wrapperClasses="h-full"
|
||||
{disabled}
|
||||
iconOnly
|
||||
endIcon={{ icon: X }}
|
||||
on:click={() => {
|
||||
value = null
|
||||
dispatch('clear')
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
></Button>
|
||||
{/if}
|
||||
<!-- <div>
|
||||
<ToggleButtonGroup bind:selected={format} let:item>
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
|
||||
import JobRunsPreview from '$lib/components/runs/JobRunsPreview.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import CalendarPicker from '$lib/components/common/calendarPicker/CalendarPicker.svelte'
|
||||
|
||||
import RunsTable from '$lib/components/runs/RunsTable.svelte'
|
||||
import { Pane, Splitpanes } from 'svelte-splitpanes'
|
||||
@@ -33,13 +32,17 @@
|
||||
} from '$lib/components/runs/BatchReRunOptionsPane.svelte'
|
||||
import { untrack } from 'svelte'
|
||||
import { page } from '$app/state'
|
||||
import RunOption from '$lib/components/runs/RunOption.svelte'
|
||||
import Select from '$lib/components/select/Select.svelte'
|
||||
import AnimatedPane from '$lib/components/splitPanes/AnimatedPane.svelte'
|
||||
import { useSearchParams } from '$lib/svelte5UtilsKit.svelte'
|
||||
import { StaleWhileLoading } from '$lib/svelte5Utils.svelte'
|
||||
import { TriangleAlertIcon } from 'lucide-svelte'
|
||||
import DropdownV2 from './DropdownV2.svelte'
|
||||
import TimeframeSelect, {
|
||||
buildManualTimeframe,
|
||||
runsTimeframes,
|
||||
useUrlSyncedTimeframe
|
||||
} from './runs/TimeframeSelect.svelte'
|
||||
|
||||
interface Props {
|
||||
/** Initial path from route params (e.g., /runs/u/user/script) */
|
||||
@@ -117,12 +120,18 @@
|
||||
}
|
||||
}
|
||||
|
||||
let _timeframe = useUrlSyncedTimeframe(runsTimeframes)
|
||||
let timeframe = $derived(_timeframe.timeframe)
|
||||
|
||||
let manualTimeframe = $derived(timeframe.type === 'manual' ? timeframe : undefined)
|
||||
|
||||
let graph: 'RunChart' | 'ConcurrencyChart' = $state(
|
||||
typeOfChart(page.url.searchParams.get('graph'))
|
||||
)
|
||||
let innerWidth = $state(window.innerWidth)
|
||||
let jobsLoader = useJobsLoader(() => ({
|
||||
filters,
|
||||
timeframe,
|
||||
jobKinds,
|
||||
autoRefresh,
|
||||
argError,
|
||||
@@ -154,8 +163,7 @@
|
||||
}
|
||||
|
||||
function reset() {
|
||||
filters.min_ts = null
|
||||
filters.max_ts = null
|
||||
_timeframe.timeframe = { ...runsTimeframes[0] }
|
||||
selectedIds = []
|
||||
filters.schedule_path = null
|
||||
selectedWorkspace = undefined
|
||||
@@ -215,10 +223,11 @@
|
||||
function getSelectedFilters() {
|
||||
const argFilter = filters.arg && JSON.parse(filters.arg)
|
||||
const resultFilter = filters.result && JSON.parse(filters.result)
|
||||
const { minTs, maxTs } = timeframe.computeMinMax()
|
||||
return {
|
||||
workspace: $workspaceStore ?? '',
|
||||
startedBefore: filters.max_ts ?? undefined,
|
||||
startedAfter: filters.min_ts ?? undefined,
|
||||
startedBefore: maxTs ?? undefined,
|
||||
startedAfter: minTs ?? undefined,
|
||||
schedulePath: filters.schedule_path ?? undefined,
|
||||
scriptPathExact: filters.path === null || filters.path === '' ? undefined : filters.path,
|
||||
createdBy: filters.user || undefined,
|
||||
@@ -574,58 +583,13 @@
|
||||
|
||||
<div class="py-2 flex items-start gap-x-4 gap-y-2 flex-row grow min-w-0 justify-end">
|
||||
<!-- Dates -->
|
||||
<div class="flex flex-row gap-2">
|
||||
<RunOption label="From" for="min-datetimes">
|
||||
{#if filters.min_ts || filters.max_ts}
|
||||
<input
|
||||
type="text"
|
||||
class="!text-sm text-primary !bg-surface-sunken h-9 !border-none"
|
||||
value={filters.min_ts
|
||||
? new Date(filters.min_ts).toLocaleString()
|
||||
: 'zoom x axis to set min'}
|
||||
disabled
|
||||
name="min-datetimes"
|
||||
/>
|
||||
{/if}
|
||||
<CalendarPicker
|
||||
clearable={true}
|
||||
bind:date={filters.min_ts}
|
||||
on:clear={() => (filters.min_ts = null)}
|
||||
label="From"
|
||||
class={filters.min_ts || filters.max_ts
|
||||
? ''
|
||||
: 'relative top-0 bottom-0 left-0 right-0 h-[34px]'}
|
||||
/>
|
||||
</RunOption>
|
||||
|
||||
<RunOption label="To" for="max-datetimes">
|
||||
{#if filters.max_ts || filters.min_ts}
|
||||
<input
|
||||
type="text"
|
||||
class="!text-sm text-primary !bg-surface-secondary h-9 !border-none"
|
||||
value={filters.max_ts
|
||||
? new Date(filters.max_ts).toLocaleString()
|
||||
: 'zoom x axis to set max'}
|
||||
name="max-datetimes"
|
||||
disabled
|
||||
/>
|
||||
{/if}
|
||||
<CalendarPicker
|
||||
clearable={true}
|
||||
on:clear={() => (filters.max_ts = null)}
|
||||
bind:date={filters.max_ts}
|
||||
label="To"
|
||||
class={filters.min_ts || filters.max_ts
|
||||
? ''
|
||||
: 'relative top-0 bottom-0 left-0 right-0 h-[34px]'}
|
||||
/>
|
||||
</RunOption>
|
||||
|
||||
{#if filters.min_ts || filters.max_ts}
|
||||
<RunOption label="Reset" for="reset" noLabel>
|
||||
<Button variant="default" size="xs" onClick={reset} btnClasses="h-9">Reset</Button>
|
||||
</RunOption>
|
||||
{/if}
|
||||
<div class="mt-auto">
|
||||
<TimeframeSelect
|
||||
onClick={() => jobsLoader?.loadJobs(true)}
|
||||
loading={jobsLoader?.loading}
|
||||
items={runsTimeframes}
|
||||
bind:value={_timeframe.timeframe}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Filters 1 -->
|
||||
@@ -650,17 +614,11 @@
|
||||
bind:argFilter={filters.arg}
|
||||
bind:resultFilter={filters.result}
|
||||
on:change={reloadJobsWithoutFilterError}
|
||||
on:successChange={(e) => {
|
||||
if (e.detail == 'running' && filters.max_ts != undefined) {
|
||||
filters.max_ts = null
|
||||
}
|
||||
}}
|
||||
{usernames}
|
||||
{folders}
|
||||
{paths}
|
||||
mobile={innerWidth < verySmallScreenWidth}
|
||||
small={innerWidth < smallScreenWidth}
|
||||
calendarSmall={!filters.min_ts && !filters.max_ts}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -696,16 +654,19 @@
|
||||
{/if}
|
||||
</div>
|
||||
{#if graph === 'RunChart'}
|
||||
{@const manualTimeframe = timeframe.type === 'manual' ? timeframe : undefined}
|
||||
<RunChart
|
||||
bind:selectedIds
|
||||
canSelect
|
||||
minTimeSet={filters.min_ts}
|
||||
maxTimeSet={filters.max_ts}
|
||||
maxIsNow={filters.max_ts == undefined}
|
||||
minTimeSet={manualTimeframe?.minTs}
|
||||
maxTimeSet={manualTimeframe?.maxTs}
|
||||
maxIsNow={manualTimeframe?.maxTs == undefined}
|
||||
jobs={completedJobs}
|
||||
onZoom={async (zoom) => {
|
||||
filters.min_ts = zoom.min.toISOString()
|
||||
filters.max_ts = zoom.max.toISOString()
|
||||
_timeframe.timeframe = buildManualTimeframe(
|
||||
zoom.min.toISOString(),
|
||||
zoom.max.toISOString()
|
||||
)
|
||||
jobsLoader?.loadJobs(true)
|
||||
}}
|
||||
onPointClicked={(ids) => {
|
||||
@@ -714,13 +675,15 @@
|
||||
/>
|
||||
{:else if graph === 'ConcurrencyChart'}
|
||||
<ConcurrentJobsChart
|
||||
minTimeSet={filters.min_ts}
|
||||
maxTimeSet={filters.max_ts}
|
||||
maxIsNow={filters.max_ts == undefined}
|
||||
minTimeSet={manualTimeframe?.minTs}
|
||||
maxTimeSet={manualTimeframe?.maxTs}
|
||||
maxIsNow={manualTimeframe?.maxTs == undefined}
|
||||
{extendedJobs}
|
||||
onZoom={async (zoom) => {
|
||||
filters.min_ts = zoom.min.toISOString()
|
||||
filters.max_ts = zoom.max.toISOString()
|
||||
_timeframe.timeframe = buildManualTimeframe(
|
||||
zoom.min.toISOString(),
|
||||
zoom.max.toISOString()
|
||||
)
|
||||
jobsLoader?.loadJobs(true)
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -4,12 +4,15 @@
|
||||
const bubble = createBubbler()
|
||||
import { IndexSearchService, ServiceLogsService } from '$lib/gen'
|
||||
|
||||
import ManuelDatePicker from './runs/ManuelDatePicker.svelte'
|
||||
import TimeframeSelect, {
|
||||
serviceLogsTimeframes,
|
||||
useUrlSyncedTimeframe
|
||||
} from './runs/TimeframeSelect.svelte'
|
||||
import CalendarPicker from './common/calendarPicker/CalendarPicker.svelte'
|
||||
import LogViewer from './LogViewer.svelte'
|
||||
import Toggle from './Toggle.svelte'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { onDestroy, tick, untrack } from 'svelte'
|
||||
import { onDestroy, tick } from 'svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import { copyToClipboard, scroll_into_view_if_needed_polyfill, truncateRev } from '$lib/utils'
|
||||
import LogSnippetViewer from './LogSnippetViewer.svelte'
|
||||
@@ -20,6 +23,7 @@
|
||||
import Select from './select/Select.svelte'
|
||||
import { goto } from '$lib/navigation'
|
||||
import { page } from '$app/stores'
|
||||
import { watch } from 'runed'
|
||||
|
||||
interface Props {
|
||||
searchTerm: string
|
||||
@@ -32,9 +36,6 @@
|
||||
let minTs: undefined | string = $state(undefined)
|
||||
let maxTs: undefined | string = $state(undefined)
|
||||
|
||||
let minTsManual: undefined | string = $state($page.url.searchParams.get('minTs') ?? undefined)
|
||||
let maxTsManual: undefined | string = $state($page.url.searchParams.get('maxTs') ?? undefined)
|
||||
|
||||
let max_lines: undefined | number = $state(undefined)
|
||||
|
||||
// let lastSeen: undefined | string = undefined
|
||||
@@ -58,15 +59,17 @@
|
||||
let timeout: number | undefined = $state(undefined)
|
||||
|
||||
let allLogs: ByMode | undefined = $state(undefined)
|
||||
let manualPicker: ManuelDatePicker | undefined = $state(undefined)
|
||||
|
||||
let _timeframe = useUrlSyncedTimeframe(serviceLogsTimeframes)
|
||||
let timeframe = $derived(_timeframe.timeframe)
|
||||
|
||||
let [minTsManual, maxTsManual] = $derived(
|
||||
timeframe.type === 'manual' ? [timeframe.minTs ?? undefined, timeframe.maxTs ?? undefined] : []
|
||||
)
|
||||
|
||||
let upTo: undefined | string = $state(undefined)
|
||||
let upToIsLatest = $state(true)
|
||||
|
||||
function onManualChanges() {
|
||||
getAllLogs(minTsManual ?? maxTs, maxTsManual)
|
||||
}
|
||||
|
||||
function getAllLogs(queryMinTs: string | undefined, queryMaxTs: string | undefined) {
|
||||
timeout && clearTimeout(timeout)
|
||||
loading = true
|
||||
@@ -151,11 +154,6 @@
|
||||
if (autoRefresh && searchTerm === '' && !maxTsManual) {
|
||||
timeout = setTimeout(() => {
|
||||
if (searchTerm !== '') return
|
||||
let minMax = manualPicker?.computeMinMax()
|
||||
if (minMax) {
|
||||
maxTsManual = minMax?.maxTs ?? undefined
|
||||
minTsManual = minMax?.minTs ?? undefined
|
||||
}
|
||||
let maxTsPlus1 = maxTs ? new Date(new Date(maxTs).getTime() + 1000) : undefined
|
||||
getAllLogs(maxTsPlus1?.toISOString(), undefined)
|
||||
}, 5000)
|
||||
@@ -315,8 +313,6 @@
|
||||
) {
|
||||
const params = new URLSearchParams()
|
||||
if (searchTerm) params.set('searchTerm', searchTerm)
|
||||
if (minTs) params.set('minTs', minTs)
|
||||
if (maxTs) params.set('maxTs', maxTs)
|
||||
if (selected?.mode) params.set('mode', selected.mode)
|
||||
if (selected?.workerGroup) params.set('workerGroup', selected.workerGroup)
|
||||
if (selected?.hostname) params.set('hostname', selected.hostname)
|
||||
@@ -435,13 +431,22 @@
|
||||
)
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
minTsManual || maxTsManual || untrack(() => onManualChanges())
|
||||
})
|
||||
$effect(() => {
|
||||
;[searchTerm, selected, minTsManual, maxTsManual, allLogs]
|
||||
untrack(() => searchLogs(searchTerm, selected, minTsManual, maxTsManual, allLogs))
|
||||
})
|
||||
watch(
|
||||
() => timeframe,
|
||||
() => {
|
||||
const ts = timeframe.computeMinMax()
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
allLogs = undefined
|
||||
getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined)
|
||||
}
|
||||
)
|
||||
watch(
|
||||
() => [searchTerm, selected, timeframe, allLogs],
|
||||
() => {
|
||||
searchLogs(searchTerm, selected, minTsManual, maxTsManual, allLogs)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<Drawer bind:this={logDrawer} bind:open={logDrawerOpen} size="1400px">
|
||||
@@ -477,71 +482,19 @@
|
||||
class="flex flex-col lg:flex-row gap-y-1 justify-between w-full relative pb-4 gap-x-0.5"
|
||||
id="service-logs-date-pickers"
|
||||
>
|
||||
<div class="flex relative">
|
||||
<input
|
||||
type="text"
|
||||
value={minTsManual
|
||||
? new Date(minTsManual).toLocaleTimeString([], {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: 'min datetime'}
|
||||
disabled
|
||||
/>
|
||||
<CalendarPicker
|
||||
label="min datetime"
|
||||
date={minTsManual}
|
||||
on:change={({ detail }) => {
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
allLogs = undefined
|
||||
minTsManual = detail
|
||||
getAllLogs(minTsManual, maxTsManual)
|
||||
}}
|
||||
placement="top-start"
|
||||
/>
|
||||
</div>
|
||||
<ManuelDatePicker
|
||||
bind:minTs={() => minTsManual ?? null, (v) => (minTsManual = v ?? undefined)}
|
||||
bind:maxTs={() => maxTsManual ?? null, (v) => (maxTsManual = v ?? undefined)}
|
||||
bind:this={manualPicker}
|
||||
<TimeframeSelect
|
||||
items={serviceLogsTimeframes}
|
||||
bind:value={timeframe}
|
||||
{loading}
|
||||
on:loadJobs={() => {
|
||||
wrapperClasses="w-full"
|
||||
onClick={() => {
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
allLogs = undefined
|
||||
getAllLogs(minTsManual, maxTsManual)
|
||||
const ts = timeframe.computeMinMax()
|
||||
getAllLogs(ts.minTs ?? undefined, ts.maxTs ?? undefined)
|
||||
}}
|
||||
serviceLogsChoices
|
||||
loadText={searchTerm === '' ? 'Last 1000 logfiles' : 'All time'}
|
||||
/>
|
||||
<div class="flex relative">
|
||||
<input
|
||||
type="text"
|
||||
value={maxTsManual
|
||||
? new Date(maxTsManual).toLocaleTimeString([], {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
: 'max datetime'}
|
||||
disabled
|
||||
/>
|
||||
<CalendarPicker
|
||||
label="max datetime"
|
||||
date={maxTsManual}
|
||||
on:change={({ detail }) => {
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
allLogs = undefined
|
||||
maxTsManual = detail
|
||||
getAllLogs(minTsManual, maxTsManual)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex w-full flex-row-reverse pb-4 -mt-2 gap-2"
|
||||
><Toggle
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { RefreshCw } from 'lucide-svelte'
|
||||
import { Button } from '../common'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
interface Props {
|
||||
minTs: string | null
|
||||
maxTs: string | null
|
||||
loading?: boolean
|
||||
selectedManualDate?: number
|
||||
loadText?: string | undefined
|
||||
serviceLogsChoices?: boolean
|
||||
numberOfLastJobsToFetch?: number
|
||||
}
|
||||
|
||||
let {
|
||||
minTs = $bindable(),
|
||||
maxTs = $bindable(),
|
||||
loading = false,
|
||||
selectedManualDate = $bindable(0),
|
||||
loadText = undefined,
|
||||
serviceLogsChoices = false,
|
||||
numberOfLastJobsToFetch = 1000
|
||||
}: Props = $props()
|
||||
|
||||
export function computeMinMax(): { minTs: string; maxTs: string | null } | undefined {
|
||||
return manualDates[selectedManualDate].computeMinMax()
|
||||
}
|
||||
|
||||
export function resetChoice() {
|
||||
selectedManualDate = 0
|
||||
}
|
||||
|
||||
function computeMinMaxInc(inc: number) {
|
||||
let minTs = new Date(new Date().getTime() - inc).toISOString()
|
||||
let maxTs = null
|
||||
return { minTs, maxTs }
|
||||
}
|
||||
|
||||
const fixedManualDates: {
|
||||
label: string
|
||||
computeMinMax: () => { minTs: string; maxTs: string | null } | undefined
|
||||
}[] = [
|
||||
...(!serviceLogsChoices
|
||||
? [
|
||||
{
|
||||
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)
|
||||
}
|
||||
]
|
||||
|
||||
let manualDates = $derived([
|
||||
{
|
||||
label: loadText ?? `Last ${numberOfLastJobsToFetch} runs`,
|
||||
computeMinMax: () => {
|
||||
return undefined
|
||||
}
|
||||
},
|
||||
...fixedManualDates
|
||||
])
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
variant="default"
|
||||
on:click={() => {
|
||||
const ts = computeMinMax()
|
||||
if (ts) {
|
||||
minTs = ts.minTs
|
||||
maxTs = ts.maxTs
|
||||
}
|
||||
dispatch('loadJobs', { minTs, maxTs })
|
||||
}}
|
||||
dropdownItems={[
|
||||
...manualDates.map((d, i) => ({
|
||||
label: d.label,
|
||||
onClick: (e) => {
|
||||
e.preventDefault()
|
||||
selectedManualDate = i
|
||||
const ts = d.computeMinMax()
|
||||
if (ts) {
|
||||
minTs = ts.minTs
|
||||
maxTs = ts.maxTs
|
||||
} else {
|
||||
minTs = null
|
||||
maxTs = null
|
||||
}
|
||||
dispatch('loadJobs')
|
||||
}
|
||||
}))
|
||||
]}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<RefreshCw size={14} class={loading ? 'animate-spin' : ''} />
|
||||
{manualDates[selectedManualDate].label}
|
||||
</div>
|
||||
</Button>
|
||||
@@ -15,8 +15,6 @@
|
||||
.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),
|
||||
@@ -90,7 +88,6 @@
|
||||
| 'tag'
|
||||
| 'schedulePath'
|
||||
small?: boolean
|
||||
calendarSmall?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -116,9 +113,8 @@
|
||||
usernames = [],
|
||||
folders = [],
|
||||
allWorkspaces = $bindable(),
|
||||
filterBy = $bindable(),
|
||||
small = false,
|
||||
calendarSmall = false
|
||||
filterBy = $bindable('path'),
|
||||
small = false
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
@@ -526,7 +522,7 @@
|
||||
|
||||
<!-- Kind -->
|
||||
<RunOption label="Kind" for="kind">
|
||||
{#if small && !calendarSmall}
|
||||
{#if small}
|
||||
<Select
|
||||
items={[
|
||||
{ label: 'All', value: 'all' },
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
<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
|
||||
}
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { CalendarIcon, RefreshCw } from 'lucide-svelte'
|
||||
import { Button } from '../common'
|
||||
import Popover from '../meltComponents/Popover.svelte'
|
||||
import DateTimeInput from '../DateTimeInput.svelte'
|
||||
import Label from '../Label.svelte'
|
||||
import { formatDateRange } from '$lib/utils'
|
||||
import { watch } from 'runed'
|
||||
import { page } from '$app/state'
|
||||
|
||||
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>
|
||||
<Popover enableFlyTransition bind:isOpen>
|
||||
{#snippet trigger()}
|
||||
<Button
|
||||
unifiedSize="md"
|
||||
iconOnly
|
||||
btnClasses="!rounded-l-none border-l-0"
|
||||
endIcon={{ icon: CalendarIcon }}
|
||||
/>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
<div class="flex flex-col">
|
||||
{#each items as item}
|
||||
<Button
|
||||
onClick={() => ((value = { ...item }), (isOpen = false))}
|
||||
variant="subtle"
|
||||
unifiedSize="md"
|
||||
btnClasses="justify-start"
|
||||
>
|
||||
{item.label}
|
||||
</Button>
|
||||
{/each}
|
||||
<div class="border-b"></div>
|
||||
<div class="px-4 py-2 flex flex-col gap-2">
|
||||
<Label label="From">
|
||||
<DateTimeInput
|
||||
clearable
|
||||
bind:value={
|
||||
() => (value.type === 'manual' ? value.minTs : undefined),
|
||||
(v) => onManualInput({ minTs: v ?? null })
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
<Label label="To">
|
||||
<DateTimeInput
|
||||
clearable
|
||||
bind:value={
|
||||
() => (value.type === 'manual' ? value.maxTs : undefined),
|
||||
(v) => onManualInput({ maxTs: v ?? null })
|
||||
}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
@@ -16,6 +16,7 @@ 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'
|
||||
import type { Timeframe } from './TimeframeSelect.svelte'
|
||||
|
||||
export function computeJobKinds(jobKindsCat: string | null): string {
|
||||
if (jobKindsCat == 'all') {
|
||||
@@ -47,6 +48,7 @@ export function computeJobKinds(jobKindsCat: string | null): string {
|
||||
export interface UseJobLoaderArgs {
|
||||
currentWorkspace: string
|
||||
filters?: Partial<RunsFilters>
|
||||
timeframe?: Timeframe
|
||||
jobKinds?: string
|
||||
autoRefresh?: boolean
|
||||
argError?: string
|
||||
@@ -71,6 +73,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
let syncQueuedRunsCount = $derived(_args.syncQueuedRunsCount ?? true)
|
||||
let lookback = $derived(_args.lookback ?? 0)
|
||||
let onSetPerPage = $derived(_args.onSetPerPage)
|
||||
let timeframe = $derived(_args?.timeframe)
|
||||
|
||||
let label = $derived(filters?.label ?? null)
|
||||
let worker = $derived(filters?.worker ?? null)
|
||||
@@ -90,8 +93,6 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
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()
|
||||
@@ -158,6 +159,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
// const minCreated = lastJob?.created_at
|
||||
const minCreated = new Date(new Date(ts).getTime() - 1).toISOString()
|
||||
|
||||
const minTs = timeframe?.computeMinMax().minTs ?? null
|
||||
let olderJobs = await fetchJobs(minCreated, minTs, undefined)
|
||||
jobs = updateWithNewJobs(olderJobs ?? [], jobs ?? [])
|
||||
computeCompletedJobs()
|
||||
@@ -295,6 +297,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
intervalId = setInterval(syncer, refreshRate)
|
||||
}
|
||||
function loadJobsIntern(shouldGetCount?: boolean): CancelablePromise<void> {
|
||||
const { minTs, maxTs } = timeframe?.computeMinMax() ?? { minTs: null, maxTs: null }
|
||||
if (shouldGetCount) {
|
||||
getCount()
|
||||
}
|
||||
@@ -378,6 +381,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
getCount()
|
||||
}
|
||||
|
||||
const { minTs, maxTs } = timeframe?.computeMinMax() ?? { minTs: null, maxTs: null }
|
||||
if (jobs && maxTs == undefined) {
|
||||
if (success == 'running') {
|
||||
loadJobsIntern(false)
|
||||
@@ -519,6 +523,7 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
|
||||
Object.keys(filters ?? {}).map((k) => filters?.[k as keyof RunsFilters])
|
||||
currentWorkspace
|
||||
lookback
|
||||
timeframe
|
||||
let p = untrack(() => onParamChanges())
|
||||
return () => p.cancel()
|
||||
})
|
||||
|
||||
@@ -1569,6 +1569,114 @@ export function formatDateShort(dateString: string | undefined): string {
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats a date range intelligently by omitting redundant information.
|
||||
* Examples:
|
||||
* - Before 01/03 7:32 PM (no "after" when only before date is in the past)
|
||||
* - Before 01/03/2026 11:01 PM (different year)
|
||||
* - 01:12 AM to 02:50 AM (same day)
|
||||
* - 01/03 to 01/05 (same year, different days)
|
||||
* - 12/31/2025 to 01/05/2026 (different years)
|
||||
*
|
||||
* @param before - The start/before date (can be string or Date or undefined)
|
||||
* @param after - The end/after date (can be string or Date or undefined)
|
||||
* @returns Formatted string representing the date range
|
||||
*/
|
||||
export function formatDateRange(
|
||||
before: string | Date | undefined,
|
||||
after: string | Date | undefined
|
||||
): string {
|
||||
const now = new Date()
|
||||
const beforeDate = before ? new Date(before) : undefined
|
||||
const afterDate = after ? new Date(after) : undefined
|
||||
|
||||
// Helper to format time only
|
||||
const formatTime = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
// Helper to format date without year
|
||||
const formatDateNoYear = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
// Helper to format date with year
|
||||
const formatDateWithYear = (date: Date) => {
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
year: 'numeric'
|
||||
}).format(date)
|
||||
}
|
||||
|
||||
// Helper to check if dates are same day
|
||||
const isSameDay = (d1: Date, d2: Date) => {
|
||||
return (
|
||||
d1.getFullYear() === d2.getFullYear() &&
|
||||
d1.getMonth() === d2.getMonth() &&
|
||||
d1.getDate() === d2.getDate()
|
||||
)
|
||||
}
|
||||
|
||||
// Helper to check if date is in current year
|
||||
const isSameYear = (date: Date, reference: Date = now) => {
|
||||
return date.getFullYear() === reference.getFullYear()
|
||||
}
|
||||
|
||||
// Only before date provided
|
||||
if (beforeDate && !afterDate) {
|
||||
const timeStr = formatTime(beforeDate)
|
||||
|
||||
// If it's today, only show time
|
||||
if (isSameDay(beforeDate, now)) {
|
||||
return `Before ${timeStr}`
|
||||
}
|
||||
|
||||
const needsYear = !isSameYear(beforeDate)
|
||||
const dateStr = needsYear ? formatDateWithYear(beforeDate) : formatDateNoYear(beforeDate)
|
||||
return `Before ${dateStr} ${timeStr}`
|
||||
}
|
||||
|
||||
// Only after date provided
|
||||
if (afterDate && !beforeDate) {
|
||||
const timeStr = formatTime(afterDate)
|
||||
|
||||
// If it's today, only show time
|
||||
if (isSameDay(afterDate, now)) {
|
||||
return `After ${timeStr}`
|
||||
}
|
||||
|
||||
const needsYear = !isSameYear(afterDate)
|
||||
const dateStr = needsYear ? formatDateWithYear(afterDate) : formatDateNoYear(afterDate)
|
||||
return `After ${dateStr} ${timeStr}`
|
||||
}
|
||||
|
||||
// Both dates provided
|
||||
if (beforeDate && afterDate) {
|
||||
// Same day - only show times
|
||||
if (isSameDay(beforeDate, afterDate)) {
|
||||
return `${formatTime(beforeDate)} to ${formatTime(afterDate)}`
|
||||
}
|
||||
|
||||
// Different days, same year
|
||||
if (isSameYear(beforeDate, afterDate)) {
|
||||
return `${formatDateNoYear(beforeDate)} to ${formatDateNoYear(afterDate)}`
|
||||
}
|
||||
|
||||
// Different years
|
||||
return `${formatDateWithYear(beforeDate)} to ${formatDateWithYear(afterDate)}`
|
||||
}
|
||||
|
||||
// No dates provided
|
||||
return ''
|
||||
}
|
||||
|
||||
export function toJsonStr(result: any) {
|
||||
try {
|
||||
// console.log(result)
|
||||
|
||||
Reference in New Issue
Block a user