Compare commits

...

50 Commits

Author SHA1 Message Date
Diego Imbert
ba5334dfbd fix bug with maxTs 2026-02-11 14:54:52 +01:00
Diego Imbert
ddabba929d Merge branch 'main' into di/runs-page-redesign 2026-02-10 15:26:14 +01:00
Diego Imbert
8885dd96f6 dedup 'the' 2026-02-10 14:27:32 +01:00
Diego Imbert
25f27d1f52 unnecessary json stringify 2026-02-10 14:24:26 +01:00
Diego Imbert
3ff5261093 Update frontend/src/lib/components/RightClickPopover.svelte
Co-authored-by: claude[bot] <209825114+claude[bot]@users.noreply.github.com>
2026-02-10 14:20:43 +01:00
Diego Imbert
b2eb6ac0e3 naming clarity 2026-02-10 14:16:04 +01:00
Diego Imbert
186ebbf40f Merge branch 'main' into di/runs-page-redesign 2026-02-10 12:11:14 +01:00
Diego Imbert
db05dbb3ba typecheck 2026-02-10 11:56:25 +01:00
Diego Imbert
63e0f9e3f9 nits 2026-02-10 11:53:16 +01:00
Diego Imbert
e84c33420b dead code 2026-02-09 22:01:02 +01:00
Diego Imbert
19eb673951 nit fixes 2026-02-09 21:59:25 +01:00
Diego Imbert
b140526ec4 reset btn 2026-02-09 21:54:22 +01:00
Diego Imbert
151fc9a951 New Timeframe system 2026-02-09 21:48:45 +01:00
Diego Imbert
bbb74b1d2d nit 2026-02-09 14:26:33 +01:00
Diego Imbert
04333cd1d8 bar on splitpane hover 2026-02-09 14:21:28 +01:00
Diego Imbert
85dc2c030d nit 2026-02-09 14:19:17 +01:00
Diego Imbert
f6975e38d5 nit 2026-02-09 14:18:03 +01:00
Diego Imbert
08a21ad22f add batch actions back 2026-02-09 13:57:29 +01:00
Diego Imbert
8ea736236f nit 2026-02-09 11:37:30 +01:00
Diego Imbert
ec3cede8d1 Go to job / flow / script action 2026-02-09 11:34:23 +01:00
Diego Imbert
1b579df254 Cancel / re-run all filtered jobs 2026-02-09 11:16:02 +01:00
Diego Imbert
7a154146fe Cancel selected jobs 2026-02-09 11:03:20 +01:00
Diego Imbert
8570da3317 nit 2026-02-09 10:56:02 +01:00
Diego Imbert
de95f4f64d Batch reruns actions + nits 2026-02-09 10:52:59 +01:00
Diego Imbert
5e2cf436f1 nit fix scroll / height 2026-02-06 23:29:05 +01:00
Diego Imbert
c6f6746201 nit batch rerun fixes 2026-02-06 18:32:42 +01:00
Diego Imbert
2369a4353e keyboard nav 2026-02-06 18:25:08 +01:00
Diego Imbert
47edc8763a fix undefined error 2026-02-06 18:24:56 +01:00
Diego Imbert
e8d7940247 Improve UX 2026-02-06 17:15:18 +01:00
Diego Imbert
9c14d60855 error toasts pickhubscript 2026-02-06 16:45:08 +01:00
Diego Imbert
d2ee90e3da opacity UX 2026-02-06 16:23:44 +01:00
Diego Imbert
1091ac9e7e fix stuck keys 2026-02-06 16:10:17 +01:00
Diego Imbert
35c6052ff9 count hint 2026-02-06 16:02:58 +01:00
Diego Imbert
bd5528f445 nit 2026-02-06 15:43:28 +01:00
Diego Imbert
2bd0e18154 DropdownMenu 2026-02-06 15:36:41 +01:00
Diego Imbert
d230b52cf9 nit card 2026-02-06 15:12:31 +01:00
Diego Imbert
547470a13a Ctrl A 2026-02-06 15:07:53 +01:00
Diego Imbert
b675281da9 nit 2026-02-06 14:44:45 +01:00
Diego Imbert
05dc037b94 RightClickPopover 2026-02-05 18:54:27 +01:00
Diego Imbert
a98ac00c5e Shift select 2026-02-05 18:08:14 +01:00
Diego Imbert
33a03f48e6 command/meta multi select 2026-02-05 18:00:54 +01:00
Diego Imbert
5eac04e3de Remove code 2026-02-05 17:30:11 +01:00
Diego Imbert
6558efd58b nit 2026-02-05 17:24:31 +01:00
Diego Imbert
bacdbbbf20 prettier cards 2026-02-05 17:19:31 +01:00
Diego Imbert
0b8681cfce nit scrollbar bg 2026-02-05 16:40:37 +01:00
Diego Imbert
d290ff065f ui nits 2026-02-05 16:32:11 +01:00
Diego Imbert
5a961422c6 remove shadow 2026-02-05 16:02:37 +01:00
Diego Imbert
d6d68934b5 Remove manualdatepicker 2026-02-05 16:01:24 +01:00
Diego Imbert
c7370ec600 nit 2026-02-05 15:55:52 +01:00
Diego Imbert
7962b74ba0 RunsPage redesign v0 2026-02-05 15:54:25 +01:00
26 changed files with 1317 additions and 1224 deletions

View File

@@ -260,6 +260,6 @@
} as any)
</script>
<div class="relative max-h-40">
<div class="relative h-44">
<Line {data} {options} />
</div>

View File

@@ -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>

View File

@@ -0,0 +1,50 @@
<script lang="ts">
type Item = {
label: string
icon?: any
right?: string
onClick?: () => void
onHover?: (hover: boolean) => void
}
export type Props = {
closeCallback?: () => void
items: Item[]
}
let { items, closeCallback }: Props = $props()
</script>
<ul class="bg-surface-tertiary rounded-md border w-56 relative drop-shadow-base">
{#each items as item}
<li class="w-full">
<button
class="px-3 h-9 text-xs cursor-pointer hover:bg-surface-hover font-normal w-full text-left flex items-center gap-2.5"
onclick={() => {
item.onClick?.()
item.onHover?.(false)
closeCallback?.()
}}
onmouseenter={() => item.onHover?.(true)}
onmouseleave={() => item.onHover?.(false)}
>
{#if item.icon}
<item.icon size="16"></item.icon>
{/if}
<span class="flex-1">
{item.label}
</span>
{#if item.right}
<span class="text-xs text-hint">{item.right}</span>
{/if}
</button>
</li>
{/each}
{#if items.length === 0}
<li class="w-full">
<div
class="px-3 h-9 text-xs font-normal w-full text-left flex items-center gap-2.5 text-hint"
>
No actions available
</div>
</li>
{/if}
</ul>

View File

@@ -1,38 +0,0 @@
<script lang="ts">
import DropdownV2 from '$lib/components/DropdownV2.svelte'
import { ChevronDown } from 'lucide-svelte'
import { twMerge } from 'tailwind-merge'
import type { Item } from '$lib/utils'
interface Props {
items?: Item[]
extraLabel?: import('svelte').Snippet
selected: string
selectedDisplayName?: string
btnClasses?: string
}
let { items = [], extraLabel, selected, selectedDisplayName, btnClasses }: Props = $props()
const filteredItems = $derived(items.filter((item) => item.id !== selected))
</script>
<DropdownV2 items={filteredItems}>
{#snippet buttonReplacement()}
<div
class={twMerge(
'p-2 h-8 flex flex-row items-center gap-2 border hover:bg-surface-hover cursor-pointer rounded-md',
btnClasses
)}
>
<div class="flex flex-row items-center gap-1 pr-2 justify-between w-full">
<span class="text-xs whitespace-nowrap">
{selectedDisplayName ?? items.find((item) => item.id === selected)?.displayName ?? ''}
</span>
{@render extraLabel?.()}
</div>
<ChevronDown size={12} />
</div>
{/snippet}
</DropdownV2>

View File

@@ -156,7 +156,7 @@
variant="subtle"
startIcon={{ icon: EllipsisVertical }}
btnClasses="bg-transparent"
iconOnly
iconOnly={!btnText}
>
{btnText}
</Button>

View File

@@ -111,7 +111,6 @@
jobKinds: getJobKinds(runnableType),
syncQueuedRunsCount: false,
refreshRate: 10000,
computeMinAndMax: undefined,
currentWorkspace: $workspaceStore ?? '',
skip: !runnableId
}) satisfies UseJobLoaderArgs

View File

@@ -0,0 +1,46 @@
<script lang="ts">
import { clickOutside } from '$lib/utils'
import { fly } from 'svelte/transition'
import Portal from './Portal.svelte'
import type { Snippet } from 'svelte'
type Props = {
children: Snippet
}
const { children }: Props = $props()
let _isOpen = $state(false)
let mousePos = $state({ x: 0, y: 0 })
export function open(e: MouseEvent) {
e.preventDefault()
_isOpen = true
mousePos = { x: e.clientX, y: e.clientY }
mousePos = { x: e.clientX, y: e.clientY }
}
export function close() {
_isOpen = false
}
export function isOpen() {
return _isOpen
}
</script>
<Portal>
{#if _isOpen}
<div
in:fly={{ x: 0, y: -10, duration: 120 }}
use:clickOutside={{
onClickOutside: (e) => {
_isOpen = false
e.preventDefault()
e.stopPropagation()
}
}}
class="absolute left-0 top-0 z-[9999] w-fit"
style="transform: translate({mousePos.x + 2}px, {mousePos.y + 2}px)"
>
{@render children()}
</div>
{/if}
</Portal>

View File

@@ -1,7 +1,6 @@
<script lang="ts">
import 'chartjs-adapter-date-fns'
import zoomPlugin from 'chartjs-plugin-zoom'
import Tooltip2 from '$lib/components/Tooltip.svelte'
import {
Chart as ChartJS,
Title,
@@ -17,7 +16,6 @@
} from 'chart.js'
import type { CompletedJob } from '$lib/gen'
import { getDbClockNow } from '$lib/forLater'
import Button from './common/button/Button.svelte'
import { Scatter } from '$lib/components/chartjs-wrappers/chartJs'
import DarkModeObserver from './DarkModeObserver.svelte'
@@ -28,10 +26,7 @@
maxTimeSet?: string | null
selectedIds?: string[]
canSelect?: boolean
lastFetchWentToEnd?: boolean
totalRowsFetched: number
onPointClicked: (ids: string[]) => void
onLoadExtra: () => void
onZoom: (zoom: { min: Date; max: Date }) => void
}
@@ -42,10 +37,7 @@
maxTimeSet = null,
selectedIds = $bindable([]),
canSelect = true,
lastFetchWentToEnd = false,
totalRowsFetched,
onPointClicked,
onLoadExtra,
onZoom
}: Props = $props()
@@ -299,23 +291,6 @@
<DarkModeObserver bind:darkMode />
<!-- {JSON.stringify(minTime)}
{JSON.stringify(maxTime)}
{JSON.stringify(jobs?.map((x) => x.started_at))} -->
<!-- {minTime}
{maxTime} -->
<!-- {JSON.stringify(jobs?.map((x) => x.started_at))} -->
<div class="relative max-h-40">
{#if !lastFetchWentToEnd}
<div class="absolute top-[-28px] left-[220px]">
<Button size="xs" color="transparent" variant="contained" on:click={() => onLoadExtra()}>
Load more
<Tooltip2>
There are more jobs to load but only the first {totalRowsFetched} were fetched
</Tooltip2>
</Button>
</div>
{/if}
<div class="relative h-44">
<Scatter {data} options={scatterOptions} />
</div>

View File

@@ -11,12 +11,11 @@
import { sendUserToast } from '$lib/toast'
import { userStore, workspaceStore, userWorkspaces } from '$lib/stores'
import { Button, Drawer, DrawerContent, Skeleton } from '$lib/components/common'
import { Button, Drawer, DrawerContent, Skeleton, Tab, Tabs } from '$lib/components/common'
import RunChart from '$lib/components/RunChart.svelte'
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'
@@ -25,27 +24,26 @@
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
import RunsQueue from '$lib/components/runs/RunsQueue.svelte'
import { twMerge } from 'tailwind-merge'
import ManuelDatePicker from '$lib/components/runs/ManuelDatePicker.svelte'
import { computeJobKinds, useJobsLoader } from '$lib/components/runs/useJobsLoader.svelte'
import { Calendar, Clock, TriangleAlert } from 'lucide-svelte'
import ConcurrentJobsChart from '$lib/components/ConcurrentJobsChart.svelte'
import { isJobSelectable, type RunsSelectionMode } from '$lib/utils'
import { pluralize } from '$lib/utils'
import BatchReRunOptionsPane, {
type BatchReRunOptions
} from '$lib/components/runs/BatchReRunOptionsPane.svelte'
import { untrack } from 'svelte'
import { page } from '$app/state'
import RunOption from '$lib/components/runs/RunOption.svelte'
import TooltipV2 from '$lib/components/meltComponents/Tooltip.svelte'
import DropdownSelect from '$lib/components/DropdownSelect.svelte'
import RunsBatchActionsDropdown from '$lib/components/runs/RunsBatchActionsDropdown.svelte'
import { createBubbler } from 'svelte/legacy'
import ToggleButtonGroup from '$lib/components/common/toggleButton-v2/ToggleButtonGroup.svelte'
import ToggleButton from '$lib/components/common/toggleButton-v2/ToggleButton.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 { CalendarIcon, ClockIcon, TriangleAlertIcon } from 'lucide-svelte'
import DropdownV2 from './DropdownV2.svelte'
import TimeframeSelect, {
buildManualTimeframe,
runsTimeframes,
useUrlSyncedTimeframe
} from './runs/TimeframeSelect.svelte'
import RunOption from './runs/RunOption.svelte'
interface Props {
/** Initial path from route params (e.g., /runs/u/user/script) */
@@ -54,6 +52,7 @@
let { initialPath }: Props = $props()
let batchRerunOptionsIsOpen = $state(false)
let filters = useSearchParams(runsFiltersSchema)
// Initialize path filter from route param if provided and not already set via query params
@@ -72,11 +71,8 @@
}
let selectedIds: string[] = $state([])
let loadingSelectedIds = $state(false)
let selectedWorkspace: string | undefined = $state(undefined)
let batchReRunOptions: BatchReRunOptions = $state({ flow: {}, script: {} })
let jobKinds: string | undefined = $derived(computeJobKinds(filters.job_kinds))
let paths: string[] = $state([])
let usernames: string[] = $state([])
@@ -84,7 +80,6 @@
let argError = $state('')
let resultError = $state('')
let filterTimeout: ReturnType<typeof setInterval> | undefined = undefined
let selectedManualDate = $state(0)
let autoRefresh: boolean = $state(getAutoRefresh())
let runDrawer: Drawer | undefined = $state(undefined)
let lookback: number = $state(1)
@@ -126,28 +121,29 @@
}
}
let manualDatePicker: ManuelDatePicker | undefined = $state(undefined)
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 graphIsRunsChart: boolean = $state(untrack(() => graph) === 'RunChart')
let innerWidth = $state(window.innerWidth)
let jobsLoader = useJobsLoader(() => ({
filters,
computeMinAndMax: manualDatePicker?.computeMinMax,
timeframe,
jobKinds,
autoRefresh,
argError,
resultError,
lookback: graphIsRunsChart ? 0 : lookback,
lookback: graph === 'RunChart' ? 0 : lookback,
onSetPerPage: (p) => (filters.per_page = p),
onSetMinMaxTs: (minTs, maxTs) => ((filters.min_ts = minTs), (filters.max_ts = maxTs)),
currentWorkspace: $workspaceStore ?? ''
}))
let lastFetchWentToEnd = $derived(jobsLoader.lastFetchWentToEnd)
let queue_count = $derived(jobsLoader.queue_count)
let suspended_count = $derived(jobsLoader.suspended_count)
let loading = $derived(jobsLoader.loading)
let externalJobs = $derived(jobsLoader.externalJobs)
let extendedJobs = $derived(jobsLoader.extendedJobs)
// Avoid flicker, but still show empty if loading takes too long
@@ -168,13 +164,9 @@
}
function reset() {
filters.min_ts = null
filters.max_ts = null
selectedManualDate = 0
_timeframe.timeframe = { ...runsTimeframes[0] }
selectedIds = []
filters.schedule_path = null
batchReRunOptions = { flow: {}, script: {} }
selectionMode = false
selectedWorkspace = undefined
jobsLoader?.loadJobs(true)
}
@@ -229,34 +221,14 @@
}
}
let selectionMode: RunsSelectionMode | false = $state(false)
async function onSetSelectionMode(mode: RunsSelectionMode | false) {
selectionMode = mode
if (!mode) {
selectedIds = []
batchReRunOptions = { flow: {}, script: {} }
return
}
const selectableIds = jobs?.filter(isJobSelectable(mode)).map((j) => j.id) ?? []
selectedIds = []
if (!selectableIds?.length) {
sendUserToast(
'There are no visible jobs that can be ' +
{ cancel: 'cancelled', 're-run': 're-ran' }[mode],
true
)
}
}
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,
@@ -320,10 +292,9 @@
selectedIds = []
jobsLoader?.loadJobs(true, true)
sendUserToast(`Canceled ${uuids.length} jobs`)
selectionMode = false
}
async function onCancelFilteredJobs() {
async function onCancelAllJobsMatchingFilters() {
forceCancelInPopup = false
askingForConfirmation = {
title: 'Confirm cancelling all jobs corresponding to the selected filters',
@@ -345,18 +316,18 @@
}
}
async function onCancelSelectedJobs() {
async function onCancelSelectedJobs(jobIdsToCancel: string[]) {
forceCancelInPopup = true
askingForConfirmation = {
confirmBtnText: `Cancel ${selectedIds.length} jobs`,
title: 'Confirm cancelling the selected jobs',
confirmBtnText: `Cancel ${jobIdsToCancel.length} jobs`,
title: `Confirm cancelling ${jobIdsToCancel.length} jobs`,
onConfirm: (forceCancel) => {
cancelJobs(selectedIds, forceCancel)
cancelJobs(jobIdsToCancel, forceCancel)
}
}
}
async function reRunJobs(jobIdsToReRun: string[]) {
async function reRunJobs(jobIdsToReRun: string[], batchReRunOptions: BatchReRunOptions) {
if (!$workspaceStore) return
if (askingForConfirmation) {
@@ -365,8 +336,8 @@
const body: Parameters<typeof JobService.batchReRunJobs>[0]['requestBody'] = {
job_ids: jobIdsToReRun,
script_options_by_path: batchReRunOptions.script,
flow_options_by_path: batchReRunOptions.flow
script_options_by_path: batchReRunOptions.script ?? {},
flow_options_by_path: batchReRunOptions.flow ?? {}
}
// workaround because EventSource does not support POST requests
@@ -424,42 +395,40 @@
})
selectedIds = []
batchReRunOptions = { flow: {}, script: {} }
jobsLoader?.loadJobs(true, true)
selectionMode = false
}
async function onReRunFilteredJobs() {
async function onRerunAllJobsMatchingFilters() {
const selectedFilters = getSelectedFilters()
selectedIds = []
loadingSelectedIds = true
const loadingToast = sendUserToast('Loading job ids', 'info')
if (filters.job_kinds !== 'runs') {
sendUserToast('Batch re-run is only supported for scripts and flows', true)
loadingToast.destroy()
return
}
selectedIds = await JobService.listFilteredJobsUuids({
...selectedFilters,
jobKinds: 'script,flow'
})
selectionMode = 're-run'
loadingToast.destroy()
batchRerunOptionsIsOpen = true
}
async function onReRunSelectedJobs() {
async function onReRunSelectedJobs(batchReRunOptions: BatchReRunOptions) {
const jobIdsToReRun = selectedIds
askingForConfirmation = {
title: `Confirm re-running the selected jobs`,
confirmBtnText: `Re-run ${jobIdsToReRun.length} jobs`,
type: 'reload',
onConfirm: async () => {
await reRunJobs(jobIdsToReRun)
await reRunJobs(jobIdsToReRun, batchReRunOptions)
}
}
}
function setLookback(lookbackInDays: number) {
lookback = lookbackInDays
}
async function loadExtra() {
await jobsLoader?.loadExtraJobs()
}
@@ -481,9 +450,6 @@
filters.job_kinds = 'all'
}
$effect(() => {
loadingSelectedIds && selectedIds.length && setTimeout(() => (loadingSelectedIds = false), 250)
})
$effect(() => {
if ($workspaceStore) {
untrack(() => {
@@ -502,30 +468,6 @@
)
})
const bubble = createBubbler()
function selectAll() {
if (!selectionMode) return
if (allSelected) {
allSelected = false
selectedIds = []
} else {
allSelected = true
selectedIds = jobs?.filter(isJobSelectable(selectionMode)).map((j) => j.id) ?? []
}
}
let allSelected = $derived.by(() => {
return selectionMode && selectedIds.length === selectableJobCount
})
const selectableJobCount = $derived.by(() => {
if (!selectionMode) return 0
return jobs?.filter(isJobSelectable(selectionMode)).length ?? 0
})
let tableTopBarWidth = $state(0)
const smallScreenWidth = 1920
const verySmallScreenWidth = 1300
@@ -534,6 +476,8 @@
const warnJobLimitMsg = $derived(
`The exact number of concurrent jobs at the beginning of the time range may be incorrect as only the last ${filters.per_page} jobs are taken into account: a job that was started earlier than this limit will not be taken into account`
)
let manualSelectionMode: undefined | 'cancel' | 'rerun' = $state()
</script>
<ConfirmationModal
@@ -603,7 +547,7 @@
{:else}
<div class="w-full h-screen flex flex-col" bind:clientWidth={innerWidth}>
<!-- Header and filters -->
<div class="flex flex-row items-start w-full border-b px-4 gap-8">
<div class="flex flex-row items-start w-full px-4 gap-8">
<div class="flex flex-row items-center h-full gap-6">
<div class="flex flex-row items-center gap-1">
<h1
@@ -638,61 +582,19 @@
/>
</div>
<div class="py-2 flex items-start gap-x-4 gap-y-2 flex-row grow min-w-0 justify-end">
<div class="py-2 flex items-start gap-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-secondary 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>
<RunOption label="Reset" for="reset" noLabel>
<Button variant="default" unifiedSize="md" onClick={reset}>Reset</Button>
</RunOption>
<RunOption label="Timeframe" for="timeframe" noLabel>
<TimeframeSelect
onClick={() => jobsLoader?.loadJobs(true)}
loading={jobsLoader?.loading}
items={runsTimeframes}
bind:value={_timeframe.timeframe}
/>
</RunOption>
<!-- Filters 1 -->
<div class="flex flex-row gap-2">
@@ -716,100 +618,58 @@
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>
</div>
<!-- Graph -->
<div class="p-2 px-4 pt-8 w-full border-b">
<div class="relative z-10">
<div class="absolute left-0 -top-7 flex flex-row gap-2 items-center min-w-24">
<ToggleButtonGroup
selected={graph}
on:selected={({ detail }) => {
graph = detail
graphIsRunsChart = graph === 'RunChart'
}}
>
{#snippet children({ item })}
<ToggleButton value="RunChart" label="Duration" {item} />
<ToggleButton
{item}
value="ConcurrencyChart"
label="Concurrency"
icon={warnJobLimit ? TriangleAlert : undefined}
tooltip={warnJobLimit ? warnJobLimitMsg : undefined}
/>
<div class="p-2 px-4 bg-surface-tertiary mx-4 mt-2 border rounded-md">
<div class="relative z-10 mb-2 flex gap-2">
<Tabs bind:selected={graph}>
<Tab value="RunChart" label="Duration" />
<Tab value="ConcurrencyChart" label="Concurrency">
{#snippet extra()}
{#if warnJobLimit}
<Tooltip Icon={TriangleAlertIcon}>{warnJobLimitMsg}</Tooltip>
{/if}
{/snippet}
</ToggleButtonGroup>
</Tab>
</Tabs>
{#if !graphIsRunsChart}
<DropdownSelect
items={[
{
displayName: 'None',
action: () => setLookback(0),
id: '0'
},
{
displayName: '1 day',
action: () => setLookback(1),
id: '1'
},
{
displayName: '3 days',
action: () => setLookback(3),
id: '3'
},
{
displayName: '7 days',
action: () => setLookback(7),
id: '7'
}
]}
selected={lookback.toString()}
selectedDisplayName={`${lookback} days lookback`}
>
{#snippet extraLabel()}
<TooltipV2>
{#snippet text()}
How far behind the min datetime to start considering jobs for the concurrency
graph. Change this value to include jobs started before the set time window for
the computation of the graph
{/snippet}
</TooltipV2>
{/snippet}
</DropdownSelect>
{/if}
</div>
{#if graph !== 'RunChart'}
<Select
class="ml-2"
bind:value={lookback}
items={[
{ label: 'None', value: 0 },
{ label: '1 day', value: 1 },
{ label: '3 days', value: 3 },
{ label: '7 days', value: 7 }
]}
transformInputSelectedText={(_, v) => `${pluralize(v, 'day')} lookback`}
tooltip={'How far behind the min datetime to start considering jobs for the concurrency graph. Change this value to include jobs started before the set time window for the computation of the graph'}
/>
{/if}
</div>
{#if graph === 'RunChart'}
{@const manualTimeframe = timeframe.type === 'manual' ? timeframe : undefined}
<RunChart
{lastFetchWentToEnd}
bind:selectedIds
canSelect={!selectionMode}
minTimeSet={filters.min_ts}
maxTimeSet={filters.max_ts}
totalRowsFetched={jobs?.length ?? 0}
maxIsNow={filters.max_ts == undefined}
onLoadExtra={loadExtra}
canSelect
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()
manualDatePicker?.resetChoice()
_timeframe.timeframe = buildManualTimeframe(
zoom.min.toISOString(),
zoom.max.toISOString()
)
jobsLoader?.loadJobs(true)
}}
onPointClicked={(ids) => {
@@ -818,78 +678,112 @@
/>
{: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)
}}
/>
{/if}
</div>
<div class="grow min-h-0">
<div class="grow min-h-0 bottom-splitpane-wrapper">
<Splitpanes>
<Pane minSize={40}>
<div class="flex flex-col h-full">
<!-- Runs table top bar -->
<div
class="flex flex-row gap-4 items-center px-2 py-1 grow-0 justify-between"
bind:clientWidth={tableTopBarWidth}
>
<div class="flex flex-row gap-4 items-center">
{#if selectionMode && selectableJobCount}
<div class="flex flex-row items-center font-semibold text-sm">
<div class="px-2">
<input
onfocus={bubble('focus')}
type="checkbox"
checked={allSelected}
id="select-all"
class={twMerge(
'cursor-pointer',
allSelected ? 'bg-blue-50 dark:bg-blue-900/50' : '',
'flex flex-row items-center p-2 pr-4 top-0 font-semibold text-sm'
)}
onclick={selectAll}
/>
</div>
<label
class="cursor-pointer whitespace-nowrap text-xs text-emphasis font-semibold"
for="select-all">Select all</label
>
<div class="h-full flex">
<div class="flex flex-col flex-1 m-4 mr-2">
<!-- Runs table. Add overflow-hidden because scroll is handled inside the runs table based on this wrapper height -->
<div class="grow min-h-0 overflow-y-hidden overflow-x-auto">
{#if jobs}
<RunsTable
{jobs}
externalJobs={externalJobs ?? []}
omittedObscuredJobs={extendedJobs?.omitted_obscured_jobs ?? false}
showExternalJobs={graph !== 'RunChart'}
activeLabel={filters.label}
{lastFetchWentToEnd}
bind:selectedIds
bind:selectedWorkspace
on:loadExtra={loadExtra}
on:filterByPath={filterByPath}
on:filterByUser={filterByUser}
on:filterByFolder={filterByFolder}
on:filterByLabel={filterByLabel}
on:filterByConcurrencyKey={filterByConcurrencyKey}
on:filterByTag={filterByTag}
on:filterBySchedule={filterBySchedule}
on:filterByWorker={filterByWorker}
bind:this={runsTable}
perPage={filters.per_page}
bind:batchRerunOptionsIsOpen
onCancelJobs={onCancelSelectedJobs}
{manualSelectionMode}
></RunsTable>
{:else}
<div class="gap-1 flex flex-col">
{#each new Array(8) as _}
<Skeleton layout={[[3]]} />
{/each}
</div>
{/if}
<RunsBatchActionsDropdown
isLoading={loadingSelectedIds}
{selectionMode}
selectionCount={selectedIds.length}
{onSetSelectionMode}
{onCancelFilteredJobs}
{onCancelSelectedJobs}
{onReRunFilteredJobs}
{onReRunSelectedJobs}
small={tableTopBarWidth < 800}
/>
</div>
<div class="flex flex-row gap-4 items-center">
<div
class="bg-surface-tertiary border rounded-b-md flex text-xs px-2 py-1 items-center gap-4"
>
{#if !manualSelectionMode}
<DropdownV2
btnText="Batch actions"
size="xs"
items={[
{
displayName: 'Cancel jobs',
action: () => ((manualSelectionMode = 'cancel'), (selectedIds = []))
},
{
displayName: 'Re-run jobs',
action: () => {
manualSelectionMode = 'rerun'
selectedIds = []
batchRerunOptionsIsOpen = true
}
},
{
displayName: 'Cancel all jobs matching filters',
action: () => onCancelAllJobsMatchingFilters()
},
{
displayName: 'Re-run all jobs matching filters',
action: () => onRerunAllJobsMatchingFilters()
}
]}
/>
{:else}
<Button
size="xs"
destructive
onClick={() => {
manualSelectionMode = undefined
batchRerunOptionsIsOpen = false
}}
>
Exit selection mode
</Button>
{/if}
<div class="flex-1"></div>
{#if !filters.job_trigger_kind}
<div class="flex flex-row gap-1 items-center">
<Toggle
id="cron-schedules"
bind:checked={filters.show_schedules}
options={tableTopBarWidth < 800 || selectionMode
? {}
: { right: 'Schedules' }}
options={{ right: 'Schedules' }}
/>
<span title="Schedules">
<Calendar size="14" />
</span>
<span title="Schedules"><CalendarIcon size="14" /></span>
</div>
{/if}
@@ -898,117 +792,101 @@
size="sm"
bind:checked={filters.show_future_jobs}
id="planned-later"
options={tableTopBarWidth < 800 || selectionMode
? {}
: { right: 'Planned later' }}
options={{ right: 'Planned later' }}
/>
<span title="Planned later">
<Clock size={14} />
</span>
<span title="Planned later"><ClockIcon size={14} /></span>
</div>
<div class="flex flex-row gap-2 items-center">
<ManuelDatePicker
on:loadJobs={() => {
jobsLoader?.loadJobs(true)
}}
bind:minTs={filters.min_ts}
bind:maxTs={filters.max_ts}
bind:selectedManualDate
{loading}
bind:this={manualDatePicker}
numberOfLastJobsToFetch={filters.per_page}
/>
<Toggle
size="sm"
bind:checked={autoRefresh}
on:change={() => {
localStorage.setItem('auto_refresh_in_runs', autoRefresh ? 'true' : 'false')
}}
options={{ right: 'Auto-refresh' }}
textClass="whitespace-nowrap"
/>
</div>
</div>
</div>
<!-- Runs table. Add overflow-hidden because scroll is handled inside the runs table based on this wrapper height -->
<div class="grow min-h-0 overflow-y-hidden overflow-x-auto">
{#if jobs}
<RunsTable
{jobs}
externalJobs={externalJobs ?? []}
omittedObscuredJobs={extendedJobs?.omitted_obscured_jobs ?? false}
showExternalJobs={!graphIsRunsChart}
activeLabel={filters.label}
{selectionMode}
{lastFetchWentToEnd}
bind:selectedIds
bind:selectedWorkspace
on:loadExtra={loadExtra}
on:filterByPath={filterByPath}
on:filterByUser={filterByUser}
on:filterByFolder={filterByFolder}
on:filterByLabel={filterByLabel}
on:filterByConcurrencyKey={filterByConcurrencyKey}
on:filterByTag={filterByTag}
on:filterBySchedule={filterBySchedule}
on:filterByWorker={filterByWorker}
bind:this={runsTable}
perPage={filters.per_page}
></RunsTable>
{:else}
<div class="gap-1 flex flex-col">
{#each new Array(8) as _}
<Skeleton layout={[[3]]} />
{/each}
</div>
{/if}
</div>
<div
class="bg-surface-secondary border-t flex text-xs px-2 py-1 items-center justify-end gap-2"
>
Per page:
<Select
class="w-20"
bind:value={
() => filters.per_page,
(newPerPage) => {
filters.per_page = newPerPage
if (newPerPage > (jobs?.length ?? 1000)) loadExtra()
<Toggle
size="xs"
bind:checked={autoRefresh}
on:change={() => {
localStorage.setItem('auto_refresh_in_runs', autoRefresh ? 'true' : 'false')
}}
options={{ right: 'Auto-refresh' }}
textClass="whitespace-nowrap"
/>
<Select
class="w-24"
bind:value={
() => filters.per_page,
(newPerPage) => {
filters.per_page = newPerPage
if (newPerPage > (jobs?.length ?? 1000)) loadExtra()
}
}
}
onCreateItem={(v) => (filters.per_page = parseInt(v))}
items={[
{ value: 25, label: '25' },
{ value: 100, label: '100' },
{ value: 1000, label: '1000' },
{ value: 10000, label: '10000' }
]}
/>
onCreateItem={(v) => (filters.per_page = parseInt(v))}
items={[
{ value: 25, label: '25' },
{ value: 100, label: '100' },
{ value: 1000, label: '1000' },
{ value: 10000, label: '10000' }
]}
transformInputSelectedText={(_, v) => `${v} / page`}
/>
</div>
</div>
</div>
</Pane>
<AnimatedPane size={40} minSize={15} class="flex flex-col" opened={selectedIds.length > 0}>
{#if selectionMode === 're-run'}
<BatchReRunOptionsPane {selectedIds} bind:options={batchReRunOptions} />
{:else if selectedIds.length === 1}
{#if selectedIds[0] === '-'}
<div class="p-4">There is no information available for this job</div>
{:else}
<JobRunsPreview
id={selectedIds[0]}
workspace={selectedWorkspace}
on:filterByConcurrencyKey={filterByConcurrencyKey}
on:filterByWorker={filterByWorker}
<AnimatedPane
size={40}
minSize={15}
class="flex flex-col"
opened={selectedIds.length > 0 || !!manualSelectionMode}
>
<div class="mt-14 overflow-y-auto pr-4 ml-2 relative flex-1">
{#if manualSelectionMode === 'cancel'}
<div
class="rounded-md bg-surface-tertiary border absolute inset-0 mb-4 flex flex-col items-center justify-center"
>
<Button
destructive
variant="accent"
disabled={!selectedIds.length}
onClick={() => onCancelSelectedJobs(selectedIds)}
>
Cancel {selectedIds.length} jobs
</Button>
</div>
{:else if batchRerunOptionsIsOpen}
<BatchReRunOptionsPane
{selectedIds}
onCancel={() => (
(batchRerunOptionsIsOpen = false),
(manualSelectionMode = undefined)
)}
onConfirm={async (options) => {
await onReRunSelectedJobs(options)
}}
/>
{:else if selectedIds.length === 1}
{#if selectedIds[0] === '-'}
<div class="p-4">There is no information available for this job</div>
{:else}
<JobRunsPreview
id={selectedIds[0]}
workspace={selectedWorkspace}
on:filterByConcurrencyKey={filterByConcurrencyKey}
on:filterByWorker={filterByWorker}
/>
{/if}
{:else if selectedIds.length > 1}
<div
class="rounded-md bg-surface-tertiary border absolute inset-0 mb-4 flex items-center justify-center"
>
<div class="text-xs m-4"> {selectedIds.length} jobs selected</div>
</div>
{/if}
{:else if selectedIds.length > 1}
<div class="text-xs m-4"
>There are {selectedIds.length} jobs selected. Choose 1 to see detailed information</div
>
{/if}
</div>
</AnimatedPane>
</Splitpanes>
</div>
</div>
{/if}
<style>
:global(.bottom-splitpane-wrapper .splitpanes__splitter) {
background-color: transparent !important;
border: none !important;
/* opacity: 0 !important; */
}
</style>

View File

@@ -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

View File

@@ -19,6 +19,7 @@
markdownTooltip?: string | undefined
customSize?: string
class?: string
Icon?: typeof InfoIcon
children?: import('svelte').Snippet
}
@@ -31,6 +32,7 @@
markdownTooltip = undefined,
customSize = '100%',
class: classNames = '',
Icon = InfoIcon,
children
}: Props = $props()
const plugins = [gfmPlugin()]
@@ -53,7 +55,7 @@
? 'text-primary-inverse'
: 'text-primary'} {classNames} relative"
>
<InfoIcon class="{small ? 'bottom-0' : '-bottom-0.5'} absolute" size={small ? 12 : 14} />
<Icon class="{small ? 'bottom-0' : '-bottom-0.5'} absolute" size={small ? 12 : 14} />
</div>
{#snippet text()}
{#if markdownTooltip}

View File

@@ -34,9 +34,11 @@ export async function copyFirstStepSchema(
})
return
}
return sendUserToast('Only scripts can be used as a input schema', true)
sendUserToast('Only scripts can be used as a input schema', true)
return
}
return sendUserToast('No first step found', true)
sendUserToast('No first step found', true)
return
}
export async function getFirstStepSchema(flowState: FlowState, flow: OpenFlow) {

View File

@@ -24,7 +24,7 @@
[])
: undefined
} catch (err) {
console.error('Error fetching top hub scripts')
sendUserToast('Failed to fetch hub scripts: ' + err, 'error')
return undefined
}
},
@@ -38,7 +38,7 @@
<script lang="ts">
import { createEventDispatcher, untrack } from 'svelte'
import { Skeleton } from '$lib/components/common'
import { classNames, createCache } from '$lib/utils'
import { classNames, createCache, sendUserToast } from '$lib/utils'
import { APP_TO_ICON_COMPONENT } from '$lib/components/icons'
import { IntegrationService, ScriptService, type HubScriptKind } from '$lib/gen'
import { Circle, ExternalLink } from 'lucide-svelte'
@@ -100,7 +100,7 @@
(x) => x.name
)
} catch (err) {
console.error('Hub is not available')
sendUserToast('Failed to fetch hub integrations: ' + err, 'error')
allApps = []
hubNotAvailable = true
}
@@ -144,7 +144,7 @@
try {
await ScriptService.pickHubScriptByPath({ path: item.path })
} catch (error) {
console.error('Failed to track hub script pick:', error)
sendUserToast('Failed to call ScriptService.pickHubScriptByPath: ' + error, 'error')
// Don't block the flow if tracking fails
}
}

View File

@@ -17,7 +17,7 @@
import type { Schema } from '$lib/common'
import InputTransformForm from '../InputTransformForm.svelte'
import type { FlowPropPickerConfig, PropPickerContext } from '../prop_picker'
import { setContext, untrack } from 'svelte'
import { setContext } from 'svelte'
import { writable } from 'svelte/store'
import type { PickableProperties } from '../flows/previousResults'
import Alert from '../common/alert/Alert.svelte'
@@ -27,27 +27,34 @@
mergeSchemasForBatchReruns
} from '$lib/components/jobs/batchReruns'
import Toggle from '../Toggle.svelte'
import { TriangleAlert } from 'lucide-svelte'
import { RefreshCwIcon, TriangleAlert } from 'lucide-svelte'
import { readFieldsRecursively } from '$lib/utils'
import Button from '../common/button/Button.svelte'
import ResizeTransitionWrapper from '../common/ResizeTransitionWrapper.svelte'
import { resource, watch } from 'runed'
let {
selectedIds,
options = $bindable()
onCancel,
onConfirm
}: {
selectedIds: string[]
options: BatchReRunOptions
onCancel: () => void
onConfirm: (options: BatchReRunOptions) => void
} = $props()
let selected: JobGroup | undefined = $state()
$effect(() => {
jobGroupsPromise.then((jobGroups) => {
let options: BatchReRunOptions = $state({ flow: {}, script: {} })
watch(
() => jobGroups.current,
() => {
selected = selected
? jobGroups.find((g) => g.script_path === selected?.script_path && g.kind === selected.kind)
: jobGroups[0]
})
})
? jobGroups.current?.find(
(g) => g.script_path === selected?.script_path && g.kind === selected.kind
)
: jobGroups.current?.[0]
}
)
setContext<PropPickerContext>('PropPickerContext', {
flowPropPickerConfig: writable<FlowPropPickerConfig | undefined>(undefined),
@@ -108,7 +115,7 @@
group.schemas.find((s) => s.script_hash === jobSchema.script_hash) ??
group.schemas[
group.schemas.push({
schema: jobSchema.schema as Schema,
schema: (jobSchema.schema as Schema) ?? {},
job_ids: [],
script_hash: jobSchema.script_hash
}) - 1
@@ -130,7 +137,7 @@
}
function propertyAlwaysExists(propertyName: string, group: JobGroup): boolean {
for (const s of group.schemas) {
if (!(propertyName in (s.schema as Schema).properties)) return false
if (!(propertyName in ((s.schema as Schema)?.properties ?? {}))) return false
}
return true
}
@@ -138,7 +145,7 @@
function propertyAlwaysHasSameType(propertyName: string, group: JobGroup): boolean {
let prevType = 'INIT'
for (const s of group.schemas) {
const currType = (s.schema as Schema).properties[propertyName]?.type
const currType = (s.schema as Schema)?.properties?.[propertyName]?.type
if (currType === undefined) continue
if (prevType !== 'INIT' && currType !== prevType) return false
prevType = currType
@@ -152,25 +159,23 @@
(options[selected.kind][selected.script_path]?.use_latest_version ?? false))
)
const jobGroupsPromise = $derived.by(() => {
readFieldsRecursively(selectedIds)
return untrack(() => fetchJobGroups())
})
const jobGroups = resource(() => readFieldsRecursively(selectedIds), fetchJobGroups)
let hideRunnableSelector = $derived(!(jobGroups.current?.length !== 1 && selectedIds.length > 1))
</script>
<div class="flex-1 flex flex-col">
<p class="ml-4 mt-4 text-xs font-semibold truncate">Batch re-run options</p>
<div class="border overflow-auto rounded-md m-4 flex-1">
<div class="flex-1 flex flex-col h-full">
<div class="border overflow-auto rounded-md mb-4 flex-1">
<Splitpanes>
<Pane size={32} class="bg-surface-secondary relative">
<PanelSection
title="Runnables"
class="bg-surface-secondary overflow-y-scroll absolute inset-0"
id="batch-rerun-options-runnable-list"
>
<div class="w-full flex flex-col gap-1">
{#await jobGroupsPromise then jobGroup}
{#each jobGroup as group}
{#if !hideRunnableSelector}
<Pane size={32} class="bg-surface-secondary relative">
<PanelSection
title="Runnables"
class="bg-surface-secondary overflow-y-scroll absolute inset-0"
id="batch-rerun-options-runnable-list"
>
<div class="w-full flex flex-col gap-1">
{#each jobGroups.current ?? [] as group}
<Button
variant="default"
unifiedSize="sm"
@@ -183,101 +188,113 @@
<span class="text-hint">({jobGroupTotalCount(group)})</span>
</Button>
{/each}
{/await}
</div>
</PanelSection>
</Pane>
<Pane size={68} class="relative">
<PanelSection
title="Inputs"
class="overflow-y-scroll absolute inset-0"
id="batch-rerun-options-args"
>
{#if selected}
<div class="text-sm w-full pb-2">
<Alert type="info" title="Available expressions :">
Use the <code>job</code> object to access data about the original job
</Alert>
</div>
<Toggle
checked={selectedUsesLatestSchema}
disabled={selected?.kind === 'flow'}
on:change={(e) => {
if (!selected) return
;(options[selected.kind][selected.script_path] ??= {}).use_latest_version =
e.detail as boolean
}}
size="sm"
options={{
right: 'Always use latest version',
rightTooltip:
selected.kind === 'flow'
? 'Flow jobs will always run on the latest version of the flow'
: 'Run all jobs with the latest version of the script even if they originally ran an older version'
}}
/>
</PanelSection>
</Pane>
{/if}
<Pane size={hideRunnableSelector ? 100 : 68} class="relative">
<div class="flex flex-col absolute inset-0">
<PanelSection
title="Inputs"
class="overflow-y-scroll flex-1"
id="batch-rerun-options-args"
>
{#if selected}
<div class="text-sm w-full pb-2">
<Alert type="info" title="Available expressions :">
Use the <code>job</code> object to access data about the original job
</Alert>
</div>
<Toggle
checked={selectedUsesLatestSchema}
disabled={selected?.kind === 'flow'}
on:change={(e) => {
if (!selected) return
;(options[selected.kind][selected.script_path] ??= {}).use_latest_version =
e.detail as boolean
}}
size="sm"
options={{
right: 'Always use latest version',
rightTooltip:
selected.kind === 'flow'
? 'Flow jobs will always run on the latest version of the flow'
: 'Run all jobs with the latest version of the script even if they originally ran an older version'
}}
/>
<!-- Even if we use the latest schema, we want the editor -->
<!-- to only lint the original jobs' values -->
{@const displayedSchema = selectedUsesLatestSchema
? (selected.latest_schema as Schema)
: mergeSchemasForBatchReruns(selected.schemas.map((s) => s.schema as Schema))}
{@const extraLib = buildExtraLibForBatchReruns({
schemas: selected.schemas,
script_path: selected.script_path
})}
<div class="w-full h-full">
{#key [selected, displayedSchema]}
{#each Object.keys(displayedSchema.properties) as propertyName}
<ResizeTransitionWrapper vertical innerClass="w-full">
<InputTransformForm
class="items-start mb-6"
arg={options[selected.kind][selected.script_path]?.input_transforms?.[
propertyName
] ?? {
type: 'javascript',
expr: batchReRunDefaultPropertyExpr(propertyName, selected.schemas)
}}
on:change={(e) => {
if (!selected) return
const newArg = e.detail.arg as InputTransform
;((options[selected.kind][selected.script_path] ??= {}).input_transforms ??=
{})[propertyName] = newArg
}}
argName={propertyName}
schema={displayedSchema}
{extraLib}
previousModuleId={undefined}
pickableProperties={{
hasResume: false,
previousId: undefined,
priorIds: {},
flow_input: {}
}}
hideHelpButton
{...propertyAlwaysExists(propertyName, selected)
? {}
: {
headerTooltip:
'This property does not exist on all versions of the script. You can handle different cases in the code below',
HeaderTooltipIcon: TriangleAlert,
headerTooltipIconClass: 'text-orange-500'
}}
{...propertyAlwaysHasSameType(propertyName, selected)
? {}
: {
headerTooltip:
'This property does not always have the same type depending on the version of the script. You can handle different cases in the code below',
HeaderTooltipIcon: TriangleAlert,
headerTooltipIconClass: 'text-orange-500'
}}
/>
</ResizeTransitionWrapper>
{/each}
{/key}
</div>
{/if}
</PanelSection>
<!-- Even if we use the latest schema, we want the editor -->
<!-- to only lint the original jobs' values -->
{@const displayedSchema = selectedUsesLatestSchema
? (selected.latest_schema as Schema | undefined)
: mergeSchemasForBatchReruns(
selected.schemas.map((s) => (s.schema as Schema) ?? {})
)}
{@const extraLib = buildExtraLibForBatchReruns({
schemas: selected.schemas,
script_path: selected.script_path
})}
<div class="w-full h-full">
{#key [selected, displayedSchema]}
{#each Object.keys(displayedSchema?.properties ?? {}) as propertyName}
<ResizeTransitionWrapper vertical innerClass="w-full">
<InputTransformForm
class="items-start mb-6"
arg={options[selected.kind][selected.script_path]?.input_transforms?.[
propertyName
] ?? {
type: 'javascript',
expr: batchReRunDefaultPropertyExpr(propertyName, selected.schemas)
}}
on:change={(e) => {
if (!selected) return
const newArg = e.detail.arg as InputTransform
;((options[selected.kind][selected.script_path] ??=
{}).input_transforms ??= {})[propertyName] = newArg
}}
argName={propertyName}
schema={displayedSchema ?? {}}
{extraLib}
previousModuleId={undefined}
pickableProperties={{
hasResume: false,
previousId: undefined,
priorIds: {},
flow_input: {}
}}
hideHelpButton
{...propertyAlwaysExists(propertyName, selected)
? {}
: {
headerTooltip:
'This property does not exist on all versions of the script. You can handle different cases in the code below',
HeaderTooltipIcon: TriangleAlert,
headerTooltipIconClass: 'text-orange-500'
}}
{...propertyAlwaysHasSameType(propertyName, selected)
? {}
: {
headerTooltip:
'This property does not always have the same type depending on the version of the script. You can handle different cases in the code below',
HeaderTooltipIcon: TriangleAlert,
headerTooltipIconClass: 'text-orange-500'
}}
/>
</ResizeTransitionWrapper>
{/each}
{/key}
</div>
{/if}
</PanelSection>
<div class="flex justify-end gap-2 w-full pt-2 pb-2 pr-4">
<Button variant="subtle" onClick={onCancel}>Cancel</Button>
<Button
variant="accent"
onClick={() => onConfirm(options)}
endIcon={{ icon: RefreshCwIcon }}>Run again</Button
>
</div>
</div>
</Pane>
</Splitpanes>
</div>

View File

@@ -109,8 +109,8 @@
bind:this={jobLoader}
/>
<div class="h-full overflow-y-auto">
<div class="flex flex-col items-start p-4 pb-8 min-h-full">
<div class="h-full">
<div class="flex flex-col items-start pb-4 min-h-full">
{#if job}
{@const isFlow = job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)}
<JobDetailHeader

View File

@@ -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>

View File

@@ -7,10 +7,8 @@
truncateHash,
truncateRev,
isScriptPreview,
isJobSelectable,
msToReadableTime,
isFlowPreview,
type RunsSelectionMode,
getJobKindIcon
} from '$lib/utils'
import { Button } from '../common'
@@ -39,7 +37,7 @@
containsLabel?: boolean
showTag?: boolean
activeLabel: string | null
selectionMode?: RunsSelectionMode | false
manualSelectionMode?: undefined | 'cancel' | 'rerun'
}
let {
@@ -49,7 +47,7 @@
containsLabel = false,
showTag = true,
activeLabel,
selectionMode = false
manualSelectionMode
}: Props = $props()
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
@@ -68,36 +66,33 @@
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'hover:bg-surface-hover cursor-pointer',
selected ? 'bg-surface-accent-selected' : '',
'cursor-pointer',
selected ? 'bg-surface-accent-selected' : 'hover:bg-surface-hover',
'grid items-center h-full'
)}
class:grid-runs-table={!containsLabel && !selectionMode && showTag}
class:grid-runs-table-with-labels={containsLabel && !selectionMode && showTag}
class:grid-runs-table-selection={!containsLabel && selectionMode && showTag}
class:grid-runs-table-with-labels-selection={containsLabel && selectionMode && showTag}
class:grid-runs-table-no-tag={!containsLabel && !selectionMode && !showTag}
class:grid-runs-table-with-labels-no-tag={containsLabel && !selectionMode && !showTag}
class:grid-runs-table-selection-no-tag={!containsLabel && selectionMode && !showTag}
class:grid-runs-table-with-labels-selection-no-tag={containsLabel && selectionMode && !showTag}
class:grid-runs-table={!containsLabel && !manualSelectionMode && showTag}
class:grid-runs-table-with-labels={containsLabel && !manualSelectionMode && showTag}
class:grid-runs-table-selection={!containsLabel && manualSelectionMode && showTag}
class:grid-runs-table-with-labels-selection={containsLabel && manualSelectionMode && showTag}
class:grid-runs-table-no-tag={!containsLabel && !manualSelectionMode && !showTag}
class:grid-runs-table-with-labels-no-tag={containsLabel && !manualSelectionMode && !showTag}
class:grid-runs-table-selection-no-tag={!containsLabel && manualSelectionMode && !showTag}
class:grid-runs-table-with-labels-selection-no-tag={containsLabel &&
manualSelectionMode &&
!showTag}
style="width: {containerWidth}px"
onclick={() => {
if (!selectionMode || isJobSelectable(selectionMode)(job)) {
dispatch('select')
}
}}
onclick={() => dispatch('select')}
oncontextmenu={(e) => !selected && dispatch('select')}
>
<!-- Selection column (only when in selection mode) -->
{#if selectionMode}
<div class="flex items-center justify-center">
<div class="w-4 h-4">
<input type="checkbox" checked={selected} disabled={!isJobSelectable(selectionMode)(job)} />
</div>
{#if manualSelectionMode}
<div class="w-4 h-4 ml-4 pointer-events-none">
<input type="checkbox" checked={selected} />
</div>
{/if}
<!-- Status -->
<div class="flex items-center justify-start pl-2">
<div class="flex items-center justify-start pl-4">
<JobStatusIcon {job} {isExternal} />
</div>

View File

@@ -1,108 +0,0 @@
<script lang="ts">
import { userStore, superadmin } from '$lib/stores'
import { X, Check, ChevronDown, Loader2, SquareMousePointer } from 'lucide-svelte'
import { Button } from '../common'
import DropdownV2 from '../DropdownV2.svelte'
import type { RunsSelectionMode } from '$lib/utils'
interface Props {
isLoading?: boolean
selectionCount: number
selectionMode: RunsSelectionMode | false
small?: boolean
onSetSelectionMode: (mode: RunsSelectionMode | false) => void
onCancelSelectedJobs: () => void
onCancelFilteredJobs: () => void
onReRunSelectedJobs: () => void
onReRunFilteredJobs: () => void
}
let {
isLoading = false,
selectionCount,
selectionMode,
small = false,
onSetSelectionMode,
onCancelSelectedJobs,
onCancelFilteredJobs,
onReRunSelectedJobs,
onReRunFilteredJobs
}: Props = $props()
function jobCountString(count: number) {
return `${count} ${count == 1 ? 'job' : 'jobs'}`
}
</script>
{#if isLoading}
<Button size="xs" color="light" disabled>
<Loader2 class="animate-spin" size={20} />
</Button>
{:else if selectionMode}
<div class="h-8 flex flex-row items-center gap-1">
<Button
startIcon={{ icon: X }}
iconOnly
unifiedSize="md"
variant="default"
on:click={() => onSetSelectionMode(false)}
/>
{#if selectionMode == 'cancel'}
<Button
disabled={selectionCount == 0}
startIcon={{ icon: Check }}
unifiedSize="md"
variant="accent"
destructive
on:click={onCancelSelectedJobs}
>
Cancel {jobCountString(selectionCount)}
</Button>
{/if}
{#if selectionMode == 're-run'}
<Button
disabled={selectionCount == 0}
startIcon={{ icon: Check }}
unifiedSize="md"
variant="accent"
on:click={onReRunSelectedJobs}
>
Re-run {jobCountString(selectionCount)}
</Button>
{/if}
</div>
{:else}
<DropdownV2
class="w-fit"
items={[
{
displayName: 'Select jobs to cancel',
action: () => onSetSelectionMode('cancel')
},
...($userStore?.is_admin || $superadmin
? [{ displayName: 'Cancel all jobs matching filters', action: onCancelFilteredJobs }]
: []),
{
displayName: 'Select jobs to re-run',
action: () => onSetSelectionMode('re-run')
},
...($userStore?.is_admin || $superadmin
? [{ displayName: 'Re-run all jobs matching filters', action: onReRunFilteredJobs }]
: [])
]}
>
{#snippet buttonReplacement()}
<Button
nonCaptureEvent
variant="default"
unifiedSize="md"
startIcon={{ icon: SquareMousePointer }}
endIcon={{ icon: ChevronDown }}
>
{#if !small}
<span>Batch actions</span>
{/if}
</Button>
{/snippet}
</DropdownV2>
{/if}

View File

@@ -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),
@@ -48,8 +46,6 @@
import Select from '../select/Select.svelte'
import { safeSelectItems } from '../select/utils.svelte'
import RunOption from './RunOption.svelte'
import DropdownSelect from '../DropdownSelect.svelte'
import TooltipV2 from '$lib/components/meltComponents/Tooltip.svelte'
import TextInput from '../text_input/TextInput.svelte'
import { jobTriggerKinds, triggerDisplayNamesMap } from '../triggers/utils'
import type { JobTriggerKind } from '$lib/gen'
@@ -90,8 +86,6 @@
| 'worker'
| 'tag'
| 'schedulePath'
small?: boolean
calendarSmall?: boolean
}
let {
@@ -117,9 +111,7 @@
usernames = [],
folders = [],
allWorkspaces = $bindable(),
filterBy = $bindable(),
small = false,
calendarSmall = false
filterBy = $bindable('path')
}: Props = $props()
const dispatch = createEventDispatcher()
@@ -182,39 +174,6 @@
}
</script>
{#snippet runsTooltip()}
<TooltipV2 placement="right">
{#snippet text()}
'Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they
start), they have been triggered through the UI, a schedule or webhook'
{/snippet}
</TooltipV2>
{/snippet}
{#snippet previewsTooltip()}
<TooltipV2 placement="right">
{#snippet text()}
'Previews are jobs that have been started in the editor as "Tests"'
{/snippet}
</TooltipV2>
{/snippet}
{#snippet dependenciesTooltip()}
<TooltipV2 placement="right">
{#snippet text()}
'Deploying a script, flow or an app launch a dependency job that creates and then attaches the
lockfile to the deployed item. This mechanism ensures that logic is always executed with the
exact same direct and indirect dependencies.'
{/snippet}
</TooltipV2>
{/snippet}
{#snippet syncTooltip()}
<TooltipV2 placement="right">
{#snippet text()}
'Sync jobs that are triggered on every script deployment to sync the workspace with the Git
repository configured in the workspace settings'
{/snippet}
</TooltipV2>
{/snippet}
{#if !mobile}
{#if $workspaceStore == 'admins'}
<RunOption label="Workspaces" for="workspaces">
@@ -527,96 +486,48 @@
<!-- Kind -->
<RunOption label="Kind" for="kind">
{#if small && !calendarSmall}
<DropdownSelect
btnClasses="min-w-24 h-9 bg-surface-secondary font-normal"
items={[
{
displayName: 'All',
action: () => {
jobKindsCat = 'all'
<ToggleButtonGroup bind:selected={jobKindsCat}>
{#snippet children({ item })}
<ToggleButton value="all" label="All" {item} />
<ToggleButton
value="runs"
label="Runs"
showTooltipIcon
tooltip="Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they start), they have been triggered through the UI, a schedule or webhook"
{item}
/>
<ToggleButton
value="dependencies"
label="Deps"
showTooltipIcon
tooltip="Deploying a script, flow or an app launch a dependency job that create and then attach the lockfile to the deployed item. This mechanism ensure that logic is always executed with the exact same direct and indirect dependencies."
{item}
/>
<ToggleButtonMore
togglableItems={[
{
label: 'Previews',
value: 'previews',
tooltip: "Previews are jobs that have been started in the editor as 'Tests'"
},
id: 'all'
},
{
displayName: 'Runs',
action: () => {
jobKindsCat = 'runs'
},
id: 'runs',
extra: runsTooltip
},
{
displayName: 'Previews',
action: () => {
jobKindsCat = 'previews'
},
id: 'previews',
extra: previewsTooltip
},
{
displayName: 'Deps',
action: () => {
jobKindsCat = 'dependencies'
},
id: 'dependencies',
extra: dependenciesTooltip
},
{
displayName: 'Sync',
action: () => {
jobKindsCat = 'deploymentcallbacks'
},
id: 'deploymentcallbacks',
extra: syncTooltip
}
]}
selected={jobKindsCat}
/>
{:else}
<ToggleButtonGroup bind:selected={jobKindsCat}>
{#snippet children({ item })}
<ToggleButton value="all" label="All" {item} />
<ToggleButton
value="runs"
label="Runs"
showTooltipIcon
tooltip="Runs are jobs that have no parent jobs (flows are jobs that are parent of the jobs they start), they have been triggered through the UI, a schedule or webhook"
{item}
/>
<ToggleButton
value="dependencies"
label="Deps"
showTooltipIcon
tooltip="Deploying a script, flow or an app launch a dependency job that create and then attach the lockfile to the deployed item. This mechanism ensure that logic is always executed with the exact same direct and indirect dependencies."
{item}
/>
<ToggleButtonMore
togglableItems={[
{
label: 'Previews',
value: 'previews',
tooltip: "Previews are jobs that have been started in the editor as 'Tests'"
},
{
label: 'Sync',
value: 'deploymentcallbacks',
tooltip:
'Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the the workspace settings'
}
]}
{item}
bind:selected={
() => jobKindsCat,
(v) => {
resetFilter()
jobKindsCat = v
}
{
label: 'Sync',
value: 'deploymentcallbacks',
tooltip:
'Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the workspace settings'
}
/>
{/snippet}
</ToggleButtonGroup>
{/if}
]}
{item}
bind:selected={
() => jobKindsCat,
(v) => {
resetFilter()
jobKindsCat = v
}
}
/>
{/snippet}
</ToggleButtonGroup>
</RunOption>
<!-- Status -->
<RunOption label="Status" for="status">
@@ -957,7 +868,7 @@
value="deploymentcallbacks"
label="Sync"
showTooltipIcon
tooltip="Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the the workspace settings"
tooltip="Sync jobs that are triggered on every script deployment to sync the workspace with the Git repository configured in the workspace settings"
{item}
/>
{/snippet}

View File

@@ -2,13 +2,25 @@
import type { Job } from '$lib/gen'
import RunRow from './RunRow.svelte'
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'
import { createEventDispatcher, onMount } from 'svelte'
import { createEventDispatcher } from 'svelte'
import Tooltip from '../Tooltip.svelte'
import { AlertTriangle } from 'lucide-svelte'
import {
AlertTriangle,
CircleXIcon,
Code2Icon,
ExternalLinkIcon,
RefreshCwIcon
} from 'lucide-svelte'
import Popover from '../Popover.svelte'
import { workspaceStore } from '$lib/stores'
import './runs-grid.css'
import type { RunsSelectionMode } from '$lib/utils'
import { useKeyPressed } from '$lib/svelte5Utils.svelte'
import { twMerge } from 'tailwind-merge'
import RightClickPopover from '../RightClickPopover.svelte'
import DropdownMenu, { type Props as DropdownMenuProps } from '../DropdownMenu.svelte'
import { clickOutside, isJobCancelable, isJobReRunnable } from '$lib/utils'
import { goto } from '$lib/navigation'
import BarsStaggered from '../icons/BarsStaggered.svelte'
interface Props {
//import InfiniteLoading from 'svelte-infinite-loading'
@@ -16,13 +28,15 @@
externalJobs?: Job[]
omittedObscuredJobs: boolean
showExternalJobs?: boolean
selectionMode?: RunsSelectionMode | false
selectedIds?: string[]
selectedWorkspace?: string | undefined
activeLabel?: string | null
// const loadMoreQuantity: number = 100
lastFetchWentToEnd?: boolean
perPage?: number
batchRerunOptionsIsOpen?: boolean
manualSelectionMode: undefined | 'cancel' | 'rerun'
onCancelJobs: (jobIds: string[]) => void
}
let {
@@ -30,14 +44,44 @@
externalJobs = [],
omittedObscuredJobs,
showExternalJobs = false,
selectionMode = false,
selectedIds = $bindable([]),
selectedWorkspace = $bindable(undefined),
activeLabel = null,
lastFetchWentToEnd = false,
perPage = 1000
perPage = 1000,
manualSelectionMode,
onCancelJobs,
batchRerunOptionsIsOpen = $bindable()
}: Props = $props()
let hasClickFocus = $state(false)
const keysPressed = useKeyPressed(['Shift', 'Control', 'Meta', 'A', 'ArrowDown', 'ArrowUp'], {
onKeyDown(key, e) {
if (!hasClickFocus) return
if (key === 'A' && (keysPressed.Control || keysPressed.Meta)) {
if (batchRerunOptionsIsOpen) return
e.preventDefault()
e.stopPropagation()
selectedIds = flatJobs
? flatJobs
.filter((jobOrDate) => jobOrDate.type === 'job')
.map((jobOrDate) => jobOrDate.job.id)
: []
} else if ((key === 'ArrowDown' || key === 'ArrowUp') && selectedIds.length === 1) {
const idx = flatJobs?.findIndex(
(jobOrDate) => jobOrDate.type === 'job' && jobOrDate.job.id === selectedIds[0]
)
if (idx == undefined) return
let nextJob = flatJobs?.[idx + (key === 'ArrowDown' ? 1 : -1)]
if (nextJob?.type === 'date') nextJob = flatJobs?.[idx + (key === 'ArrowDown' ? 2 : -2)]
if (nextJob?.type !== 'job') return
selectedIds = [nextJob.job.id]
e.preventDefault()
}
}
})
let rightClickPopover: RightClickPopover | undefined = $state(undefined)
function getTime(job: Job): string | undefined {
return job['completed_at'] ?? job['started_at'] ?? job['scheduled_for'] ?? job['created_at']
}
@@ -116,7 +160,6 @@
}
let tableHeight: number = $state(0)
let headerHeight: number = $state(0)
let containerWidth: number = $state(0)
// const MAX_ITEMS = perPage
@@ -136,22 +179,21 @@
}
*/
function jobCountString(jobCount: number | undefined, lastFetchWentToEnd: boolean): string {
function jobCountString(
jobCount: number | undefined,
lastFetchWentToEnd: boolean,
hideLabel?: boolean
): string {
if (jobCount === undefined) {
return ''
}
const jc = jobCount
const isTruncated = jc >= perPage && !lastFetchWentToEnd
return `${jc}${isTruncated ? '+' : ''} job${jc != 1 ? 's' : ''}`
if (hideLabel) return `${jc}${isTruncated ? '+' : ''}`
else return `${jc}${isTruncated ? '+' : ''} job${jc != 1 ? 's' : ''}`
}
function computeHeight() {
tableHeight = document.querySelector('#runs-table-wrapper')!.parentElement?.clientHeight ?? 0
}
onMount(() => {
computeHeight()
})
const dispatch = createEventDispatcher()
let scrollToIndex = $state(0)
@@ -193,34 +235,129 @@
return nstickyIndices
})
const showTag = $derived(containerWidth > 700)
let showTag = $derived(containerWidth > 700)
let selectedIdsPossibleActions = $derived.by(() => {
const cancellableJobIds: string[] = []
const rerunnableJobIds: string[] = []
for (const jobId of selectedIds) {
const job = flatJobs?.find(
(jobOrDate) => jobOrDate.type === 'job' && jobOrDate.job.id === jobId
)
if (job?.type === 'job') {
if (isJobCancelable(job.job)) cancellableJobIds.push(job.job.id)
if (isJobReRunnable(job.job)) rerunnableJobIds.push(job.job.id)
}
}
return { cancellableJobIds, rerunnableJobIds }
})
let hoveredDropdownAction: 'cancel' | 'rerun' | null = $state(null)
let dropdownActions: DropdownMenuProps['items'] = $derived.by(() => {
let rerunnable = selectedIdsPossibleActions.rerunnableJobIds.length
let cancellable = selectedIdsPossibleActions.cancellableJobIds.length
const actions: DropdownMenuProps['items'] = []
if (selectedIds.length === 1) {
actions.push({
label: 'Show run details',
icon: ExternalLinkIcon,
onClick: () => goto(`/run/${selectedIds[0]}`)
})
const job = flatJobs?.find(
(jobOrDate) => jobOrDate.type === 'job' && jobOrDate.job.id === selectedIds[0]
)
if (job?.type === 'job') {
if (job.job.job_kind === 'script') {
actions.push({
label: 'Go to script page',
icon: Code2Icon,
onClick: () => goto(`/scripts/get/${job.job.script_hash}`)
})
}
if (job.job.job_kind === 'flow') {
actions.push({
label: 'Go to flow page',
icon: BarsStaggered,
onClick: () => goto(`/flows/get/${job.job.script_path}`)
})
}
}
}
if (rerunnable)
actions.push({
label: 'Run again',
icon: RefreshCwIcon,
right: selectedIds.length >= 2 ? `${rerunnable}` : undefined,
onClick: () => {
selectedIds = selectedIdsPossibleActions.rerunnableJobIds
batchRerunOptionsIsOpen = true
},
onHover: (hover) => (hoveredDropdownAction = hover ? 'rerun' : null)
})
if (cancellable)
actions.push({
label: 'Cancel',
icon: CircleXIcon,
right: selectedIds.length >= 2 ? `${cancellable}` : undefined,
onClick: () => onCancelJobs?.(selectedIdsPossibleActions.cancellableJobIds),
onHover: (hover) => (hoveredDropdownAction = hover ? 'cancel' : null)
})
return actions
})
function jobIsSelectable(job: Job) {
if (
(rightClickPopover?.isOpen() && hoveredDropdownAction === 'cancel') ||
manualSelectionMode === 'cancel'
)
return isJobCancelable(job)
if (
(rightClickPopover?.isOpen() && hoveredDropdownAction === 'rerun') ||
manualSelectionMode === 'rerun' ||
batchRerunOptionsIsOpen
)
return isJobReRunnable(job)
return true
}
let selectableJobs = $derived(jobs?.filter(jobIsSelectable) ?? [])
</script>
<svelte:window onresize={() => computeHeight()} />
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="divide-y h-full border min-w-[650px]"
class="divide-y h-full flex flex-col min-w-[650px]"
id="runs-table-wrapper"
onclick={() => (hasClickFocus = true)}
use:clickOutside={{ onClickOutside: () => (hasClickFocus = false) }}
bind:clientWidth={containerWidth}
>
<div bind:clientHeight={headerHeight}>
<div>
<div
class="grid bg-surface-secondary sticky top-0 w-full py-2 pr-4"
class:grid-runs-table={!containsLabel && !selectionMode && showTag}
class:grid-runs-table-with-labels={containsLabel && !selectionMode && showTag}
class:grid-runs-table-selection={!containsLabel && selectionMode && showTag}
class:grid-runs-table-with-labels-selection={containsLabel && selectionMode && showTag}
class:grid-runs-table-no-tag={!containsLabel && !selectionMode && !showTag}
class:grid-runs-table-with-labels-no-tag={containsLabel && !selectionMode && !showTag}
class:grid-runs-table-selection-no-tag={!containsLabel && selectionMode && !showTag}
class="grid sticky top-0 w-full min-h-6 my-2 pr-4 items-end"
class:grid-runs-table={!containsLabel && !manualSelectionMode && showTag}
class:grid-runs-table-with-labels={containsLabel && !manualSelectionMode && showTag}
class:grid-runs-table-selection={!containsLabel && manualSelectionMode && showTag}
class:grid-runs-table-with-labels-selection={containsLabel && manualSelectionMode && showTag}
class:grid-runs-table-no-tag={!containsLabel && !manualSelectionMode && !showTag}
class:grid-runs-table-with-labels-no-tag={containsLabel && !manualSelectionMode && !showTag}
class:grid-runs-table-selection-no-tag={!containsLabel && manualSelectionMode && !showTag}
class:grid-runs-table-with-labels-selection-no-tag={containsLabel &&
selectionMode &&
manualSelectionMode &&
!showTag}
>
{#if selectionMode}
<div class="text-xs font-semibold pl-4"></div>
{#if manualSelectionMode}
{@const allSelected = selectedIds.length === selectableJobs?.length}
<div class="w-4 h-4 ml-4">
<input
type="checkbox"
bind:checked={
() => allSelected,
() => (selectedIds = allSelected ? [] : (selectableJobs.map((j) => j.id) ?? []))
}
/>
</div>
{/if}
<div class="text-2xs px-2 flex flex-row items-center gap-2">
<div class="text-2xs px-4 flex flex-row items-center gap-2 leading-3">
{#if showExternalJobs && externalJobs.length > 0}
<div class="flex flex-row">
{jobs
@@ -239,125 +376,186 @@
</Popover>
</div>
{:else}
{jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''}
{@const jobCount = jobs
? jobCountString(jobs.length, lastFetchWentToEnd, selectedIds.length >= 2)
: ''}
{selectedIds.length >= 2 ? `${selectedIds.length}/` : ''}<wbr />
{jobCount}
{/if}
</div>
<div class="text-xs font-semibold">Started</div>
<div class="text-xs font-semibold">Duration</div>
<div class="text-xs font-semibold">Path</div>
<div class="text-xs font-semibold leading-3">Started</div>
<div class="text-xs font-semibold leading-3">Duration</div>
<div class="text-xs font-semibold leading-3">Path</div>
{#if containsLabel}
<div class="text-xs font-semibold">Label</div>
<div class="text-xs font-semibold leading-3">Label</div>
{/if}
<div class="text-xs font-semibold">Triggered by</div>
<div class="text-xs font-semibold leading-3">Triggered by</div>
{#if showTag}
<div class="text-xs font-semibold">Tag</div>
<div class="text-xs font-semibold leading-3">Tag</div>
{/if}
<div class=""></div>
<div> </div>
</div>
</div>
{#if jobs?.length == 0 && (!showExternalJobs || externalJobs?.length == 0)}
<div class="text-xs text-secondary p-8"> No jobs found for the selected filters. </div>
{:else}
<VirtualList
width="100%"
height={tableHeight - headerHeight}
itemCount={flatJobs?.length ?? 3}
itemSize={42}
overscanCount={20}
{stickyIndices}
{scrollToIndex}
scrollToAlignment="center"
>
{#snippet header()}{/snippet}
{#snippet item({ index, style })}
<div {style} class="w-full">
{#if flatJobs}
{@const jobOrDate = flatJobs[index]}
{#if jobOrDate}
{#if jobOrDate?.type === 'date'}
<div
class="bg-surface-secondary py-2 font-semibold text-xs pl-2 h-[42px] flex items-center"
>
{jobOrDate.date}
</div>
<div
bind:clientHeight={tableHeight}
class="relative flex-1 border rounded-t-md overflow-clip bg-surface-tertiary [&>.virtual-list-wrapper::-webkit-scrollbar-track]:bg-surface-tertiary"
>
{#if jobs?.length == 0 && (!showExternalJobs || externalJobs?.length == 0)}
<div class="text-xs text-secondary p-8"> No jobs found for the selected filters. </div>
{:else}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="absolute inset-0 -mt-3"
oncontextmenu={(e) => {
e.preventDefault()
rightClickPopover?.open(e)
}}
>
<VirtualList
width="100%"
height={tableHeight}
itemCount={flatJobs?.length ?? 3}
itemSize={42}
overscanCount={20}
{stickyIndices}
{scrollToIndex}
scrollToAlignment="center"
>
{#snippet header()}{/snippet}
{#snippet item({ index, style })}
<div {style} class="w-full bg-surface-tertiary">
{#if flatJobs}
{@const jobOrDate = flatJobs[index]}
{#if jobOrDate}
{#if jobOrDate?.type === 'date'}
<div
class={twMerge(
'border-b py-1.5 font-semibold text-xs pl-4 h-[42px] flex items-end bg-surface-tertiary'
)}
>
{jobOrDate.date}
</div>
{:else}
{@const selected =
jobOrDate.job.id !== '-' && selectedIds.includes(jobOrDate.job.id)}
{@const nonSelectable = !jobIsSelectable(jobOrDate.job)}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class={twMerge(
'flex flex-row items-center h-full w-full select-none transition-opacity',
nonSelectable || (rightClickPopover?.isOpen() && !selected)
? 'opacity-20'
: ''
)}
>
<RunRow
{manualSelectionMode}
{containsLabel}
{showTag}
job={jobOrDate.job}
{selected}
on:select={() => {
const jobId = jobOrDate.job.id
if (keysPressed.Shift && selectedIds.length > 0) {
if (nonSelectable) return
const lastSelectedId = selectedIds[selectedIds.length - 1]
const lastSelectedIndex = flatJobs?.findIndex(
(jobOrDate) =>
jobOrDate.type === 'job' && jobOrDate.job.id === lastSelectedId
)
if (lastSelectedIndex != undefined && flatJobs) {
const [start, end] =
index < lastSelectedIndex
? [index, lastSelectedIndex]
: [lastSelectedIndex, index]
const newSelectedIds = flatJobs
.slice(start, end + 1)
.filter((jobOrDate) => jobOrDate.type === 'job')
.map((jobOrDate) => jobOrDate.job.id)
selectedIds = Array.from(new Set([...selectedIds, ...newSelectedIds]))
}
} else if (
keysPressed.Control ||
keysPressed.Meta ||
manualSelectionMode
) {
if (nonSelectable) return
if (selectedIds.includes(jobOrDate.job.id)) {
selectedIds = selectedIds.filter((id) => id != jobId)
} else {
selectedIds.push(jobId)
selectedIds = selectedIds
}
} else {
if (batchRerunOptionsIsOpen) batchRerunOptionsIsOpen = false
if (
selectedIds.length !== 1 ||
selectedIds[0] !== jobOrDate.job.id ||
selectedWorkspace !== jobOrDate.job.workspace_id
) {
selectedWorkspace = jobOrDate.job.workspace_id
selectedIds = [jobOrDate.job.id]
dispatch('select')
} else {
selectedIds = []
selectedWorkspace = undefined
dispatch('select')
}
}
}}
{activeLabel}
on:filterByLabel
on:filterByPath
on:filterByUser
on:filterByFolder
on:filterByConcurrencyKey
on:filterBySchedule
on:filterByWorker
{containerWidth}
/>
</div>
{/if}
{:else}
{JSON.stringify(jobOrDate)}
{/if}
{:else}
<div class="flex flex-row items-center h-full w-full">
<RunRow
{containsLabel}
{showTag}
job={jobOrDate.job}
selected={jobOrDate.job.id !== '-' && selectedIds.includes(jobOrDate.job.id)}
{selectionMode}
on:select={() => {
const jobId = jobOrDate.job.id
if (selectionMode) {
if (selectedIds.includes(jobOrDate.job.id)) {
selectedIds = selectedIds.filter((id) => id != jobId)
} else {
selectedIds.push(jobId)
selectedIds = selectedIds
}
} else {
if (
JSON.stringify(selectedIds) !== JSON.stringify([jobOrDate.job.id]) ||
selectedWorkspace !== jobOrDate.job.workspace_id
) {
selectedWorkspace = jobOrDate.job.workspace_id
selectedIds = [jobOrDate.job.id]
dispatch('select')
} else {
selectedIds = []
selectedWorkspace = undefined
dispatch('select')
}
}
}}
{activeLabel}
on:filterByLabel
on:filterByPath
on:filterByUser
on:filterByFolder
on:filterByConcurrencyKey
on:filterBySchedule
on:filterByWorker
{containerWidth}
/>
<div class="w-1/12 text-2xs">...</div>
<div class="w-4/12 text-xs">...</div>
<div class="w-4/12 text-xs">...</div>
<div class="w-3/12 text-xs">...</div>
</div>
{/if}
{:else}
{JSON.stringify(jobOrDate)}
{/if}
{:else}
<div class="flex flex-row items-center h-full w-full">
<div class="w-1/12 text-2xs">...</div>
<div class="w-4/12 text-xs">...</div>
<div class="w-4/12 text-xs">...</div>
<div class="w-3/12 text-xs">...</div>
</div>
{/if}
</div>
{/snippet}
{#snippet footer()}
<div
>{#if !lastFetchWentToEnd && jobs && jobs.length >= perPage}
<button
class="text-xs text-accent text-center w-full pb-2"
onclick={() => {
dispatch('loadExtra')
}}
{/snippet}
{#snippet footer()}
<div
>{#if !lastFetchWentToEnd && jobs && jobs.length >= perPage}
<button
class="text-xs text-accent text-center w-full pb-2"
onclick={() => {
dispatch('loadExtra')
}}
>
Load next {perPage} jobs
</button>
{/if}</div
>
Load next {perPage} jobs
</button>
{/if}</div
>
{/snippet}
</VirtualList>
{/if}
{/snippet}
</VirtualList>
</div>
{/if}
</div>
</div>
<RightClickPopover bind:this={rightClickPopover}>
<DropdownMenu closeCallback={() => rightClickPopover?.close()} items={dropdownActions} />
</RightClickPopover>
<style>
:global(.virtual-list-wrapper:hover::-webkit-scrollbar) {
:global(.virtual-list-wrapper::-webkit-scrollbar) {
width: 8px !important;
height: 8px !important;
}

View File

@@ -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>

View File

@@ -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
@@ -54,9 +56,7 @@ export interface UseJobLoaderArgs {
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
}
@@ -71,10 +71,9 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
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 timeframe = $derived(_args?.timeframe)
let label = $derived(filters?.label ?? null)
let worker = $derived(filters?.worker ?? null)
@@ -94,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()
@@ -162,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()
@@ -299,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()
}
@@ -374,9 +373,6 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
let lastQueueTs: string | undefined = undefined
async function syncer() {
if (success == 'waiting') {
onSetMinMaxTs?.(null, null)
}
if (loadingFetch) {
return
}
@@ -385,15 +381,8 @@ export function useJobsLoader(args: () => UseJobLoaderArgs) {
getCount()
}
const ts = computeMinAndMax?.()
if (ts) {
onSetMinMaxTs?.(ts.minTs, ts.maxTs)
if (maxTs != undefined) {
loadJobsIntern(false)
}
}
if (jobs && maxTs == undefined) {
const { minTs, maxTs } = timeframe?.computeMinMax() ?? { minTs: null, maxTs: null }
if (jobs) {
if (success == 'running') {
loadJobsIntern(false)
} else {
@@ -534,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()
})

View File

@@ -16,6 +16,7 @@
inputSizeClasses
} from '../text_input/TextInput.svelte'
import { ButtonType } from '../common/button/model'
import Tooltip from '../Tooltip.svelte'
type Value = Item['value']
@@ -37,6 +38,7 @@
RightIcon,
createText,
noItemsMsg,
tooltip,
open = $bindable(false),
id,
itemLabelWrapperClasses,
@@ -72,12 +74,13 @@
createText?: string
noItemsMsg?: string
open?: boolean
tooltip?: string
id?: string
itemLabelWrapperClasses?: string
itemButtonWrapperClasses?: string
size?: 'sm' | 'md' | 'lg'
showPlaceholderOnOpen?: boolean
transformInputSelectedText?: (text: string) => string
transformInputSelectedText?: (text: string, value: Value) => string
groupBy?: (item: Item) => string
sortBy?: (a: Item, b: Item) => number
onFocus?: () => void
@@ -106,7 +109,9 @@
if (!open) filterText = ''
})
let valueEntry = $derived(value && processedItems?.find((item) => deepEqual(item.value, value)))
let valueEntry = $derived(
value != null ? processedItems?.find((item) => deepEqual(item.value, value)) : undefined
)
function setValue(item: ProcessedItem<Value>) {
if (item.__is_create && onCreateItem) {
@@ -126,12 +131,12 @@
let inputText = $derived.by(() => {
let text = valueEntry?.label ?? getLabel({ value }) ?? ''
return transformInputSelectedText?.(text) ?? text
return transformInputSelectedText?.(text, value) ?? text
})
</script>
<div
class={`relative ${className}`}
class={`relative h-fit ${className}`}
use:clickOutside={{ onClickOutside: () => (open = false) }}
onpointerdown={() => onFocus?.()}
onfocus={() => onFocus?.()}
@@ -155,6 +160,13 @@
<RightIcon size={iconSize} class="text-secondary" />
</div>
{/if}
{#if tooltip}
<div class="absolute z-10 right-2 h-full flex items-center">
<Tooltip>{tooltip}</Tooltip>
</div>
{/if}
<!-- svelte-ignore a11y_autofocus -->
<input
{autofocus}

View File

@@ -415,3 +415,51 @@ export function useInfiniteQuery<TData, TPageParam = number>(
reset
}
}
export function useKeyPressed<Key extends string>(
keys: Key[],
params?: {
onKeyUp?: (key: Key, e: KeyboardEvent) => void
onKeyDown?: (key: Key, e: KeyboardEvent) => void
}
): Record<Key, boolean> {
if (typeof window === 'undefined')
return Object.fromEntries(keys.map((key) => [key, false])) as Record<Key, boolean>
let obj = $state(Object.fromEntries(keys.map((key) => [key, false])) as Record<Key, boolean>)
$effect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
for (const key of keys) {
if (event.key.toLowerCase() === key.toLowerCase()) {
obj[key] = true
params?.onKeyDown?.(key, event)
}
}
}
const handleKeyUp = (event: KeyboardEvent) => {
for (const key of keys) {
if (event.key.toLowerCase() === key.toLowerCase()) {
obj[key] = false
params?.onKeyUp?.(key, event)
}
}
}
// Reset all keys when window loses focus or visibility changes to prevent stuck keys
const resetAllKeys = () => {
for (const key of keys) obj[key] = false
}
window.addEventListener('keydown', handleKeyDown)
window.addEventListener('keyup', handleKeyUp)
window.addEventListener('blur', resetAllKeys)
document.addEventListener('visibilitychange', resetAllKeys)
return () => {
window.removeEventListener('keydown', handleKeyDown)
window.removeEventListener('keyup', handleKeyUp)
window.removeEventListener('blur', resetAllKeys)
document.removeEventListener('visibilitychange', resetAllKeys)
}
})
return obj
}

View File

@@ -15,7 +15,9 @@ export function sendUserToast(
actions: ToastAction[] = [],
errorMessage: string | undefined = undefined,
duration: number = 5000
): void {
): {
destroy: () => void
} {
const type = typeof _type === 'boolean' ? (_type ? 'error' : 'success') : _type
const error = type === 'error'
if (globalThis.windmillToast) {
@@ -27,9 +29,9 @@ export function sendUserToast(
errorMessage,
duration
})
return
return { destroy: () => {} }
}
toast.push({
const id = toast.push({
component: {
// https://github.com/zerodevx/svelte-toast/issues/115
// Svelte 5 changed its component type and svelte-toast is not up to date yet
@@ -56,4 +58,8 @@ export function sendUserToast(
'--toastBoxShadow': 'none'
}
})
return {
destroy: () => toast.pop(id)
}
}

View File

@@ -19,8 +19,6 @@ import type { TriggerKind } from './components/triggers'
import { stateSnapshot } from './svelte5Utils.svelte'
import { validate, dereference } from '@scalar/openapi-parser'
export type RunsSelectionMode = 'cancel' | 're-run'
export namespace OpenApi {
export enum OpenApiVersion {
V2,
@@ -88,14 +86,6 @@ export function isJobReRunnable(j: Job): boolean {
export const WORKER_NAME_PREFIX = 'wk'
export function isJobSelectable(selectionType: RunsSelectionMode) {
const f: (j: Job) => boolean = {
cancel: isJobCancelable,
're-run': isJobReRunnable
}[selectionType]
return f
}
export function escapeHtml(unsafe: string) {
return unsafe
.replace(/&/g, '&amp;')
@@ -1588,6 +1578,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 "end" when only start 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 start - The start date (can be string or Date or undefined)
* @param end - The end date (can be string or Date or undefined)
* @returns Formatted string representing the date range
*/
export function formatDateRange(
start: string | Date | undefined,
end: string | Date | undefined
): string {
const now = new Date()
const startDate = start ? new Date(start) : undefined
const endDate = end ? new Date(end) : 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 start date provided
if (startDate && !endDate) {
const timeStr = formatTime(startDate)
// If it's today, only show time
if (isSameDay(startDate, now)) {
return `Before ${timeStr}`
}
const needsYear = !isSameYear(startDate)
const dateStr = needsYear ? formatDateWithYear(startDate) : formatDateNoYear(startDate)
return `Before ${dateStr} ${timeStr}`
}
// Only end date provided
if (endDate && !startDate) {
const timeStr = formatTime(endDate)
// If it's today, only show time
if (isSameDay(endDate, now)) {
return `After ${timeStr}`
}
const needsYear = !isSameYear(endDate)
const dateStr = needsYear ? formatDateWithYear(endDate) : formatDateNoYear(endDate)
return `After ${dateStr} ${timeStr}`
}
// Both dates provided
if (startDate && endDate) {
// Same day - only show times
if (isSameDay(startDate, endDate)) {
return `${formatTime(startDate)} to ${formatTime(endDate)}`
}
// Different days, same year
if (isSameYear(startDate, endDate)) {
return `${formatDateNoYear(startDate)} to ${formatDateNoYear(endDate)}`
}
// Different years
return `${formatDateWithYear(startDate)} to ${formatDateWithYear(endDate)}`
}
// No dates provided
return ''
}
export function toJsonStr(result: any) {
try {
// console.log(result)