Compare commits
50 Commits
wmill-scri
...
di/runs-pa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba5334dfbd | ||
|
|
ddabba929d | ||
|
|
8885dd96f6 | ||
|
|
25f27d1f52 | ||
|
|
3ff5261093 | ||
|
|
b2eb6ac0e3 | ||
|
|
186ebbf40f | ||
|
|
db05dbb3ba | ||
|
|
63e0f9e3f9 | ||
|
|
e84c33420b | ||
|
|
19eb673951 | ||
|
|
b140526ec4 | ||
|
|
151fc9a951 | ||
|
|
bbb74b1d2d | ||
|
|
04333cd1d8 | ||
|
|
85dc2c030d | ||
|
|
f6975e38d5 | ||
|
|
08a21ad22f | ||
|
|
8ea736236f | ||
|
|
ec3cede8d1 | ||
|
|
1b579df254 | ||
|
|
7a154146fe | ||
|
|
8570da3317 | ||
|
|
de95f4f64d | ||
|
|
5e2cf436f1 | ||
|
|
c6f6746201 | ||
|
|
2369a4353e | ||
|
|
47edc8763a | ||
|
|
e8d7940247 | ||
|
|
9c14d60855 | ||
|
|
d2ee90e3da | ||
|
|
1091ac9e7e | ||
|
|
35c6052ff9 | ||
|
|
bd5528f445 | ||
|
|
2bd0e18154 | ||
|
|
d230b52cf9 | ||
|
|
547470a13a | ||
|
|
b675281da9 | ||
|
|
05dc037b94 | ||
|
|
a98ac00c5e | ||
|
|
33a03f48e6 | ||
|
|
5eac04e3de | ||
|
|
6558efd58b | ||
|
|
bacdbbbf20 | ||
|
|
0b8681cfce | ||
|
|
d290ff065f | ||
|
|
5a961422c6 | ||
|
|
d6d68934b5 | ||
|
|
c7370ec600 | ||
|
|
7962b74ba0 |
@@ -260,6 +260,6 @@
|
||||
} as any)
|
||||
</script>
|
||||
|
||||
<div class="relative max-h-40">
|
||||
<div class="relative h-44">
|
||||
<Line {data} {options} />
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
50
frontend/src/lib/components/DropdownMenu.svelte
Normal file
50
frontend/src/lib/components/DropdownMenu.svelte
Normal 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>
|
||||
@@ -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>
|
||||
@@ -156,7 +156,7 @@
|
||||
variant="subtle"
|
||||
startIcon={{ icon: EllipsisVertical }}
|
||||
btnClasses="bg-transparent"
|
||||
iconOnly
|
||||
iconOnly={!btnText}
|
||||
>
|
||||
{btnText}
|
||||
</Button>
|
||||
|
||||
@@ -111,7 +111,6 @@
|
||||
jobKinds: getJobKinds(runnableType),
|
||||
syncQueuedRunsCount: false,
|
||||
refreshRate: 10000,
|
||||
computeMinAndMax: undefined,
|
||||
currentWorkspace: $workspaceStore ?? '',
|
||||
skip: !runnableId
|
||||
}) satisfies UseJobLoaderArgs
|
||||
|
||||
46
frontend/src/lib/components/RightClickPopover.svelte
Normal file
46
frontend/src/lib/components/RightClickPopover.svelte
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
184
frontend/src/lib/components/runs/TimeframeSelect.svelte
Normal file
184
frontend/src/lib/components/runs/TimeframeSelect.svelte
Normal 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>
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, '&')
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user