mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-10 16:05:58 +00:00
fix(frontend): Improve runs page ux (#6485)
* improve arg layout * improve runs row (wip) * Add job badges * group filters in dropdown * improve runs row layout * Improve filter layout * use select for graph display * handle width modification * Remove useless headers * fix bad display when result is null * Display all jobs tags * Improve display for 'step of flow' jobs * Add empty message for JobAssetsViewer * Move job preview assets tab to flow result for flows * Only show tag in the tag column * Add job kind to rows * Add padding to the run preview * nit * move refresh on top of table * Move filters into header bar * move runs table topbar outside table * Simplify layout * Use toggle for kind for large screen * move sync job and add batch actions breakpoint * revert dropdown to toggle for conurrency/duration * handle run labels overflow * improve time display * fix flow preview with no path display * Add titles * Prevent tab shift for script and flow result * nit * Allow job deselect * Make job link more visible * Fix filtering for queued job * Fix filter not reseting after select from toggleMore * Allways show assets for flow status viewer * Update run chart to svelte 5 and fix reactivity issue * migrate concurrency chart to svelte 5 * Improve admmin workspace display and fix missing in add filter popover * nit * fix run table resize * Add breakpoint to hide tag in small screens * use a css file for gathering RunRow and RunTable classes * nit * nit * remove debug log * nit * fix typo * Have too icons for queued workers and suspended * add gap before auto-refresh * Replace min max to from to calendar picker * Add loading state for job preview * Move duration * Display kind full width when calendar not set * Only show 2 digits for jobs duration * Replace Scheduled for by a clock un the run row * Fix typpo in dropown select to dropdown select * Hide sync and previews in toggle more * Fix runs row padding * Change notification colors for queued jobs * use utils debounce function * fix typo * nit * use class instead of classNames * clean select filter side effects
This commit is contained in:
Generated
+5
-4
@@ -49,7 +49,7 @@
|
||||
"highlight.js": "^11.8.0",
|
||||
"idb": "^8.0.2",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-svelte": "^0.399.0",
|
||||
"lucide-svelte": "^0.540.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@~20.2.1",
|
||||
"monaco-editor-wrapper": "6.12.0",
|
||||
@@ -7848,9 +7848,10 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/lucide-svelte": {
|
||||
"version": "0.399.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.399.0.tgz",
|
||||
"integrity": "sha512-NQ8AxNMKbIJsx7HV//gnAsIY1wJfb3rbXSK2S/ZDjIldvAEdzGngpUT8T8Q8zHYUuii0bavAmVARN8giR4vvpA==",
|
||||
"version": "0.540.0",
|
||||
"resolved": "https://registry.npmjs.org/lucide-svelte/-/lucide-svelte-0.540.0.tgz",
|
||||
"integrity": "sha512-jedJgKrrsT1B6mAMHGMmk6dIZKhorLq4KO7v+j7hYfeGR0LRe3W+SvwpWF1E6WT2/AY9IAm8KT/G+Nzf+0lW3g==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"svelte": "^3 || ^4 || ^5.0.0-next.42"
|
||||
}
|
||||
|
||||
@@ -116,7 +116,7 @@
|
||||
"highlight.js": "^11.8.0",
|
||||
"idb": "^8.0.2",
|
||||
"lru-cache": "^11.1.0",
|
||||
"lucide-svelte": "^0.399.0",
|
||||
"lucide-svelte": "^0.540.0",
|
||||
"minimatch": "^10.0.1",
|
||||
"monaco-editor": "npm:@codingame/monaco-vscode-editor-api@~20.2.1",
|
||||
"monaco-editor-wrapper": "6.12.0",
|
||||
|
||||
@@ -95,14 +95,14 @@
|
||||
<div class="relative">
|
||||
{#if JSON.stringify(value).length > 120}
|
||||
<button
|
||||
class="text-xs absolute top-0 right-4 text-tertiary"
|
||||
class="text-xs absolute top-0 right-8 text-tertiary"
|
||||
onclick={() => {
|
||||
jsonViewerContent = value
|
||||
jsonViewer?.toggleDrawer()
|
||||
}}><Expand size={18} /></button
|
||||
>
|
||||
{/if}
|
||||
<div class="max-h-60 overflow-auto">
|
||||
<div class="max-h-60 overflow-auto" style="scrollbar-gutter: stable">
|
||||
<ObjectViewer collapsed={false} topBrackets={true} pureViewer={true} json={value} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,16 +13,24 @@
|
||||
Tooltip
|
||||
} from 'chart.js'
|
||||
import type { CompletedJob, ExtendedJobs } from '$lib/gen'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { getDbClockNow } from '$lib/forLater'
|
||||
import { Line } from '$lib/components/chartjs-wrappers/chartJs'
|
||||
|
||||
export let extendedJobs: ExtendedJobs | undefined = undefined
|
||||
export let maxIsNow: boolean = false
|
||||
export let minTimeSet: string | undefined = undefined
|
||||
export let maxTimeSet: string | undefined = undefined
|
||||
interface Props {
|
||||
extendedJobs?: ExtendedJobs | undefined
|
||||
maxIsNow?: boolean
|
||||
minTimeSet?: string | undefined
|
||||
maxTimeSet?: string | undefined
|
||||
onZoom: (zoom: { min: Date; max: Date }) => void
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
let {
|
||||
extendedJobs = undefined,
|
||||
maxIsNow = false,
|
||||
minTimeSet = undefined,
|
||||
maxTimeSet = undefined,
|
||||
onZoom
|
||||
}: Props = $props()
|
||||
|
||||
function calculateTimeSeries(extendedJobs: ExtendedJobs): AggregatedInterval[] {
|
||||
const timeline = new Map<number, { count: number; id_started: string[]; id_ended: string[] }>()
|
||||
@@ -57,7 +65,7 @@
|
||||
}
|
||||
const s = timeline.get(startTime)!
|
||||
s.count += 1
|
||||
s.id_started.push('unknoww')
|
||||
s.id_started.push('unknown')
|
||||
if (j.duration_ms != undefined) {
|
||||
const jc = j as CompletedJob
|
||||
const endTime = startTime + jc.duration_ms
|
||||
@@ -103,9 +111,6 @@
|
||||
}
|
||||
type AggregatedInterval = { time: Date; count: number; msg?: string }
|
||||
|
||||
let intervals: AggregatedInterval[] | undefined = undefined
|
||||
$: intervals = extendedJobs ? calculateTimeSeries(extendedJobs) : undefined
|
||||
|
||||
ChartJS.register(
|
||||
Title,
|
||||
Tooltip,
|
||||
@@ -118,31 +123,12 @@
|
||||
TimeScale
|
||||
)
|
||||
|
||||
$: data = {
|
||||
datasets: [
|
||||
{
|
||||
borderColor: '#4ade80',
|
||||
backgroundColor: '#f8717100',
|
||||
pointRadius: 0,
|
||||
label: 'running',
|
||||
showLine: true,
|
||||
stepped: true,
|
||||
data:
|
||||
intervals?.map((job) => ({
|
||||
x: job.time as any,
|
||||
y: job.count,
|
||||
id: job.msg
|
||||
})) ?? []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const zoomOptions = {
|
||||
pan: {
|
||||
enabled: true,
|
||||
modifierKey: 'ctrl' as 'ctrl',
|
||||
onPanComplete: ({ chart }) => {
|
||||
dispatch('zoom', {
|
||||
onZoom({
|
||||
min: addSeconds(new Date(chart.scales.x.min), -1),
|
||||
max: addSeconds(new Date(chart.scales.x.max), 1)
|
||||
})
|
||||
@@ -154,17 +140,13 @@
|
||||
},
|
||||
mode: 'x' as 'x',
|
||||
onZoom: ({ chart }) => {
|
||||
dispatch('zoom', {
|
||||
onZoom({
|
||||
min: addSeconds(new Date(chart.scales.x.min), -1),
|
||||
max: addSeconds(new Date(chart.scales.x.max), 1)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
let minTime = addSeconds(new Date(), -300)
|
||||
let maxTime = getDbClockNow()
|
||||
|
||||
$: computeMinMaxTime(intervals, minTimeSet, maxTimeSet)
|
||||
|
||||
function minJobTime(intervals: AggregatedInterval[]): Date {
|
||||
return intervals[0].time
|
||||
@@ -181,15 +163,13 @@
|
||||
let minTimeSetDate = minTimeSet ? new Date(minTimeSet) : undefined
|
||||
let maxTimeSetDate = maxTimeSet ? new Date(maxTimeSet) : undefined
|
||||
if (minTimeSetDate && maxTimeSetDate) {
|
||||
minTime = minTimeSetDate
|
||||
maxTime = maxTimeSetDate
|
||||
return
|
||||
return { min: minTimeSetDate, max: maxTimeSetDate }
|
||||
}
|
||||
|
||||
if (intervals == undefined || intervals?.length == 0) {
|
||||
minTime = minTimeSetDate ?? addSeconds(new Date(), -300)
|
||||
maxTime = maxTimeSetDate ?? getDbClockNow()
|
||||
return
|
||||
const minTime = minTimeSetDate ?? addSeconds(new Date(), -300)
|
||||
const maxTime = maxTimeSetDate ?? getDbClockNow()
|
||||
return { min: minTime, max: maxTime }
|
||||
}
|
||||
|
||||
const maxJob = maxIsNow ? getDbClockNow() : maxJobTime(intervals)
|
||||
@@ -197,12 +177,11 @@
|
||||
|
||||
const diff = (maxJob.getTime() - minJob.getTime()) / 20000
|
||||
|
||||
minTime = minTimeSetDate ?? addSeconds(minJob, -diff)
|
||||
if (maxIsNow) {
|
||||
maxTime = maxTimeSetDate ?? maxJob
|
||||
} else {
|
||||
maxTime = maxTimeSetDate ?? addSeconds(maxJob, diff)
|
||||
}
|
||||
const minTime = minTimeSetDate ?? addSeconds(minJob, -diff)
|
||||
const maxTime = maxIsNow
|
||||
? (maxTimeSetDate ?? maxJob)
|
||||
: (maxTimeSetDate ?? addSeconds(maxJob, diff))
|
||||
return { min: minTime, max: maxTime }
|
||||
}
|
||||
|
||||
function addSeconds(date: Date, seconds: number): Date {
|
||||
@@ -210,7 +189,30 @@
|
||||
return date
|
||||
}
|
||||
|
||||
$: options = {
|
||||
const intervals = $derived(extendedJobs ? calculateTimeSeries(extendedJobs) : undefined)
|
||||
|
||||
let data = $derived({
|
||||
datasets: [
|
||||
{
|
||||
borderColor: '#4ade80',
|
||||
backgroundColor: '#f8717100',
|
||||
pointRadius: 0,
|
||||
label: 'running',
|
||||
showLine: true,
|
||||
stepped: true,
|
||||
data:
|
||||
intervals?.map((job) => ({
|
||||
x: job.time as any,
|
||||
y: job.count,
|
||||
id: job.msg
|
||||
})) ?? []
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
const minMaxTimes = $derived(computeMinMaxTime(intervals, minTimeSet, maxTimeSet))
|
||||
|
||||
let options = $derived({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
@@ -232,8 +234,8 @@
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
min: minTime,
|
||||
max: maxTime
|
||||
min: minMaxTimes.min,
|
||||
max: minMaxTimes.max
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
@@ -254,7 +256,7 @@
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
}
|
||||
} as any
|
||||
} as any)
|
||||
</script>
|
||||
|
||||
<div class="relative max-h-40">
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
<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>
|
||||
@@ -47,7 +47,7 @@
|
||||
aiDescription={item.displayName}
|
||||
>
|
||||
{#if item.icon}
|
||||
<item.icon size={14} color={item.iconColor} />
|
||||
<item.icon size={14} color={item.iconColor} class="shrink-0" />
|
||||
{/if}
|
||||
<p title={item.displayName} class="truncate grow min-w-0 whitespace-nowrap text-left">
|
||||
{item.displayName}
|
||||
|
||||
@@ -50,6 +50,7 @@
|
||||
AI_TOOL_MESSAGE_PREFIX,
|
||||
getToolCallId
|
||||
} from './graph/renderers/nodes/AIToolNode.svelte'
|
||||
import JobAssetsViewer from './assets/JobAssetsViewer.svelte'
|
||||
|
||||
let {
|
||||
flowStateStore,
|
||||
@@ -1048,7 +1049,11 @@
|
||||
}
|
||||
}
|
||||
})
|
||||
let selected = $derived(isListJob ? 'sequence' : 'graph') as 'sequence' | 'graph' | 'logs'
|
||||
let selected = $derived(isListJob ? 'sequence' : 'graph') as
|
||||
| 'sequence'
|
||||
| 'graph'
|
||||
| 'logs'
|
||||
| 'assets'
|
||||
|
||||
let animateLogsTab = $state(false)
|
||||
|
||||
@@ -1076,6 +1081,23 @@
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
// Set all tabs content to the same height to prevent layout jumps
|
||||
let tabsHeight = $state({
|
||||
sequenceHeight: 0,
|
||||
logsHeight: 0,
|
||||
assetsHeight: 0,
|
||||
graphHeight: 0
|
||||
})
|
||||
|
||||
let minTabHeight = $derived(
|
||||
Math.max(
|
||||
tabsHeight.sequenceHeight,
|
||||
tabsHeight.logsHeight,
|
||||
tabsHeight.assetsHeight,
|
||||
tabsHeight.graphHeight
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
<JobLoader workspaceOverride={workspaceId} {noLogs} noCode bind:this={jobLoader} />
|
||||
@@ -1154,12 +1176,17 @@
|
||||
: ''}><span class="font-semibold">Logs</span></Tab
|
||||
>
|
||||
<Tab value="sequence"><span class="font-semibold">Details</span></Tab>
|
||||
<Tab value="assets"><span class="font-semibold">Assets</span></Tab>
|
||||
</Tabs>
|
||||
{:else}
|
||||
<div class="h-[30px]"></div>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class="{selected != 'sequence' ? 'hidden' : ''} max-w-7xl mx-auto">
|
||||
<div
|
||||
class="{selected != 'sequence' ? 'hidden' : ''} max-w-7xl mx-auto"
|
||||
bind:clientHeight={tabsHeight.sequenceHeight}
|
||||
style="min-height: {minTabHeight}px"
|
||||
>
|
||||
{#if isListJob}
|
||||
{@const sliceFrom =
|
||||
globalIterationBounds[buildSubflowKey(flowJobIds?.moduleId ?? '', prefix)]
|
||||
@@ -1508,7 +1535,11 @@
|
||||
<div class="p-2 text-tertiary text-sm italic">Empty flow</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="{selected != 'logs' ? 'hidden' : ''} mx-auto h-[800px]">
|
||||
<div
|
||||
class="{selected != 'logs' ? 'hidden' : ''} mx-auto"
|
||||
bind:clientHeight={tabsHeight.logsHeight}
|
||||
style="min-height: {minTabHeight}px"
|
||||
>
|
||||
<FlowLogViewerWrapper
|
||||
{job}
|
||||
{localModuleStates}
|
||||
@@ -1517,10 +1548,23 @@
|
||||
{onSelectedIteration}
|
||||
/>
|
||||
</div>
|
||||
{#if selected == 'assets' && render}
|
||||
<div
|
||||
class="p-2"
|
||||
bind:clientHeight={tabsHeight.assetsHeight}
|
||||
style="min-height: {minTabHeight}px"
|
||||
>
|
||||
<JobAssetsViewer {job} />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if render}
|
||||
{#if job.raw_flow && !isListJob}
|
||||
<div class="{selected != 'graph' ? 'hidden' : ''} grow mt-4">
|
||||
<div
|
||||
class="{selected != 'graph' ? 'hidden' : ''} grow mt-4"
|
||||
bind:clientHeight={tabsHeight.graphHeight}
|
||||
style="min-height: {minTabHeight}px"
|
||||
>
|
||||
<div class="grid grid-cols-3 border h-full" bind:clientHeight={wrapperHeight}>
|
||||
<div class="col-span-2 bg-surface-secondary">
|
||||
<div class="flex flex-col">
|
||||
|
||||
@@ -85,7 +85,7 @@ ${Object.entries(args)
|
||||
{#each Object.entries(args).sort((a, b) => a[0].localeCompare(b[0])) as [arg, value]}
|
||||
<Row>
|
||||
<Cell first>{arg}</Cell>
|
||||
<Cell last><ArgInfo {value} /></Cell>
|
||||
<Cell><ArgInfo {value} /></Cell>
|
||||
</Row>
|
||||
{/each}
|
||||
{:else if args}
|
||||
|
||||
@@ -15,28 +15,42 @@
|
||||
LogarithmicScale
|
||||
} from 'chart.js'
|
||||
import type { CompletedJob } from '$lib/gen'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
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'
|
||||
|
||||
export let jobs: CompletedJob[] | undefined = []
|
||||
export let maxIsNow: boolean = false
|
||||
export let minTimeSet: string | undefined = undefined
|
||||
export let maxTimeSet: string | undefined = undefined
|
||||
export let selectedIds: string[] = []
|
||||
export let canSelect: boolean = true
|
||||
export let lastFetchWentToEnd: boolean = false
|
||||
interface Props {
|
||||
jobs?: CompletedJob[] | undefined
|
||||
maxIsNow?: boolean
|
||||
minTimeSet?: string | undefined
|
||||
maxTimeSet?: string | undefined
|
||||
selectedIds?: string[]
|
||||
canSelect?: boolean
|
||||
lastFetchWentToEnd?: boolean
|
||||
onPointClicked: (ids: string[]) => void
|
||||
onLoadExtra: () => void
|
||||
onZoom: (zoom: { min: Date; max: Date }) => void
|
||||
}
|
||||
|
||||
let {
|
||||
jobs = [],
|
||||
maxIsNow = false,
|
||||
minTimeSet = undefined,
|
||||
maxTimeSet = undefined,
|
||||
selectedIds = $bindable([]),
|
||||
canSelect = true,
|
||||
lastFetchWentToEnd = false,
|
||||
onPointClicked,
|
||||
onLoadExtra,
|
||||
onZoom
|
||||
}: Props = $props()
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
const SUCCESS_COLOR = '#4ade80'
|
||||
// const SUCCESS_COLOR_TRANSPARENT = '#c9b638'
|
||||
const SUCCESS_COLOR_TRANSPARENT = mergeColors(SUCCESS_COLOR, getBackgorundColor(), 0.8)
|
||||
const SUCCESS_COLOR_TRANSPARENT = $derived(mergeColors(SUCCESS_COLOR, getBackgorundColor(), 0.8))
|
||||
const FAIL_COLOR = '#f87171'
|
||||
const FAIL_COLOR_TRANSPARENT = mergeColors(FAIL_COLOR, getBackgorundColor(), 0.8)
|
||||
|
||||
$: success = jobs?.filter((x) => x.success)
|
||||
$: failed = jobs?.filter((x) => !x.success)
|
||||
const FAIL_COLOR_TRANSPARENT = $derived(mergeColors(FAIL_COLOR, getBackgorundColor(), 0.8))
|
||||
|
||||
ChartJS.register(
|
||||
Title,
|
||||
@@ -51,43 +65,12 @@
|
||||
TimeScale
|
||||
)
|
||||
|
||||
$: data = {
|
||||
datasets: [
|
||||
{
|
||||
borderColor: 'rgba(99,0,125, 0)',
|
||||
backgroundColor: FAIL_COLOR as string | string[],
|
||||
radius: 3,
|
||||
label: 'Failed',
|
||||
data:
|
||||
failed?.map((job) => ({
|
||||
x: job.started_at as any,
|
||||
y: job.duration_ms,
|
||||
id: job.id,
|
||||
path: job.script_path
|
||||
})) ?? []
|
||||
},
|
||||
{
|
||||
borderColor: 'rgba(99,0,125, 0)',
|
||||
backgroundColor: SUCCESS_COLOR as string | string[],
|
||||
radius: 3,
|
||||
label: 'Successful',
|
||||
data:
|
||||
success?.map((job) => ({
|
||||
x: job.started_at as any,
|
||||
y: job.duration_ms,
|
||||
id: job.id,
|
||||
path: job.script_path
|
||||
})) ?? []
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const zoomOptions = {
|
||||
pan: {
|
||||
enabled: true,
|
||||
modifierKey: 'ctrl' as 'ctrl',
|
||||
onPanComplete: ({ chart }) => {
|
||||
dispatch('zoom', {
|
||||
onZoom({
|
||||
min: addSeconds(new Date(chart.scales.x.min), -1),
|
||||
max: addSeconds(new Date(chart.scales.x.max), 1)
|
||||
})
|
||||
@@ -99,7 +82,7 @@
|
||||
},
|
||||
mode: 'x' as 'x',
|
||||
onZoom: ({ chart }) => {
|
||||
dispatch('zoom', {
|
||||
onZoom({
|
||||
min: addSeconds(new Date(chart.scales.x.min), -1),
|
||||
max: addSeconds(new Date(chart.scales.x.max), 1)
|
||||
})
|
||||
@@ -107,15 +90,14 @@
|
||||
}
|
||||
}
|
||||
|
||||
function isDark(): boolean {
|
||||
return document.documentElement.classList.contains('dark')
|
||||
}
|
||||
|
||||
ChartJS.defaults.color = isDark() ? '#ccc' : '#666'
|
||||
ChartJS.defaults.borderColor = isDark() ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
|
||||
let darkMode = $state(false)
|
||||
$effect(() => {
|
||||
ChartJS.defaults.color = darkMode ? '#ccc' : '#666'
|
||||
ChartJS.defaults.borderColor = darkMode ? 'rgba(255, 255, 255, 0.1)' : 'rgba(0, 0, 0, 0.1)'
|
||||
})
|
||||
|
||||
function getBackgorundColor(): string {
|
||||
return isDark() ? '#2e3440' : '#ffffff'
|
||||
return darkMode ? '#2e3440' : '#ffffff'
|
||||
}
|
||||
function hexToRgb(hexColor: string): number[] {
|
||||
hexColor = hexColor.replace(/^#/, '')
|
||||
@@ -150,29 +132,10 @@
|
||||
return rgbToHex(blendedRgb)
|
||||
}
|
||||
|
||||
function highlightSelectedPoints(ids: string[]) {
|
||||
if (!canSelect || ids.length === 0) {
|
||||
data.datasets[0].backgroundColor = FAIL_COLOR
|
||||
data.datasets[1].backgroundColor = SUCCESS_COLOR
|
||||
} else {
|
||||
data.datasets[0].backgroundColor = data.datasets[0].data.map((p) =>
|
||||
ids.includes(p.id) ? FAIL_COLOR : FAIL_COLOR_TRANSPARENT
|
||||
)
|
||||
data.datasets[1].backgroundColor = data.datasets[1].data.map((p) =>
|
||||
ids.includes(p.id) ? SUCCESS_COLOR : SUCCESS_COLOR_TRANSPARENT
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function getPath(x: any): string {
|
||||
return x.path
|
||||
}
|
||||
|
||||
let minTime = addSeconds(new Date(), -300)
|
||||
let maxTime = getDbClockNow()
|
||||
|
||||
$: computeMinMaxTime(jobs, minTimeSet, maxTimeSet)
|
||||
|
||||
function minJobTime(jobs: CompletedJob[]): Date {
|
||||
let min: Date = new Date(jobs[0].started_at)
|
||||
for (const job of jobs) {
|
||||
@@ -195,6 +158,7 @@
|
||||
}
|
||||
return max
|
||||
}
|
||||
|
||||
function computeMinMaxTime(
|
||||
jobs: CompletedJob[] | undefined,
|
||||
minTimeSet: string | undefined,
|
||||
@@ -203,15 +167,13 @@
|
||||
let minTimeSetDate = minTimeSet ? new Date(minTimeSet) : undefined
|
||||
let maxTimeSetDate = maxTimeSet ? new Date(maxTimeSet) : undefined
|
||||
if (minTimeSetDate && maxTimeSetDate) {
|
||||
minTime = minTimeSetDate
|
||||
maxTime = maxTimeSetDate
|
||||
return
|
||||
return { minTime: minTimeSetDate, maxTime: maxTimeSetDate }
|
||||
}
|
||||
|
||||
if (jobs == undefined || jobs?.length == 0) {
|
||||
minTime = minTimeSetDate ?? addSeconds(new Date(), -300)
|
||||
maxTime = maxTimeSetDate ?? getDbClockNow()
|
||||
return
|
||||
const computedMinTime = minTimeSetDate ?? addSeconds(new Date(), -300)
|
||||
const computedMaxTime = maxTimeSetDate ?? getDbClockNow()
|
||||
return { minTime: computedMinTime, maxTime: computedMaxTime }
|
||||
}
|
||||
|
||||
const maxJob = maxIsNow ? getDbClockNow() : maxJobTime(jobs)
|
||||
@@ -219,12 +181,11 @@
|
||||
|
||||
const diff = (maxJob.getTime() - minJob.getTime()) / 20000
|
||||
|
||||
minTime = minTimeSetDate ?? addSeconds(minJob, -diff)
|
||||
if (maxIsNow) {
|
||||
maxTime = maxTimeSetDate ?? maxJob
|
||||
} else {
|
||||
maxTime = maxTimeSetDate ?? addSeconds(maxJob, diff)
|
||||
}
|
||||
let computedMinTime = minTimeSetDate ?? addSeconds(minJob, -diff)
|
||||
let computedMaxTime = maxIsNow
|
||||
? (maxTimeSetDate ?? maxJob)
|
||||
: (maxTimeSetDate ?? addSeconds(maxJob, diff))
|
||||
return { minTime: computedMinTime, maxTime: computedMaxTime }
|
||||
}
|
||||
|
||||
function addSeconds(date: Date, seconds: number): Date {
|
||||
@@ -232,7 +193,56 @@
|
||||
return date
|
||||
}
|
||||
|
||||
$: scatterOptions = {
|
||||
let success = $derived(jobs?.filter((x) => x.success))
|
||||
let failed = $derived(jobs?.filter((x) => !x.success))
|
||||
let data = $derived.by(() => {
|
||||
const data = {
|
||||
datasets: [
|
||||
{
|
||||
borderColor: 'rgba(99,0,125, 0)',
|
||||
backgroundColor: FAIL_COLOR as string | string[],
|
||||
radius: 3,
|
||||
label: 'Failed',
|
||||
data:
|
||||
failed?.map((job) => ({
|
||||
x: job.started_at as any,
|
||||
y: job.duration_ms,
|
||||
id: job.id,
|
||||
path: job.script_path
|
||||
})) ?? []
|
||||
},
|
||||
{
|
||||
borderColor: 'rgba(99,0,125, 0)',
|
||||
backgroundColor: SUCCESS_COLOR as string | string[],
|
||||
radius: 3,
|
||||
label: 'Successful',
|
||||
data:
|
||||
success?.map((job) => ({
|
||||
x: job.started_at as any,
|
||||
y: job.duration_ms,
|
||||
id: job.id,
|
||||
path: job.script_path
|
||||
})) ?? []
|
||||
}
|
||||
]
|
||||
}
|
||||
if (!canSelect || selectedIds.length === 0) {
|
||||
data.datasets[0].backgroundColor = FAIL_COLOR
|
||||
data.datasets[1].backgroundColor = SUCCESS_COLOR
|
||||
} else {
|
||||
data.datasets[0].backgroundColor = data.datasets[0].data.map((p) =>
|
||||
selectedIds.includes(p.id) ? FAIL_COLOR : FAIL_COLOR_TRANSPARENT
|
||||
)
|
||||
data.datasets[1].backgroundColor = data.datasets[1].data.map((p) =>
|
||||
selectedIds.includes(p.id) ? SUCCESS_COLOR : SUCCESS_COLOR_TRANSPARENT
|
||||
)
|
||||
}
|
||||
return data
|
||||
})
|
||||
|
||||
const minMaxTime = $derived.by(() => computeMinMaxTime(jobs, minTimeSet, maxTimeSet))
|
||||
|
||||
let scatterOptions = $derived({
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
@@ -242,17 +252,17 @@
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function (context) {
|
||||
label: function (context: any) {
|
||||
return getPath(context.raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
onClick: (e, u) => {
|
||||
onClick: (_e: any, u: any) => {
|
||||
if (canSelect) {
|
||||
const ids = u.map((j) => data.datasets[j.datasetIndex].data[j.index].id)
|
||||
const ids = u.map((j: any) => data.datasets[j.datasetIndex].data[j.index].id)
|
||||
selectedIds = ids
|
||||
dispatch('pointClicked', ids)
|
||||
onPointClicked(ids)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -262,8 +272,8 @@
|
||||
grid: {
|
||||
display: false
|
||||
},
|
||||
min: minTime,
|
||||
max: maxTime
|
||||
min: minMaxTime.minTime,
|
||||
max: minMaxTime.maxTime
|
||||
},
|
||||
y: {
|
||||
grid: {
|
||||
@@ -277,11 +287,11 @@
|
||||
}
|
||||
},
|
||||
animation: false
|
||||
} as any
|
||||
|
||||
$: data && scatterOptions && highlightSelectedPoints(selectedIds)
|
||||
} as any)
|
||||
</script>
|
||||
|
||||
<DarkModeObserver bind:darkMode />
|
||||
|
||||
<!-- {JSON.stringify(minTime)}
|
||||
{JSON.stringify(maxTime)}
|
||||
|
||||
@@ -291,12 +301,8 @@
|
||||
<!-- {JSON.stringify(jobs?.map((x) => x.started_at))} -->
|
||||
<div class="relative max-h-40">
|
||||
{#if !lastFetchWentToEnd}
|
||||
<div class="absolute top-[-10px] left-[60px]"
|
||||
><Button
|
||||
size="xs"
|
||||
color="transparent"
|
||||
variant="contained"
|
||||
on:click={() => dispatch('loadExtra')}
|
||||
<div class="absolute top-[-26px] left-[160px]"
|
||||
><Button size="xs" color="transparent" variant="contained" on:click={() => onLoadExtra()}
|
||||
>Load more <Tooltip2
|
||||
>There are more jobs to load but only the first 1000 were fetched</Tooltip2
|
||||
></Button
|
||||
|
||||
@@ -6,10 +6,10 @@
|
||||
export let date: string
|
||||
export let agoOnlyIfRecent: boolean = false
|
||||
export let noDate = false
|
||||
export let isRecent: boolean = true
|
||||
|
||||
let computedTimeAgo: string | undefined = undefined
|
||||
|
||||
let isRecent = true
|
||||
let interval
|
||||
|
||||
$: date && computeDate()
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
right?: string
|
||||
rightTooltip?: string
|
||||
rightDocumentationLink?: string
|
||||
title?: string
|
||||
} = {}
|
||||
export let checked: boolean = false
|
||||
export let disabled = false
|
||||
@@ -37,6 +38,7 @@
|
||||
class="{$$props.class || ''} z-auto flex flex-row items-center duration-50 {disabled
|
||||
? 'grayscale opacity-50'
|
||||
: 'cursor-pointer'}"
|
||||
title={options?.title}
|
||||
>
|
||||
{#if Boolean(options?.left)}
|
||||
<span
|
||||
@@ -103,7 +105,7 @@
|
||||
class={twMerge(
|
||||
'ml-2 font-medium duration-50 select-none',
|
||||
bothOptions || textDisabled ? (checked ? 'text-primary' : 'text-disabled') : 'text-primary',
|
||||
size === 'xs' || size === '2sm' ? 'text-xs' : 'text-sm',
|
||||
size === 'xs' || size === '2sm' ? 'text-xs' : size === '2xs' ? 'text-xs' : 'text-sm',
|
||||
textClass
|
||||
)}
|
||||
style={textStyle}
|
||||
|
||||
@@ -65,30 +65,34 @@
|
||||
let resourceEditorDrawer: ResourceEditorDrawer | undefined = $state()
|
||||
</script>
|
||||
|
||||
<ul class="flex flex-col divide-y mt-1">
|
||||
{#each assets.value ?? [] as asset}
|
||||
<li class="flex justify-between py-3 leading-4 text-sm pl-4">
|
||||
<div class="flex flex-col flex-1 truncate">
|
||||
{asset.path}
|
||||
<span class="text-2xs text-tertiary">
|
||||
{formatAssetKind({
|
||||
...asset,
|
||||
...(asset.kind === 'resource'
|
||||
? { metadata: { resource_type: resourceDataCache[asset.path] } }
|
||||
: {})
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<AssetButtons
|
||||
{asset}
|
||||
{resourceDataCache}
|
||||
{dbManagerDrawer}
|
||||
{resourceEditorDrawer}
|
||||
{s3FilePicker}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{#if assets.value && assets.value.length > 0}
|
||||
<ul class="flex flex-col divide-y mt-1">
|
||||
{#each assets.value ?? [] as asset}
|
||||
<li class="flex justify-between py-3 leading-4 text-sm pl-4">
|
||||
<div class="flex flex-col flex-1 truncate">
|
||||
{asset.path}
|
||||
<span class="text-2xs text-tertiary">
|
||||
{formatAssetKind({
|
||||
...asset,
|
||||
...(asset.kind === 'resource'
|
||||
? { metadata: { resource_type: resourceDataCache[asset.path] } }
|
||||
: {})
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<AssetButtons
|
||||
{asset}
|
||||
{resourceDataCache}
|
||||
{dbManagerDrawer}
|
||||
{resourceEditorDrawer}
|
||||
{s3FilePicker}
|
||||
/>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<div class="text-sm text-tertiary">No assets found</div>
|
||||
{/if}
|
||||
|
||||
<S3FilePicker bind:this={s3FilePicker} readOnlyMode />
|
||||
<DbManagerDrawer bind:this={dbManagerDrawer} />
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
export let baseClass = 'text-center'
|
||||
export let capitalize = false
|
||||
export let icon: BadgeIconProps | undefined = undefined
|
||||
export let verySmall = false
|
||||
|
||||
let hidden = false
|
||||
const colors: Record<BadgeColor, string> = {
|
||||
@@ -46,11 +47,18 @@
|
||||
|
||||
$: badgeClass = classNames(
|
||||
baseClass,
|
||||
small ? 'text-xs' : large ? 'text-sm font-medium' : 'text-xs font-semibold',
|
||||
small
|
||||
? 'text-xs'
|
||||
: verySmall
|
||||
? 'text-2xs'
|
||||
: large
|
||||
? 'text-sm font-medium'
|
||||
: 'text-xs font-semibold',
|
||||
colors[color],
|
||||
href &&
|
||||
(color.startsWith(ColorModifier) ? hovers[color.replace(ColorModifier, '')] : hovers[color]),
|
||||
rounded ? 'rounded-full px-2 py-1' : 'rounded px-2.5 py-0.5',
|
||||
verySmall ? 'px-0.5 py-0.5' : '',
|
||||
'flex flex-row gap-1 items-center',
|
||||
$$props.class
|
||||
)
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
import type { Placement } from '@floating-ui/core'
|
||||
import DateTimeInput from '$lib/components/DateTimeInput.svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export let date: string | undefined
|
||||
export let label: string
|
||||
@@ -25,7 +26,10 @@
|
||||
<svelte:fragment slot="trigger">
|
||||
<button
|
||||
title="Open calendar picker"
|
||||
class="absolute bottom-1 right-2 top-1 py-1 min-w-min !px-2 items-center text-primary bg-surface border rounded center-center hover:bg-surface-hover transition-all cursor-pointer"
|
||||
class={twMerge(
|
||||
'absolute bottom-1 right-2 top-1 py-1 min-w-min !px-2 items-center text-primary bg-surface border rounded center-center hover:bg-surface-hover transition-all cursor-pointer',
|
||||
$$props.class
|
||||
)}
|
||||
aria-label="Open calendar picker"
|
||||
on:click={() => {
|
||||
input?.focus()
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
type TogglableItem = {
|
||||
label: string
|
||||
value: string
|
||||
tooltip?: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
@@ -29,11 +30,11 @@
|
||||
togglableItems
|
||||
}: Props = $props()
|
||||
|
||||
function select(v: string) {
|
||||
selected = v
|
||||
}
|
||||
|
||||
let items = togglableItems.map((i) => ({ displayName: i.label, action: () => select(i.value) }))
|
||||
let items = togglableItems.map((i) => ({
|
||||
displayName: i.label,
|
||||
action: () => (selected = i.value),
|
||||
tooltip: i.tooltip
|
||||
}))
|
||||
|
||||
function isAnOptionSelected(selected: string | undefined) {
|
||||
return togglableItems.some((i) => i.value === selected)
|
||||
@@ -48,6 +49,7 @@
|
||||
>
|
||||
<div {id} class="flex">
|
||||
{#if isAnOptionSelected(selected)}
|
||||
{@const tooltip = togglableItems.find((i) => i.value === selected)?.tooltip}
|
||||
<ToggleButton
|
||||
{disabled}
|
||||
value={selected ?? ''}
|
||||
@@ -56,6 +58,8 @@
|
||||
{light}
|
||||
{id}
|
||||
label={togglableItems.find((i) => i.value === selected)?.label}
|
||||
{tooltip}
|
||||
showTooltipIcon={!!tooltip}
|
||||
/>
|
||||
{/if}
|
||||
<div class="flex items-center">
|
||||
|
||||
@@ -1,13 +1,23 @@
|
||||
<script lang="ts">
|
||||
interface Props {
|
||||
height?: string;
|
||||
width?: string;
|
||||
height?: string
|
||||
width?: string
|
||||
size?: number
|
||||
}
|
||||
|
||||
let { height = '24px', width = '24px' }: Props = $props();
|
||||
let { height, width, size = 24 }: Props = $props()
|
||||
|
||||
let derivedWidth = $derived(width || `${size}px`)
|
||||
let derivedHeight = $derived(height || `${size}px`)
|
||||
</script>
|
||||
|
||||
<svg {width} {height} viewBox="0 0 100 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<svg
|
||||
width={derivedWidth}
|
||||
height={derivedHeight}
|
||||
viewBox="0 0 100 100"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
fill="#F05133"
|
||||
d="M92.71 44.408 52.591 4.291c-2.31-2.311-6.057-2.311-8.369 0l-8.33 8.332L46.459 23.19c2.456-.83 5.272-.273 7.229 1.685 1.969 1.97 2.521 4.81 1.67 7.275l10.186 10.185c2.465-.85 5.307-.3 7.275 1.671 2.75 2.75 2.75 7.206 0 9.958-2.752 2.751-7.208 2.751-9.961 0-2.068-2.07-2.58-5.11-1.531-7.658l-9.5-9.499v24.997c.67.332 1.303.774 1.861 1.332 2.75 2.75 2.75 7.206 0 9.959-2.75 2.749-7.209 2.749-9.957 0-2.75-2.754-2.75-7.21 0-9.959.68-.679 1.467-1.193 2.307-1.537v-25.23c-.84-.344-1.625-.853-2.307-1.537-2.083-2.082-2.584-5.14-1.516-7.698L31.798 16.715 4.288 44.222c-2.311 2.313-2.311 6.06 0 8.371l40.121 40.118c2.31 2.311 6.056 2.311 8.369 0L92.71 52.779c2.311-2.311 2.311-6.06 0-8.371z"
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
import { fade } from 'svelte/transition'
|
||||
import { X, Minimize2, Maximize2 } from 'lucide-svelte'
|
||||
import type { Placement } from '@floating-ui/core'
|
||||
import { pointerDownOutside } from '$lib/utils'
|
||||
import { debounce, pointerDownOutside } from '$lib/utils'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { Button } from '$lib/components/common'
|
||||
@@ -24,6 +24,7 @@
|
||||
export let placement: Placement = 'bottom'
|
||||
export let disablePopup: boolean = false
|
||||
export let openOnHover: boolean = false
|
||||
export let debounceDelay: number = 0
|
||||
export let floatingConfig: any | undefined = undefined
|
||||
export let usePointerDownOutside: boolean = false
|
||||
export let closeOnOutsideClick: boolean = true
|
||||
@@ -41,6 +42,15 @@
|
||||
let fullScreen = false
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
function clearTimers() {
|
||||
clearDebounceClose()
|
||||
clearDebounceOpen()
|
||||
}
|
||||
|
||||
// Cleanup timers on component destruction
|
||||
import { onDestroy } from 'svelte'
|
||||
onDestroy(clearTimers)
|
||||
|
||||
const {
|
||||
elements: { trigger, content, arrow, close: closeElement, overlay },
|
||||
states,
|
||||
@@ -109,6 +119,15 @@
|
||||
async function getMenuElements(): Promise<HTMLElement[]> {
|
||||
return Array.from(document.querySelectorAll('[data-popover]')) as HTMLElement[]
|
||||
}
|
||||
|
||||
let { debounced: debounceClose, clearDebounce: clearDebounceClose } = debounce(
|
||||
() => openOnHover && close(),
|
||||
debounceDelay
|
||||
)
|
||||
let { debounced: debounceOpen, clearDebounce: clearDebounceOpen } = debounce(
|
||||
() => openOnHover && open(),
|
||||
debounceDelay
|
||||
)
|
||||
</script>
|
||||
|
||||
<button
|
||||
@@ -116,8 +135,8 @@
|
||||
use:melt={$trigger}
|
||||
aria-label="Popup button"
|
||||
disabled={disablePopup || disabled}
|
||||
on:mouseenter={() => (openOnHover ? open() : null)}
|
||||
on:mouseleave={() => (openOnHover ? close() : null)}
|
||||
on:mouseenter={debounceOpen}
|
||||
on:mouseleave={debounceClose}
|
||||
use:pointerDownOutside={{
|
||||
capture: true,
|
||||
stopPropagation: false,
|
||||
@@ -141,6 +160,8 @@
|
||||
|
||||
{#if isOpen && !disablePopup}
|
||||
<div
|
||||
on:mouseenter={debounceOpen}
|
||||
on:mouseleave={debounceClose}
|
||||
use:melt={$content}
|
||||
transition:fade={{ duration: 0 }}
|
||||
class={twMerge(
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
import Popover from '../Popover.svelte'
|
||||
import { isFlowPreview, isScriptPreview, truncateRev } from '$lib/utils'
|
||||
import { createEventDispatcher, setContext, untrack } from 'svelte'
|
||||
import { ListFilter } from 'lucide-svelte'
|
||||
import { ListFilter, LoaderCircle } from 'lucide-svelte'
|
||||
import FlowAssetsHandler, { initFlowGraphAssetsCtx } from '../flows/FlowAssetsHandler.svelte'
|
||||
import JobAssetsViewer from '../assets/JobAssetsViewer.svelte'
|
||||
|
||||
@@ -78,199 +78,244 @@
|
||||
})
|
||||
|
||||
let jobLoader: JobLoader | undefined = $state(undefined)
|
||||
|
||||
// Set all tabs content to the same height to prevent layout jumps
|
||||
let tabsHeigh = $state({
|
||||
codeHeight: 0,
|
||||
logsHeight: 0,
|
||||
assetsHeight: 0,
|
||||
resultHeight: 0
|
||||
})
|
||||
|
||||
let minTabHeight = $derived(
|
||||
Math.max(
|
||||
tabsHeigh.codeHeight,
|
||||
tabsHeigh.logsHeight,
|
||||
tabsHeigh.assetsHeight,
|
||||
tabsHeigh.resultHeight
|
||||
)
|
||||
)
|
||||
|
||||
let jobIsLoading = $state(false)
|
||||
</script>
|
||||
|
||||
<JobLoader workspaceOverride={workspace} bind:job={currentJob} bind:this={jobLoader} />
|
||||
<JobLoader
|
||||
workspaceOverride={workspace}
|
||||
bind:job={currentJob}
|
||||
bind:isLoading={jobIsLoading}
|
||||
bind:this={jobLoader}
|
||||
/>
|
||||
|
||||
<div class="p-4 flex flex-col gap-2 items-start h-full">
|
||||
{#if job}
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{#if job?.['priority']}
|
||||
<Badge color="red">
|
||||
priority: {job?.['priority']}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if job && 'duration_ms' in job && job.duration_ms != undefined}
|
||||
<DurationMs
|
||||
duration_ms={job.duration_ms}
|
||||
self_wait_time_ms={job?.self_wait_time_ms}
|
||||
aggregate_wait_time_ms={job?.aggregate_wait_time_ms}
|
||||
/>
|
||||
{/if}
|
||||
{#if job?.['mem_peak']}
|
||||
<Badge large>
|
||||
Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if workspace && $workspaceStore != workspace}
|
||||
<Badge large>
|
||||
Workspace: {workspace}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if job.tag}
|
||||
<Badge large>
|
||||
Tag: {job.tag}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0}
|
||||
{#each job?.['labels'] as label}
|
||||
<Badge baseClass="text-2xs">Label: {label}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if concurrencyKey}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
This job has concurrency limits enabled with the key:
|
||||
<Button
|
||||
class="inline-text"
|
||||
size="xs2"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
dispatch('filterByConcurrencyKey', concurrencyKey)
|
||||
}}
|
||||
>
|
||||
{concurrencyKey}
|
||||
<ListFilter class="inline-block" size={10} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
<Badge large>Concurrency: {truncateRev(concurrencyKey, 20)}</Badge>
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if job?.worker}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
This job was run on worker:
|
||||
<Button
|
||||
class="inline-text"
|
||||
size="xs2"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
dispatch('filterByWorker', job?.worker)
|
||||
}}
|
||||
>
|
||||
{job?.worker}
|
||||
<ListFilter class="inline-block" size={10} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
<Badge large>Worker: {truncateRev(job.worker, 20)}</Badge>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
<a
|
||||
href="{base}/run/{job?.id}?workspace={job?.workspace_id}"
|
||||
class="flex flex-row gap-1 items-center"
|
||||
target={blankLink ? '_blank' : undefined}
|
||||
>
|
||||
<span class="font-semibold text-sm leading-6">ID:</span>
|
||||
<span class="text-sm">{job?.id ?? ''}</span>
|
||||
</a>
|
||||
|
||||
<span class="font-semibold text-xs leading-6">Arguments</span>
|
||||
|
||||
<div class="w-full">
|
||||
<JobArgs
|
||||
id={job?.id}
|
||||
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
|
||||
args={job?.args}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if job?.type === 'CompletedJob'}
|
||||
<span class="font-semibold text-xs leading-6">Results</span>
|
||||
{/if}
|
||||
|
||||
{#if job && 'scheduled_for' in job && !job.running && job.scheduled_for && forLater(job.scheduled_for)}
|
||||
<div class="text-sm font-semibold text-tertiary mb-1">
|
||||
<div>Job is scheduled for</div>
|
||||
<div>{new Date(job?.['scheduled_for']).toLocaleString()}</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class=" w-full rounded-md min-h-full">
|
||||
{#if job?.workflow_as_code_status}
|
||||
<WorkflowTimeline
|
||||
flow_status={asWorkflowStatus(job.workflow_as_code_status)}
|
||||
flowDone={job.type == 'CompletedJob'}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if job?.type === 'CompletedJob'}
|
||||
<Tabs bind:selected={viewTab}>
|
||||
<Tab size="xs" value="result">Result</Tab>
|
||||
<Tab size="xs" value="logs">Logs</Tab>
|
||||
<Tab size="xs" value="assets">Assets</Tab>
|
||||
{#if isScriptPreview(job?.job_kind)}
|
||||
<Tab size="xs" value="code">Code</Tab>
|
||||
{/if}
|
||||
</Tabs>
|
||||
|
||||
<Skeleton loading={!job} layout={[[5]]} />
|
||||
{#if job}
|
||||
{#if viewTab == 'result' && (job?.job_kind == 'flow' || isFlowPreview(job?.job_kind))}
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="w-full mt-10 mb-20">
|
||||
<FlowStatusViewer jobId={job.id} workspaceId={job.workspace_id} />
|
||||
</div>
|
||||
</div>
|
||||
{:else if viewTab == 'assets'}
|
||||
<JobAssetsViewer {job} />
|
||||
{:else}
|
||||
<div class="flex flex-col border rounded-md p-2 mt-2 h-full overflow-auto">
|
||||
{#if viewTab == 'logs'}
|
||||
<div class="w-full">
|
||||
<LogViewer
|
||||
jobId={job.id}
|
||||
duration={job?.['duration_ms']}
|
||||
mem={job?.['mem_peak']}
|
||||
isLoading={job?.['running'] == false}
|
||||
content={job?.logs}
|
||||
tag={job?.tag}
|
||||
/>
|
||||
</div>
|
||||
{:else if viewTab == 'code'}
|
||||
{#if job && 'raw_code' in job && job.raw_code}
|
||||
<div class="text-xs">
|
||||
<HighlightCode lines language={job.language} code={job.raw_code} />
|
||||
</div>
|
||||
{:else if job}
|
||||
No code is available
|
||||
{:else}
|
||||
<Skeleton layout={[[5]]} />
|
||||
{/if}
|
||||
{:else if job !== undefined && (job.result_stream || (job.type == 'CompletedJob' && job.result !== undefined))}
|
||||
<DisplayResult
|
||||
workspaceId={job?.workspace_id}
|
||||
jobId={job?.id}
|
||||
{result}
|
||||
disableExpand
|
||||
language={job?.language}
|
||||
/>
|
||||
{:else if job}
|
||||
No output is available yet
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="h-full overflow-y-auto">
|
||||
<div class="flex flex-col gap-2 items-start p-4 pb-8 min-h-full">
|
||||
{#if job}
|
||||
<div class="flex gap-2 flex-wrap">
|
||||
{#if job?.['priority']}
|
||||
<Badge color="red">
|
||||
priority: {job?.['priority']}
|
||||
</Badge>
|
||||
{/if}
|
||||
{:else if job && `running` in job ? job.running : false}
|
||||
{#if job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)}
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<FlowProgressBar {job} class="py-4" />
|
||||
<FlowStatusViewer jobId={job.id} workspaceId={job.workspace_id} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-sm font-semibold text-tertiary mb-1"> Job is still running </div>
|
||||
<LogViewer
|
||||
jobId={job?.id}
|
||||
duration={job?.['duration_ms']}
|
||||
mem={job?.['mem_peak']}
|
||||
content={job?.logs}
|
||||
isLoading={job?.['running'] == false}
|
||||
tag={job?.tag}
|
||||
{#if job && 'duration_ms' in job && job.duration_ms != undefined}
|
||||
<DurationMs
|
||||
duration_ms={job.duration_ms}
|
||||
self_wait_time_ms={job?.self_wait_time_ms}
|
||||
aggregate_wait_time_ms={job?.aggregate_wait_time_ms}
|
||||
/>
|
||||
{/if}
|
||||
{#if job?.['mem_peak']}
|
||||
<Badge large>
|
||||
Mem: {job?.['mem_peak'] ? `${(job['mem_peak'] / 1024).toPrecision(4)}MB` : 'N/A'}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if workspace && $workspaceStore != workspace}
|
||||
<Badge large>
|
||||
Workspace: {workspace}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if job.tag}
|
||||
<Badge large>
|
||||
Tag: {job.tag}
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0}
|
||||
{#each job?.['labels'] as label}
|
||||
<Badge baseClass="text-2xs">Label: {label}</Badge>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if concurrencyKey}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
This job has concurrency limits enabled with the key:
|
||||
<Button
|
||||
class="inline-text"
|
||||
size="xs2"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
dispatch('filterByConcurrencyKey', concurrencyKey)
|
||||
}}
|
||||
>
|
||||
{concurrencyKey}
|
||||
<ListFilter class="inline-block" size={10} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
<Badge large>Concurrency: {truncateRev(concurrencyKey, 20)}</Badge>
|
||||
</Popover>
|
||||
{/if}
|
||||
{#if job?.worker}
|
||||
<Popover notClickable>
|
||||
{#snippet text()}
|
||||
This job was run on worker:
|
||||
<Button
|
||||
class="inline-text"
|
||||
size="xs2"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
dispatch('filterByWorker', job?.worker)
|
||||
}}
|
||||
>
|
||||
{job?.worker}
|
||||
<ListFilter class="inline-block" size={10} />
|
||||
</Button>
|
||||
{/snippet}
|
||||
<Badge large>Worker: {truncateRev(job.worker, 20)}</Badge>
|
||||
</Popover>
|
||||
{/if}
|
||||
</div>
|
||||
<a
|
||||
href="{base}/run/{job?.id}?workspace={job?.workspace_id}"
|
||||
class="flex flex-row gap-1 items-center"
|
||||
target={blankLink ? '_blank' : undefined}
|
||||
>
|
||||
<span class="font-semibold text-sm leading-6">ID:</span>
|
||||
<span class="text-sm">{job?.id ?? ''}</span>
|
||||
</a>
|
||||
|
||||
<div class="w-full">
|
||||
<JobArgs
|
||||
id={job?.id}
|
||||
workspace={job?.workspace_id ?? $workspaceStore ?? 'no_w'}
|
||||
args={job?.args}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if job && 'scheduled_for' in job && !job.running && job.scheduled_for && forLater(job.scheduled_for)}
|
||||
<div class="text-sm font-semibold text-tertiary mb-1">
|
||||
<div>Job is scheduled for</div>
|
||||
<div>{new Date(job?.['scheduled_for']).toLocaleString()}</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="w-full rounded-md min-h-full">
|
||||
{#if job?.workflow_as_code_status}
|
||||
<WorkflowTimeline
|
||||
flow_status={asWorkflowStatus(job.workflow_as_code_status)}
|
||||
flowDone={job.type == 'CompletedJob'}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if job?.type === 'CompletedJob'}
|
||||
{#if job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)}
|
||||
<div class="w-full mt-8 mb-20">
|
||||
<FlowStatusViewer jobId={job.id} workspaceId={job.workspace_id} wideResults
|
||||
></FlowStatusViewer>
|
||||
</div>
|
||||
{:else}
|
||||
<Tabs bind:selected={viewTab}>
|
||||
<Tab size="xs" value="result">Results</Tab>
|
||||
<Tab size="xs" value="logs">Logs</Tab>
|
||||
<Tab size="xs" value="assets">Assets</Tab>
|
||||
{#if isScriptPreview(job?.job_kind)}
|
||||
<Tab size="xs" value="code">Code</Tab>
|
||||
{/if}
|
||||
</Tabs>
|
||||
|
||||
<Skeleton loading={!job} layout={[[5]]} />
|
||||
{#if job}
|
||||
<div class="flex flex-col border rounded-md p-2 mt-2 overflow-auto">
|
||||
{#if viewTab == 'logs'}
|
||||
<div
|
||||
class="w-full"
|
||||
bind:clientHeight={tabsHeigh.logsHeight}
|
||||
style="min-height: {minTabHeight}px"
|
||||
>
|
||||
<LogViewer
|
||||
jobId={job.id}
|
||||
duration={job?.['duration_ms']}
|
||||
mem={job?.['mem_peak']}
|
||||
isLoading={job?.['running'] == false}
|
||||
content={job?.logs}
|
||||
tag={job?.tag}
|
||||
/>
|
||||
</div>
|
||||
{:else if viewTab == 'assets'}
|
||||
<div
|
||||
class="w-full h-full"
|
||||
bind:clientHeight={tabsHeigh.assetsHeight}
|
||||
style="min-height: {minTabHeight}px"
|
||||
>
|
||||
<JobAssetsViewer {job} />
|
||||
</div>
|
||||
{:else if viewTab == 'code'}
|
||||
<div
|
||||
class="text-xs"
|
||||
bind:clientHeight={tabsHeigh.codeHeight}
|
||||
style="min-height: {minTabHeight}px"
|
||||
>
|
||||
{#if job && 'raw_code' in job && job.raw_code}
|
||||
<div class="text-xs">
|
||||
<HighlightCode lines language={job.language} code={job.raw_code} />
|
||||
</div>
|
||||
{:else if job}
|
||||
<span class="text-sm">No code available</span>
|
||||
{:else}
|
||||
<Skeleton layout={[[5]]} />
|
||||
{/if}
|
||||
</div>
|
||||
{:else if job !== undefined && (job.result_stream || (job.type == 'CompletedJob' && job.result !== undefined))}
|
||||
<div
|
||||
class="w-full"
|
||||
bind:clientHeight={tabsHeigh.resultHeight}
|
||||
style="min-height: {minTabHeight}px"
|
||||
>
|
||||
<DisplayResult
|
||||
workspaceId={job?.workspace_id}
|
||||
jobId={job?.id}
|
||||
{result}
|
||||
disableExpand
|
||||
language={job?.language}
|
||||
/>
|
||||
</div>
|
||||
{:else if job}
|
||||
No output is available yet
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else if job && `running` in job ? job.running : false}
|
||||
{#if job?.job_kind == 'flow' || isFlowPreview(job?.job_kind)}
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<FlowProgressBar {job} class="py-4" />
|
||||
<FlowStatusViewer jobId={job.id} workspaceId={job.workspace_id} />
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-sm font-semibold text-tertiary mb-1"> Job is still running </div>
|
||||
<LogViewer
|
||||
jobId={job?.id}
|
||||
duration={job?.['duration_ms']}
|
||||
mem={job?.['mem_peak']}
|
||||
content={job?.logs}
|
||||
isLoading={job?.['running'] == false}
|
||||
tag={job?.tag}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{:else if jobIsLoading}
|
||||
<div class="mx-auto my-auto">
|
||||
<LoaderCircle size={20} class="animate-spin" />
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<FlowAssetsHandler
|
||||
modules={job?.raw_flow?.modules ?? []}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
<script lang="ts">
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import PreprocessedArgsDisplay from '$lib/components/runs/PreprocessedArgsDisplay.svelte'
|
||||
import { truncateHash } from '$lib/utils'
|
||||
import { base } from '$app/paths'
|
||||
import { truncateRev } from '$lib/utils'
|
||||
import WorkerHostname from '$lib/components/WorkerHostname.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import Badge from '$lib/components/common/badge/Badge.svelte'
|
||||
import type { Job } from '$lib/gen'
|
||||
|
||||
interface Props {
|
||||
job: Job
|
||||
displayPersistentScriptDefinition?: boolean
|
||||
openPersistentScriptDrawer?: () => void
|
||||
concurrencyKey?: string
|
||||
showScriptHash?: boolean
|
||||
verySmall?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
job,
|
||||
displayPersistentScriptDefinition,
|
||||
openPersistentScriptDrawer,
|
||||
concurrencyKey,
|
||||
showScriptHash = true,
|
||||
verySmall = false
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
{#if job.script_hash && showScriptHash && job.job_kind !== 'aiagent'}
|
||||
{#if job.job_kind == 'script'}
|
||||
<a href="{base}/scripts/get/{job.script_hash}?workspace={$workspaceStore}"
|
||||
><Badge color="gray" {verySmall}>{truncateHash(job.script_hash)}</Badge></a
|
||||
>
|
||||
{:else}
|
||||
<div>
|
||||
<Badge color="gray" {verySmall}>{truncateHash(job.script_hash)}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if job && 'job_kind' in job}
|
||||
<div>
|
||||
<Badge color="blue" {verySmall}>{job.job_kind}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if job && job.flow_status && job.job_kind === 'script'}
|
||||
<PreprocessedArgsDisplay preprocessed={job.preprocessed} />
|
||||
{/if}
|
||||
{#if displayPersistentScriptDefinition}
|
||||
<button onclick={() => openPersistentScriptDrawer?.()}>
|
||||
<Badge color="red">persistent</Badge>
|
||||
</button>
|
||||
{/if}
|
||||
{#if job && 'priority' in job}
|
||||
<div>
|
||||
<Badge color="blue" {verySmall}>priority: {job.priority}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if job.tag}
|
||||
<!-- for related places search: ADD_NEW_LANG -->
|
||||
<div>
|
||||
<Badge color="indigo" {verySmall}>Tag: {job.tag}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !job.visible_to_owner}
|
||||
<div>
|
||||
<Badge color="red" {verySmall}>
|
||||
only visible to you
|
||||
<Tooltip>
|
||||
{#snippet text()}
|
||||
The option to hide this run from the owner of this script or flow was activated
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0}
|
||||
{#each job?.['labels'] as label}
|
||||
<div>
|
||||
<Badge {verySmall}>Label: {label}</Badge>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if concurrencyKey}
|
||||
<div>
|
||||
<Tooltip notClickable>
|
||||
{#snippet text()}
|
||||
This job has concurrency limits enabled with the key
|
||||
<a
|
||||
href={`${base}/runs/?job_kinds=all&graph=ConcurrencyChart&concurrency_key=${concurrencyKey}`}
|
||||
>
|
||||
{concurrencyKey}
|
||||
</a>
|
||||
{/snippet}
|
||||
<a
|
||||
href={`${base}/runs/?job_kinds=all&graph=ConcurrencyChart&concurrency_key=${concurrencyKey}`}
|
||||
>
|
||||
<Badge {verySmall}>Concurrency: {truncateRev(concurrencyKey, 20)}</Badge></a
|
||||
>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
{#if job?.worker}
|
||||
<div>
|
||||
<Tooltip notClickable>
|
||||
{#snippet text()}
|
||||
worker:
|
||||
<a href={`${base}/runs/?job_kinds=all&worker=${job?.worker}`}>
|
||||
{job?.worker}
|
||||
</a><br />
|
||||
<WorkerHostname worker={job?.worker!} minTs={job?.['created_at']} />
|
||||
{/snippet}
|
||||
<a href={`${base}/runs/?job_kinds=all&worker=${job?.worker}`}>
|
||||
<Badge {verySmall}>Worker: {truncateRev(job?.worker, 20)}</Badge></a
|
||||
>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,112 @@
|
||||
<script lang="ts">
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { ListFilterPlus } from 'lucide-svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
import DropdownV2 from '$lib/components/DropdownV2.svelte'
|
||||
import type { Job } from '$lib/gen'
|
||||
import type { Item } from '$lib/utils'
|
||||
|
||||
interface Props {
|
||||
job: Job
|
||||
activeLabel: string | null
|
||||
onFilterByLabel: (label: string) => void
|
||||
labelWidth: number
|
||||
}
|
||||
|
||||
let { job, activeLabel, onFilterByLabel, labelWidth }: Props = $props()
|
||||
|
||||
const GAP = 4
|
||||
const LABEL_MAX_WIDTH = 84
|
||||
const MORE_LABEL_WIDTH = 30
|
||||
// Min width for labels columns is 120px
|
||||
|
||||
const labels = $derived(job && Array.isArray(job?.['labels']) ? (job['labels'] as string[]) : [])
|
||||
|
||||
const labelSplit = $derived.by(() => {
|
||||
if (!labels || labels.length === 0 || labelWidth <= 0) {
|
||||
return { visibleLabels: [], hiddenLabels: [] }
|
||||
}
|
||||
|
||||
if (labels.length === 1) {
|
||||
return { visibleLabels: labels, hiddenLabels: [] }
|
||||
}
|
||||
|
||||
let currentWidth = 0
|
||||
const visible: string[] = []
|
||||
const hidden: string[] = []
|
||||
const margin = 20
|
||||
|
||||
for (let i = 0; i < labels.length; i++) {
|
||||
const label = labels[i]
|
||||
|
||||
// Check if we need to reserve space for overflow badge
|
||||
const needsOverflowBadge = i < labels.length - 1
|
||||
const remainingWidth = labelWidth - currentWidth
|
||||
const requiredWidth =
|
||||
LABEL_MAX_WIDTH + (needsOverflowBadge ? MORE_LABEL_WIDTH + GAP : 0) + margin
|
||||
|
||||
if (remainingWidth >= requiredWidth || visible.length === 0) {
|
||||
visible.push(label)
|
||||
currentWidth += LABEL_MAX_WIDTH + GAP
|
||||
} else {
|
||||
hidden.push(...labels.slice(i))
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return { visibleLabels: visible, hiddenLabels: hidden }
|
||||
})
|
||||
|
||||
const visibleLabels = $derived(labelSplit.visibleLabels || [])
|
||||
const hiddenLabels = $derived(labelSplit.hiddenLabels || [])
|
||||
|
||||
const dropdownItems = $derived(
|
||||
hiddenLabels.map(
|
||||
(label): Item => ({
|
||||
displayName: label,
|
||||
action: () => onFilterByLabel(label),
|
||||
icon: ListFilterPlus
|
||||
})
|
||||
)
|
||||
)
|
||||
</script>
|
||||
|
||||
{#if labels && labels.length > 0}
|
||||
<div class="flex flex-row items-center" style="gap: {GAP}px">
|
||||
{#each visibleLabels as label}
|
||||
<Tooltip openDelay={500} placement="bottom">
|
||||
<button
|
||||
class={twMerge(
|
||||
activeLabel == label ? 'bg-blue-50 dark:bg-blue-900/50' : '',
|
||||
'flex flex-row items-center px-2 group py-1 rounded-md bg-surface-secondary hover:bg-surface'
|
||||
)}
|
||||
style="gap: {GAP}px; width: {LABEL_MAX_WIDTH}px"
|
||||
onclick={() => {
|
||||
onFilterByLabel(label)
|
||||
}}
|
||||
>
|
||||
<span class="truncate text-2xs font-normal">{label}</span>
|
||||
<ListFilterPlus size={12} class="shrink-0 text-gray-300 group-hover:text-primary" />
|
||||
</button>
|
||||
{#snippet text()}
|
||||
{`Filter by label: ${label}`}
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
{/each}
|
||||
|
||||
{#if hiddenLabels.length > 0}
|
||||
<DropdownV2 placement="bottom-start" items={dropdownItems} customWidth={224}>
|
||||
{#snippet buttonReplacement()}
|
||||
<button
|
||||
class="flex flex-row items-center justify-center px-2 py-1 text-2xs font-semibold hover:bg-surface bg-surface-secondary text-secondary rounded-md"
|
||||
style="gap: {GAP}px; width: {MORE_LABEL_WIDTH}px"
|
||||
>
|
||||
+{hiddenLabels.length}
|
||||
</button>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<span class="text-2xs text-secondary">-</span>
|
||||
{/if}
|
||||
@@ -0,0 +1,30 @@
|
||||
<script lang="ts">
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
|
||||
interface Props {
|
||||
label: string
|
||||
children: import('svelte').Snippet
|
||||
tooltip?: import('svelte').Snippet
|
||||
for?: string
|
||||
noLabel?: boolean
|
||||
}
|
||||
|
||||
let { label, children, tooltip, for: forAttr = '', noLabel = false }: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-start w-fit">
|
||||
<div class="text-xs truncate">
|
||||
{#if forAttr}
|
||||
<label for={forAttr} class:text-transparent={noLabel}>{label}</label>
|
||||
{:else}
|
||||
<span class:text-transparent={noLabel}>{label}</span>
|
||||
{/if}
|
||||
{#if tooltip}
|
||||
<Tooltip small>{@render tooltip()}</Tooltip>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-1 items-center justify-start w-full relative h-[34px]">
|
||||
{@render children()}
|
||||
</div>
|
||||
</div>
|
||||
@@ -4,24 +4,28 @@
|
||||
import type { Job } from '$lib/gen'
|
||||
import {
|
||||
displayDate,
|
||||
msToReadableTime,
|
||||
truncateHash,
|
||||
truncateRev,
|
||||
isFlowPreview,
|
||||
isScriptPreview,
|
||||
isJobSelectable
|
||||
isJobSelectable,
|
||||
msToReadableTime,
|
||||
isFlowPreview
|
||||
} from '$lib/utils'
|
||||
import { Badge, Button } from '../common'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
|
||||
import {
|
||||
Bot,
|
||||
Calendar,
|
||||
Check,
|
||||
Clock,
|
||||
Code,
|
||||
ExternalLink,
|
||||
FastForward,
|
||||
Folder,
|
||||
Hourglass,
|
||||
ListFilter,
|
||||
ListFilterPlus,
|
||||
Package,
|
||||
Play,
|
||||
ShieldQuestion,
|
||||
X
|
||||
@@ -34,45 +38,104 @@
|
||||
|
||||
import WaitTimeWarning from '../common/waitTimeWarning/WaitTimeWarning.svelte'
|
||||
import type { RunsSelectionMode } from './RunsBatchActionsDropdown.svelte'
|
||||
import DropdownV2 from '../DropdownV2.svelte'
|
||||
import { Tooltip } from '../meltComponents'
|
||||
import { GitIcon } from '../icons'
|
||||
import RunLabels from './RunLabels.svelte'
|
||||
import './runs-grid.css'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
export let job: Job
|
||||
export let selected: boolean = false
|
||||
export let containerWidth: number = 0
|
||||
export let containsLabel: boolean = false
|
||||
export let activeLabel: string | null
|
||||
export let selectionMode: RunsSelectionMode | false = false
|
||||
interface Props {
|
||||
job: Job
|
||||
selected?: boolean
|
||||
containerWidth?: number
|
||||
containsLabel?: boolean
|
||||
showTag?: boolean
|
||||
activeLabel: string | null
|
||||
selectionMode?: RunsSelectionMode | false
|
||||
}
|
||||
|
||||
let scheduleEditor: ScheduleEditor
|
||||
let {
|
||||
job,
|
||||
selected = false,
|
||||
containerWidth = 0,
|
||||
containsLabel = false,
|
||||
showTag = true,
|
||||
activeLabel,
|
||||
selectionMode = false
|
||||
}: Props = $props()
|
||||
|
||||
$: isExternal = job && job.id === '-'
|
||||
let scheduleEditor: ScheduleEditor | undefined = $state(undefined)
|
||||
|
||||
let isExternal = $derived(job && job.id === '-')
|
||||
|
||||
function getJobKindIcon(jobKind: Job['job_kind']) {
|
||||
if (jobKind === 'flow' || isFlowPreview(jobKind)) {
|
||||
return BarsStaggered
|
||||
} else if (jobKind === 'deploymentcallback') {
|
||||
return GitIcon
|
||||
} else if (
|
||||
jobKind === 'dependencies' ||
|
||||
jobKind === 'appdependencies' ||
|
||||
jobKind === 'flowdependencies'
|
||||
) {
|
||||
return Package
|
||||
} else if (
|
||||
jobKind === 'script' ||
|
||||
isScriptPreview(jobKind) ||
|
||||
jobKind === 'script_hub' ||
|
||||
jobKind === 'singlescriptflow'
|
||||
) {
|
||||
return Code
|
||||
} else if (jobKind === 'aiagent') {
|
||||
return Bot
|
||||
}
|
||||
return Code
|
||||
}
|
||||
|
||||
let labelWidth = $state(0)
|
||||
|
||||
let isJobRecent = $state(true)
|
||||
</script>
|
||||
|
||||
<Portal name="run-row">
|
||||
<ScheduleEditor onUpdate={() => goto('/schedules')} bind:this={scheduleEditor} />
|
||||
</Portal>
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'hover:bg-surface-hover cursor-pointer',
|
||||
selected ? 'bg-blue-50 dark:bg-blue-900/50' : '',
|
||||
'flex flex-row items-center h-full'
|
||||
'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}
|
||||
style="width: {containerWidth}px"
|
||||
on:click={() => {
|
||||
onclick={() => {
|
||||
if (!selectionMode || isJobSelectable(selectionMode)(job)) {
|
||||
dispatch('select')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="w-1/12 flex justify-center">
|
||||
{#if selectionMode && isJobSelectable(selectionMode)(job)}
|
||||
<div class="px-2">
|
||||
<input type="checkbox" checked={selected} />
|
||||
<!-- 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}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Status -->
|
||||
<div class="flex items-center justify-start pl-2">
|
||||
{#if isExternal}
|
||||
<Badge color="gray" baseClass="!px-1.5">
|
||||
<ShieldQuestion size={14} />
|
||||
@@ -114,16 +177,13 @@
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-4/12 flex justify-start">
|
||||
<div class="flex flex-row items-center gap-1 text-gray-500 dark:text-gray-300 text-2xs">
|
||||
<!-- Job time -->
|
||||
<div class="overflow-hidden min-w-0">
|
||||
<div class="flex flex-row items-center gap-1 text-secondary text-2xs">
|
||||
{#if job}
|
||||
{#if 'started_at' in job && job.started_at}
|
||||
Started <TimeAgo agoOnlyIfRecent date={job.started_at ?? ''} />
|
||||
{#if job && 'duration_ms' in job && job.duration_ms != undefined}
|
||||
(Ran in {msToReadableTime(
|
||||
job.duration_ms
|
||||
)}{#if job.job_kind == 'flow' || isFlowPreview(job.job_kind)} total{/if})
|
||||
{/if}
|
||||
{isJobRecent ? 'Started' : ''}
|
||||
<TimeAgo bind:isRecent={isJobRecent} agoOnlyIfRecent date={job.started_at ?? ''} />
|
||||
{#if job && (job.self_wait_time_ms || job.aggregate_wait_time_ms)}
|
||||
<WaitTimeWarning
|
||||
self_wait_time_ms={job.self_wait_time_ms}
|
||||
@@ -132,7 +192,7 @@
|
||||
/>
|
||||
{/if}
|
||||
{:else if `scheduled_for` in job && job.scheduled_for && forLater(job.scheduled_for)}
|
||||
Scheduled for {displayDate(job.scheduled_for)}
|
||||
{displayDate(job.scheduled_for)}<Clock size={12} />
|
||||
{:else if job.canceled}
|
||||
{#if job.type == 'CompletedJob'}
|
||||
Cancelled <TimeAgo agoOnlyIfRecent date={job.created_at || ''} />
|
||||
@@ -151,154 +211,207 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="w-4/12 flex justify-start flex-col">
|
||||
<div class="flex flex-row text-sm">
|
||||
{#if job === undefined}
|
||||
No job found
|
||||
{:else}
|
||||
<div class="flex flex-row gap-1 min-w-0">
|
||||
<div class="whitespace-nowrap text-xs font-semibold truncate">
|
||||
{#if job.script_path}
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
{#if isExternal}
|
||||
<span class="w-30 justify-center">-</span>
|
||||
{:else}
|
||||
<a
|
||||
href="{base}/run/{job.id}?workspace={job.workspace_id}"
|
||||
class="truncate w-30 dark:text-blue-400"
|
||||
>
|
||||
{job.script_path}
|
||||
</a>
|
||||
<Button
|
||||
title="Filter by path"
|
||||
size="xs2"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
dispatch('filterByPath', job.script_path)
|
||||
}}
|
||||
>
|
||||
<ListFilter size={10} />
|
||||
</Button>
|
||||
{/if}
|
||||
{#if job.script_path?.startsWith('f/')}
|
||||
<Button
|
||||
title="Filter by folder"
|
||||
size="xs2"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
// split script_path by / and get the second element
|
||||
const folder = job.script_path?.split('/')[1]
|
||||
|
||||
dispatch('filterByFolder', folder)
|
||||
}}
|
||||
>
|
||||
<Folder size={10} />
|
||||
</Button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if 'job_kind' in job && isScriptPreview(job.job_kind)}
|
||||
<a href="{base}/run/{job.id}?workspace={job.workspace_id}">Preview without path </a>
|
||||
{:else if 'job_kind' in job && job.job_kind == 'dependencies'}
|
||||
<a href="{base}/run/{job.id}?workspace={job.workspace_id}">
|
||||
lock deps of {truncateHash(job.script_hash ?? '')}
|
||||
</a>
|
||||
{:else if 'job_kind' in job && job.job_kind == 'identity'}
|
||||
<a href="{base}/run/{job.id}?workspace={job.workspace_id}">no op</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if job && job.parent_job}
|
||||
{#if job.is_flow_step}
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
<BarsStaggered class="text-secondary" size={14} />
|
||||
<span class="mx-1 text-xs">
|
||||
Step of flow <a href={`${base}/run/${job.parent_job}?workspace=${job.workspace_id}`}>
|
||||
{truncateRev(job.parent_job, 6)}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
<span class="text-2xs text-tertiary truncate">
|
||||
parent <a href={`${base}/run/${job.parent_job}?workspace=${job.workspace_id}`}>
|
||||
{truncateRev(job.parent_job, 10)}
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Job duration-->
|
||||
<div class="text-2xs font-normal text-secondary pr-2">
|
||||
{#if job && 'duration_ms' in job && job.duration_ms != undefined}
|
||||
{msToReadableTime(job.duration_ms, 2)}
|
||||
{:else}
|
||||
-
|
||||
{/if}
|
||||
</div>
|
||||
{#if containsLabel}
|
||||
<div class="w-3/12 flex justify-start px-0.5">
|
||||
{#if job && job?.['labels']}
|
||||
<div class="flex flex-row items-center gap-1 overflow-x-auto">
|
||||
{#if Array.isArray(job?.['labels'])}
|
||||
{#each job?.['labels'] as label}
|
||||
<Button
|
||||
variant="border"
|
||||
size="xs3"
|
||||
btnClasses={twMerge(
|
||||
activeLabel == label ? 'bg-blue-50 dark:bg-blue-900/50' : '',
|
||||
'!text-2xs !font-normal truncate max-w-28'
|
||||
)}
|
||||
color="light"
|
||||
on:click={() => {
|
||||
dispatch('filterByLabel', label)
|
||||
}}
|
||||
endIcon={{ icon: ListFilter }}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
{/each}
|
||||
|
||||
<!-- Job path-->
|
||||
<div class="flex justify-start flex-col pr-4">
|
||||
{#if job === undefined}
|
||||
No job found
|
||||
{:else}
|
||||
{@const JobKindIcon = getJobKindIcon(job.job_kind)}
|
||||
<div class="flex flex-row gap-3 min-w-0 items-center h-full">
|
||||
<Tooltip class="h-full">
|
||||
<div class="relative">
|
||||
{#if job && job.parent_job}
|
||||
<span class="absolute -top-1 -right-1 text-xs text-blue-500">*</span>
|
||||
{/if}
|
||||
<JobKindIcon size={14} />
|
||||
</div>
|
||||
{#snippet text()}
|
||||
<span>
|
||||
{#if job && job.job_kind}
|
||||
{job.job_kind}
|
||||
{/if}
|
||||
{#if job && job.is_flow_step && job.parent_job}
|
||||
<br /> Step of flow
|
||||
<a href={`${base}/run/${job.parent_job}?workspace=${job.workspace_id}`}>
|
||||
{truncateRev(job.parent_job, 10)}
|
||||
</a>
|
||||
{:else if job && job.parent_job}
|
||||
<br /> Parent
|
||||
<a href={`${base}/run/${job.parent_job}?workspace=${job.workspace_id}`}>
|
||||
{truncateRev(job.parent_job, 10)}
|
||||
</a>
|
||||
{/if}
|
||||
</span>
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
|
||||
<div class="whitespace-nowrap text-xs text-secondary truncate">
|
||||
{#if job.script_path}
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
{#if isExternal}
|
||||
<span class="w-30 justify-center">-</span>
|
||||
{:else}
|
||||
<span class="truncate w-30" title={job.script_path}>
|
||||
{job.script_path}
|
||||
</span>
|
||||
{/if}
|
||||
{#if !isExternal || job.script_path?.startsWith('f/')}
|
||||
{@const isFolder = job.script_path?.startsWith('f/')}
|
||||
<DropdownV2
|
||||
items={() => {
|
||||
const items = isExternal
|
||||
? []
|
||||
: [
|
||||
{
|
||||
displayName: `Filter by path: ${job.script_path}`,
|
||||
action: () => dispatch('filterByPath', job.script_path),
|
||||
disabled: isExternal
|
||||
}
|
||||
]
|
||||
if (isFolder) {
|
||||
const folder = job.script_path?.split('/')[1]
|
||||
return [
|
||||
{
|
||||
displayName: `Filter by folder: ${folder}`,
|
||||
action: () => dispatch('filterByFolder', folder)
|
||||
},
|
||||
...items
|
||||
]
|
||||
}
|
||||
return items
|
||||
}}
|
||||
class="w-fit"
|
||||
>
|
||||
{#snippet buttonReplacement()}
|
||||
<div
|
||||
class="p-1 hover:bg-surface cursor-pointer rounded-md text-gray-300 hover:text-primary"
|
||||
>
|
||||
<ListFilterPlus size={14} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if 'job_kind' in job && isScriptPreview(job.job_kind)}
|
||||
Preview without path
|
||||
{:else if 'job_kind' in job && job.job_kind == 'dependencies'}
|
||||
lock deps of {truncateHash(job.script_hash ?? '')}
|
||||
{:else if 'job_kind' in job && job.job_kind == 'identity'}
|
||||
no op
|
||||
{:else if 'job_kind' in job && isFlowPreview(job.job_kind)}
|
||||
Preview without path
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<!-- Labels-->
|
||||
{#if containsLabel}
|
||||
<div class="flex justify-start overflow-hidden" bind:clientWidth={labelWidth}>
|
||||
<RunLabels
|
||||
{job}
|
||||
{activeLabel}
|
||||
onFilterByLabel={(label) => dispatch('filterByLabel', label)}
|
||||
{labelWidth}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="w-3/12 flex justify-start">
|
||||
<!-- Author and schedule-->
|
||||
<div class="flex justify-start pr-4 text-secondary">
|
||||
{#if job && job.schedule_path}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
<Calendar size={14} />
|
||||
<div class="flex flex-row items-center gap-1 w-full -ml-2">
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
btnClasses="font-normal"
|
||||
btnClasses="font-normal bg-transparent hover:bg-surface hover:text-primary"
|
||||
on:click={() => scheduleEditor?.openEdit(job.schedule_path ?? '', job.job_kind == 'flow')}
|
||||
>
|
||||
<div class="truncate text-ellipsis text-left" title={job.schedule_path}>
|
||||
{truncateRev(job.schedule_path, 20)}
|
||||
</div>
|
||||
<Calendar size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
dispatch('filterBySchedule', job.schedule_path)
|
||||
}}
|
||||
<div class="text-xs truncate text-ellipsis text-lef" dir="rtl" title={job.schedule_path}>
|
||||
{job.schedule_path}
|
||||
</div>
|
||||
<DropdownV2
|
||||
items={[
|
||||
{
|
||||
displayName: `Filter by schedule: ${truncateRev(job.schedule_path, 20)}`,
|
||||
action: () => dispatch('filterBySchedule', job.schedule_path)
|
||||
}
|
||||
]}
|
||||
class="w-fit"
|
||||
>
|
||||
<ListFilter size={10} />
|
||||
</Button>
|
||||
{#snippet buttonReplacement()}
|
||||
<div
|
||||
class="p-1 hover:bg-surface cursor-pointer rounded-md text-gray-300 hover:text-primary"
|
||||
>
|
||||
<ListFilterPlus size={14} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="flex flex-row gap-1 items-center">
|
||||
<div class="text-xs truncate text-ellipsis text-left" title={job.created_by}>
|
||||
{truncateRev(job.created_by ?? '', 20)}
|
||||
<div class="flex flex-row gap-1 items-center w-full">
|
||||
<div class="text-xs truncate text-ellipsis text-left" dir="rtl" title={job.created_by}>
|
||||
{job.created_by ?? ''}
|
||||
</div>
|
||||
{#if !isExternal}
|
||||
<Button
|
||||
size="xs2"
|
||||
color="light"
|
||||
on:click={() => {
|
||||
dispatch('filterByUser', job.created_by ?? '')
|
||||
}}
|
||||
<DropdownV2
|
||||
items={[
|
||||
{
|
||||
displayName: `Filter by triggered by: ${job.created_by}`,
|
||||
action: () => dispatch('filterByUser', job.created_by ?? '')
|
||||
}
|
||||
]}
|
||||
customWidth={256}
|
||||
class="w-fit"
|
||||
>
|
||||
<ListFilter size={10} />
|
||||
</Button>
|
||||
{#snippet buttonReplacement()}
|
||||
<div
|
||||
class="p-1 hover:bg-surface cursor-pointer rounded-md text-gray-300 hover:text-primary"
|
||||
>
|
||||
<ListFilterPlus size={14} />
|
||||
</div>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Job tag-->
|
||||
{#if showTag}
|
||||
<div class="flex justify-start gap-1">
|
||||
{#if job.tag}
|
||||
<span class="text-xs text-secondary truncate" title={job.tag}>{job.tag}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Job link-->
|
||||
{#if !isExternal}
|
||||
<div class="flex justify-end pr-2">
|
||||
<a
|
||||
target="_blank"
|
||||
href="{base}/run/{job.id}?workspace={job.workspace_id}"
|
||||
class={twMerge(
|
||||
'text-right float-right px-2',
|
||||
selected
|
||||
? 'text-blue-500 hover:text-primary'
|
||||
: 'text-gray-300 dark:text-gray-500 hover:text-primary dark:hover:text-primary'
|
||||
)}
|
||||
title="See run detail in a new tab"
|
||||
>
|
||||
<ExternalLink size={14} />
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,36 @@
|
||||
<script lang="ts" context="module">
|
||||
<script lang="ts" module>
|
||||
export type RunsSelectionMode = 'cancel' | 're-run'
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { userStore, superadmin } from '$lib/stores'
|
||||
import { X, Check, ChevronDown, Loader2 } from 'lucide-svelte'
|
||||
import { X, Check, ChevronDown, Loader2, SquareMousePointer } from 'lucide-svelte'
|
||||
import { Button } from '../common'
|
||||
import DropdownV2 from '../DropdownV2.svelte'
|
||||
|
||||
export let isLoading = false
|
||||
export let selectionCount: number
|
||||
export let selectionMode: RunsSelectionMode | false
|
||||
export let onSetSelectionMode: (mode: RunsSelectionMode | false) => void
|
||||
export let onCancelSelectedJobs: () => void
|
||||
export let onCancelFilteredJobs: () => void
|
||||
export let onReRunSelectedJobs: () => void
|
||||
export let onReRunFilteredJobs: () => void
|
||||
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'}`
|
||||
@@ -27,7 +42,7 @@
|
||||
<Loader2 class="animate-spin" size={20} />
|
||||
</Button>
|
||||
{:else if selectionMode}
|
||||
<div class="mt-1 p-2 h-8 flex flex-row items-center gap-1">
|
||||
<div class="h-8 flex flex-row items-center gap-1">
|
||||
<Button
|
||||
startIcon={{ icon: X }}
|
||||
size="xs"
|
||||
@@ -62,7 +77,7 @@
|
||||
</div>
|
||||
{:else}
|
||||
<DropdownV2
|
||||
class="w-fit mx-auto"
|
||||
class="w-fit"
|
||||
items={[
|
||||
{
|
||||
displayName: 'Select jobs to cancel',
|
||||
@@ -80,13 +95,16 @@
|
||||
: [])
|
||||
]}
|
||||
>
|
||||
<svelte:fragment slot="buttonReplacement">
|
||||
{#snippet buttonReplacement()}
|
||||
<div
|
||||
class="mt-1 p-2 h-8 flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md"
|
||||
class="px-2 h-[30px] border flex flex-row items-center hover:bg-surface-hover cursor-pointer rounded-md gap-2"
|
||||
>
|
||||
<span class="text-xs min-w-[5rem]">Batch actions</span>
|
||||
<ChevronDown class="w-5 h-5" />
|
||||
<SquareMousePointer size={16} />
|
||||
{#if !small}
|
||||
<span class="text-xs min-w-[5rem]">Batch actions</span>
|
||||
{/if}
|
||||
<ChevronDown size={16} />
|
||||
</div>
|
||||
</svelte:fragment>
|
||||
{/snippet}
|
||||
</DropdownV2>
|
||||
{/if}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import ToggleButton from '../common/toggleButton-v2/ToggleButton.svelte'
|
||||
import ToggleButtonGroup from '../common/toggleButton-v2/ToggleButtonGroup.svelte'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
import { AlertCircle, CheckCircle2, Filter, Hourglass, PlayCircle, X } from 'lucide-svelte'
|
||||
import { CircleAlert, CircleCheck, Hourglass, ListFilterPlus, CirclePlay, X } from 'lucide-svelte'
|
||||
import JsonEditor from '../JsonEditor.svelte'
|
||||
import Toggle from '../Toggle.svelte'
|
||||
import Label from '../Label.svelte'
|
||||
@@ -14,6 +14,9 @@
|
||||
import Popover from '$lib/components/meltComponents/Popover.svelte'
|
||||
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'
|
||||
|
||||
interface Props {
|
||||
// Filters
|
||||
@@ -48,6 +51,8 @@
|
||||
| 'worker'
|
||||
| 'tag'
|
||||
| 'schedulePath'
|
||||
small?: boolean
|
||||
calendarSmall?: boolean
|
||||
}
|
||||
|
||||
let {
|
||||
@@ -72,7 +77,9 @@
|
||||
usernames = [],
|
||||
folders = [],
|
||||
allWorkspaces = $bindable(false),
|
||||
filterBy = $bindable('path')
|
||||
filterBy = $bindable('path'),
|
||||
small = false,
|
||||
calendarSmall = false
|
||||
}: Props = $props()
|
||||
|
||||
let copyArgFilter = $state(argFilter)
|
||||
@@ -115,106 +122,153 @@
|
||||
;(path || user || folder || label || worker || concurrencyKey || tag || schedulePath) &&
|
||||
untrack(() => autosetFilter())
|
||||
})
|
||||
|
||||
function resetFilter() {
|
||||
path = null
|
||||
user = null
|
||||
folder = null
|
||||
label = null
|
||||
concurrencyKey = null
|
||||
tag = null
|
||||
schedulePath = undefined
|
||||
worker = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="flex gap-4">
|
||||
{#if !mobile}
|
||||
<div class="flex gap-2">
|
||||
{#if $workspaceStore == 'admins'}
|
||||
<div class="relative">
|
||||
<span class="text-xs absolute -top-4">Workspaces</span>
|
||||
<ToggleButtonGroup
|
||||
bind:selected={allWorkspacesValue}
|
||||
on:selected={({ detail }) => (allWorkspaces = detail === 'all')}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value={'admins'} label="Admins" {item} />
|
||||
<ToggleButton value={'all'} label="All" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{/if}
|
||||
{#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 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.'
|
||||
{/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 the workspace settings'
|
||||
{/snippet}
|
||||
</TooltipV2>
|
||||
{/snippet}
|
||||
|
||||
<div class="relative">
|
||||
<span class="text-xs absolute -top-4">Filter by</span>
|
||||
<ToggleButtonGroup
|
||||
bind:selected={filterBy}
|
||||
on:selected={(e) => {
|
||||
if (e.detail != filterBy) {
|
||||
path = null
|
||||
user = null
|
||||
folder = null
|
||||
label = null
|
||||
concurrencyKey = null
|
||||
tag = null
|
||||
schedulePath = undefined
|
||||
{#if !mobile}
|
||||
{#if $workspaceStore == 'admins'}
|
||||
<RunOption label="Workspaces" for="workspaces">
|
||||
<ToggleButtonGroup
|
||||
bind:selected={allWorkspacesValue}
|
||||
on:selected={({ detail }) => (allWorkspaces = detail === 'all')}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value={'admins'} label="Admins" {item} />
|
||||
<ToggleButton value={'all'} label="All" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</RunOption>
|
||||
{/if}
|
||||
<!-- Filter by -->
|
||||
<div class="flex flex-row gap-1">
|
||||
<RunOption label="Filter by" for="filter-by">
|
||||
<ToggleButtonGroup
|
||||
bind:selected={filterBy}
|
||||
on:selected={(e) => {
|
||||
if (e.detail != filterBy) {
|
||||
resetFilter()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="path" label="Path" {item} />
|
||||
<ToggleButton value="user" label="User" {item} />
|
||||
<ToggleButton value="folder" label="Folder" {item} />
|
||||
<ToggleButtonMore
|
||||
togglableItems={[
|
||||
{ label: 'Schedule path', value: 'schedulePath' },
|
||||
{ label: 'Concurrency key', value: 'concurrencyKey' },
|
||||
{ label: 'Label', value: 'label' },
|
||||
{ label: 'Tag', value: 'tag' },
|
||||
{ label: 'Worker', value: 'worker' }
|
||||
]}
|
||||
{item}
|
||||
bind:selected={
|
||||
() => filterBy,
|
||||
(v) => {
|
||||
resetFilter()
|
||||
filterBy = v
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value="path" label="Path" {item} />
|
||||
<ToggleButton value="user" label="User" {item} />
|
||||
<ToggleButton value="folder" label="Folder" {item} />
|
||||
<ToggleButtonMore
|
||||
togglableItems={[
|
||||
{ label: 'Schedule path', value: 'schedulePath' },
|
||||
{ label: 'Concurrency key', value: 'concurrencyKey' },
|
||||
{ label: 'Label', value: 'label' },
|
||||
{ label: 'Tag', value: 'tag' },
|
||||
{ label: 'Worker', value: 'worker' }
|
||||
]}
|
||||
{item}
|
||||
bind:selected={filterBy}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</RunOption>
|
||||
|
||||
{#if filterBy == 'user'}
|
||||
{#key user}
|
||||
<div class="relative">
|
||||
<span class="text-xs absolute -top-4">User</span>
|
||||
<Select
|
||||
items={safeSelectItems(usernames)}
|
||||
bind:value={() => user ?? undefined, (v) => (user = v ?? null)}
|
||||
clearable
|
||||
onClear={() => ((user = null), dispatch('reset'))}
|
||||
inputClass="!h-[32px]"
|
||||
onCreateItem={(item) => (usernames.push(item), (user = item))}
|
||||
createText="Press enter to use this value"
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
{:else if filterBy == 'folder'}
|
||||
{#if filterBy == 'user'}
|
||||
{#key user}
|
||||
<RunOption label="User" for="user">
|
||||
<Select
|
||||
items={safeSelectItems(usernames)}
|
||||
bind:value={() => user ?? undefined, (v) => (user = v ?? null)}
|
||||
clearable
|
||||
onClear={() => ((user = null), dispatch('reset'))}
|
||||
inputClass="!h-[32px] min-w-36"
|
||||
onCreateItem={(item) => (usernames.push(item), (user = item))}
|
||||
createText="Press enter to use this value"
|
||||
id="user"
|
||||
/>
|
||||
</RunOption>
|
||||
{/key}
|
||||
{:else if filterBy == 'folder'}
|
||||
<RunOption label="Folder" for="folder">
|
||||
{#key folder}
|
||||
<div class="relative">
|
||||
<span class="text-xs absolute -top-4">Folder</span>
|
||||
|
||||
<Select
|
||||
items={safeSelectItems(folders)}
|
||||
bind:value={() => folder ?? undefined, (v) => (folder = v ?? null)}
|
||||
clearable
|
||||
onClear={() => ((folder = null), dispatch('reset'))}
|
||||
inputClass="!h-[32px]"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
items={safeSelectItems(folders)}
|
||||
bind:value={() => folder ?? undefined, (v) => (folder = v ?? null)}
|
||||
clearable
|
||||
onClear={() => ((folder = null), dispatch('reset'))}
|
||||
inputClass="!h-[32px] min-w-36"
|
||||
id="folder"
|
||||
/>
|
||||
{/key}
|
||||
{:else if filterBy === 'path'}
|
||||
</RunOption>
|
||||
{:else if filterBy === 'path'}
|
||||
<RunOption label="Path" for="path">
|
||||
{#key path}
|
||||
<div class="relative">
|
||||
<span class="text-xs absolute -top-4">Path</span>
|
||||
<Select
|
||||
items={safeSelectItems(paths)}
|
||||
bind:value={() => path ?? undefined, (v) => (path = v ?? null)}
|
||||
clearable
|
||||
onClear={() => ((path = null), dispatch('reset'))}
|
||||
inputClass="!h-[32px]"
|
||||
onCreateItem={(item) => (paths.push(item), (path = item))}
|
||||
createText="Press enter to use this value"
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
items={safeSelectItems(paths)}
|
||||
bind:value={() => path ?? undefined, (v) => (path = v ?? null)}
|
||||
clearable
|
||||
onClear={() => ((path = null), dispatch('reset'))}
|
||||
inputClass="!h-[32px] min-w-36"
|
||||
onCreateItem={(item) => (paths.push(item), (path = item))}
|
||||
createText="Press enter to use this value"
|
||||
id="path"
|
||||
/>
|
||||
{/key}
|
||||
{:else if filterBy === 'label'}
|
||||
</RunOption>
|
||||
{:else if filterBy === 'label'}
|
||||
<RunOption label="Label" for="label">
|
||||
{#snippet tooltip()}
|
||||
<a
|
||||
href="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs#jobs-labels"
|
||||
target="_blank">Job Labels</a
|
||||
> are string values in the array at the result field 'wm_labels' to easily filter them.
|
||||
{/snippet}
|
||||
{#key label}
|
||||
<div class="relative">
|
||||
{#if label}
|
||||
@@ -229,20 +283,11 @@
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<span class="text-xs absolute -top-4"
|
||||
>Label <Tooltip
|
||||
><a
|
||||
href="https://www.windmill.dev/docs/core_concepts/monitor_past_and_future_runs#jobs-labels"
|
||||
target="_blank">Job Labels</a
|
||||
> are string values in the array at the result field 'wm_labels' to easily filter them.</Tooltip
|
||||
></span
|
||||
>
|
||||
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
class="!h-[32px] py-1 !text-xs !w-64"
|
||||
class="!h-[32px] py-1 !text-xs min-w-36"
|
||||
bind:value={displayedLabel}
|
||||
onkeydown={(e) => {
|
||||
if (labelTimeout) {
|
||||
@@ -254,58 +299,59 @@
|
||||
}, 1000)
|
||||
}}
|
||||
/>
|
||||
<div class="absolute top-10">
|
||||
<div class="absolute -top-4 right-0">
|
||||
<Toggle
|
||||
bind:checked={allowWildcards}
|
||||
size="xs"
|
||||
options={{ right: 'allow wildcards (*)' }}
|
||||
size="2xs"
|
||||
options={{ right: '(*)', title: 'allow wildcards (*)' }}
|
||||
></Toggle>
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
{:else if filterBy === 'concurrencyKey'}
|
||||
</RunOption>
|
||||
{:else if filterBy === 'concurrencyKey'}
|
||||
<RunOption label="Concurrency Key" for="concurrencyKey">
|
||||
{#snippet tooltip()}
|
||||
For concurrency limited jobs, the concurrency key defines a group of jobs that share the
|
||||
same limits.
|
||||
{#if !$enterpriseLicense}
|
||||
Concurrency limits are an EE feature.
|
||||
{/if}
|
||||
{/snippet}
|
||||
{#key concurrencyKey}
|
||||
<div class="relative">
|
||||
{#if concurrencyKey}
|
||||
<button
|
||||
class="absolute top-2 right-2 z-50"
|
||||
onclick={() => {
|
||||
concurrencyKey = null
|
||||
dispatch('reset')
|
||||
}}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
<span class="text-xs absolute -top-4"
|
||||
>Concurrency Key <Tooltip>
|
||||
For concurrency limited jobs, the concurrency key defines a group of jobs that share
|
||||
the same limits.
|
||||
{#if !$enterpriseLicense}
|
||||
Concurrency limits are an EE feature.
|
||||
{/if}
|
||||
</Tooltip></span
|
||||
>
|
||||
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
class="!h-[32px] py-1 !text-xs !w-64"
|
||||
bind:value={displayedConcurrencyKey}
|
||||
onkeydown={(e) => {
|
||||
if (concurrencyKeyTimeout) {
|
||||
clearTimeout(concurrencyKeyTimeout)
|
||||
}
|
||||
|
||||
concurrencyKeyTimeout = setTimeout(() => {
|
||||
concurrencyKey = displayedConcurrencyKey
|
||||
}, 1000)
|
||||
{#if concurrencyKey}
|
||||
<button
|
||||
class="absolute top-2 right-2 z-50"
|
||||
onclick={() => {
|
||||
concurrencyKey = null
|
||||
dispatch('reset')
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
class="!h-[32px] py-1 !text-xs min-w-36"
|
||||
bind:value={displayedConcurrencyKey}
|
||||
onkeydown={(e) => {
|
||||
if (concurrencyKeyTimeout) {
|
||||
clearTimeout(concurrencyKeyTimeout)
|
||||
}
|
||||
|
||||
concurrencyKeyTimeout = setTimeout(() => {
|
||||
concurrencyKey = displayedConcurrencyKey
|
||||
}, 1000)
|
||||
}}
|
||||
id="concurrencyKey"
|
||||
/>
|
||||
{/key}
|
||||
{:else if filterBy === 'tag'}
|
||||
</RunOption>
|
||||
{:else if filterBy === 'tag'}
|
||||
<RunOption label="Tag" for="tag">
|
||||
{#key tag}
|
||||
<div class="relative">
|
||||
{#if tag}
|
||||
@@ -319,13 +365,12 @@
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
<span class="text-xs absolute -top-4"> Tag </span>
|
||||
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
class="!h-[32px] py-1 !text-xs !w-64"
|
||||
class="!h-[32px] py-1 !text-xs min-w-36"
|
||||
bind:value={displayedTag}
|
||||
onkeydown={(e) => {
|
||||
if (tagTimeout) {
|
||||
@@ -336,17 +381,20 @@
|
||||
tag = displayedTag
|
||||
}, 1000)
|
||||
}}
|
||||
id="tag"
|
||||
/>
|
||||
<div class="absolute top-10">
|
||||
<div class="absolute -top-4 right-0">
|
||||
<Toggle
|
||||
bind:checked={allowWildcards}
|
||||
size="xs"
|
||||
options={{ right: 'allow wildcards (*)' }}
|
||||
size="2xs"
|
||||
options={{ right: 'wildcards (*)', title: 'allow wildcards (*)' }}
|
||||
></Toggle>
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
{:else if filterBy === 'schedulePath'}
|
||||
</RunOption>
|
||||
{:else if filterBy === 'schedulePath'}
|
||||
<RunOption label="Schedule Path" for="schedulePath">
|
||||
{#key tag}
|
||||
<div class="relative">
|
||||
{#if tag}
|
||||
@@ -360,13 +408,12 @@
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
<span class="text-xs absolute -top-4"> Schedule Path </span>
|
||||
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
class="!h-[32px] py-1 !text-xs !w-64"
|
||||
class="!h-[32px] py-1 !text-xs min-w-36"
|
||||
bind:value={displayedSchedule}
|
||||
onkeydown={(e) => {
|
||||
if (tagTimeout) {
|
||||
@@ -377,10 +424,13 @@
|
||||
schedulePath = displayedSchedule
|
||||
}, 1000)
|
||||
}}
|
||||
id="schedulePath"
|
||||
/>
|
||||
</div>
|
||||
{/key}
|
||||
{:else if filterBy === 'worker'}
|
||||
</RunOption>
|
||||
{:else if filterBy === 'worker'}
|
||||
<RunOption label="Worker" for="worker">
|
||||
{#key worker}
|
||||
<div class="relative">
|
||||
{#if worker}
|
||||
@@ -394,13 +444,12 @@
|
||||
<X size={14} />
|
||||
</button>
|
||||
{/if}
|
||||
<span class="text-xs absolute -top-4"> Worker </span>
|
||||
|
||||
<!-- svelte-ignore a11y_autofocus -->
|
||||
<input
|
||||
autofocus
|
||||
type="text"
|
||||
class="!h-[32px] py-1 !text-xs !w-64"
|
||||
class="!h-[32px] py-1 !text-xs min-w-36"
|
||||
bind:value={displayedWorker}
|
||||
onkeydown={(e) => {
|
||||
if (workerTimeout) {
|
||||
@@ -411,20 +460,70 @@
|
||||
worker = displayedWorker
|
||||
}, 1000)
|
||||
}}
|
||||
id="worker"
|
||||
/>
|
||||
<div class="absolute top-10">
|
||||
<div class="absolute -top-4 right-0">
|
||||
<Toggle
|
||||
bind:checked={allowWildcards}
|
||||
size="xs"
|
||||
options={{ right: 'allow wildcards (*)' }}
|
||||
size="2xs"
|
||||
options={{ right: 'wildcards (*)', title: 'allow wildcards (*)' }}
|
||||
></Toggle>
|
||||
</div>
|
||||
</div>
|
||||
{/key}
|
||||
{/if}
|
||||
</div>
|
||||
<div class="relative">
|
||||
<span class="text-xs absolute -top-4">Kind</span>
|
||||
</RunOption>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Kind -->
|
||||
<RunOption label="Kind" for="kind">
|
||||
{#if small && !calendarSmall}
|
||||
<DropdownSelect
|
||||
btnClasses="min-w-24"
|
||||
items={[
|
||||
{
|
||||
displayName: 'All',
|
||||
action: () => {
|
||||
jobKindsCat = 'all'
|
||||
},
|
||||
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} />
|
||||
@@ -435,13 +534,6 @@
|
||||
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="previews"
|
||||
label="Previews"
|
||||
showTooltipIcon
|
||||
tooltip="Previews are jobs that have been started in the editor as 'Tests'"
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value="dependencies"
|
||||
label="Deps"
|
||||
@@ -449,75 +541,94 @@
|
||||
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}
|
||||
/>
|
||||
<ToggleButton
|
||||
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"
|
||||
<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
|
||||
}
|
||||
}
|
||||
/>
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
<div class="relative">
|
||||
<span class="text-xs absolute -top-4">Status</span>
|
||||
<ToggleButtonGroup
|
||||
selected={success ?? 'all'}
|
||||
on:selected={({ detail }) => {
|
||||
success = detail === 'all' ? undefined : detail
|
||||
dispatch('successChange', success)
|
||||
}}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value={'all'} label="All" {item} />
|
||||
{/if}
|
||||
</RunOption>
|
||||
<!-- Status -->
|
||||
<RunOption label="Status" for="status">
|
||||
<ToggleButtonGroup
|
||||
selected={success ?? 'all'}
|
||||
on:selected={({ detail }) => {
|
||||
success = detail === 'all' ? undefined : detail
|
||||
dispatch('successChange', success)
|
||||
}}
|
||||
id="status"
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value={'all'} label="All" {item} />
|
||||
<ToggleButton
|
||||
value={'running'}
|
||||
tooltip="Running"
|
||||
class="whitespace-nowrap"
|
||||
icon={CirclePlay}
|
||||
iconProps={{ color: success === 'running' ? 'blue' : 'gray' }}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value={'success'}
|
||||
tooltip="Success"
|
||||
class="whitespace-nowrap"
|
||||
icon={CircleCheck}
|
||||
iconProps={{ color: success === 'success' ? 'green' : 'gray' }}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value={'failure'}
|
||||
tooltip="Failure"
|
||||
class="whitespace-nowrap"
|
||||
icon={CircleAlert}
|
||||
iconProps={{ color: success === 'failure' ? 'red' : 'gray' }}
|
||||
{item}
|
||||
/>
|
||||
{#if success == 'waiting'}
|
||||
<ToggleButton
|
||||
value={'running'}
|
||||
tooltip="Running"
|
||||
value={'waiting'}
|
||||
tooltip="Waiting"
|
||||
class="whitespace-nowrap"
|
||||
icon={PlayCircle}
|
||||
iconProps={{ color: success === 'running' ? 'blue' : 'gray' }}
|
||||
icon={Hourglass}
|
||||
iconProps={{ color: 'blue' }}
|
||||
{item}
|
||||
/>
|
||||
{:else if success == 'suspended'}
|
||||
<ToggleButton
|
||||
value={'success'}
|
||||
tooltip="Success"
|
||||
value={'suspended'}
|
||||
tooltip="Suspended"
|
||||
class="whitespace-nowrap"
|
||||
icon={CheckCircle2}
|
||||
iconProps={{ color: success === 'success' ? 'green' : 'gray' }}
|
||||
icon={Hourglass}
|
||||
iconProps={{ color: 'blue' }}
|
||||
{item}
|
||||
/>
|
||||
<ToggleButton
|
||||
value={'failure'}
|
||||
tooltip="Failure"
|
||||
class="whitespace-nowrap"
|
||||
icon={AlertCircle}
|
||||
iconProps={{ color: success === 'failure' ? 'red' : 'gray' }}
|
||||
{item}
|
||||
/>
|
||||
{#if success == 'waiting'}
|
||||
<ToggleButton
|
||||
value={'waiting'}
|
||||
tooltip="Waiting"
|
||||
class="whitespace-nowrap"
|
||||
icon={Hourglass}
|
||||
iconProps={{ color: 'blue' }}
|
||||
{item}
|
||||
/>
|
||||
{:else if success == 'suspended'}
|
||||
<ToggleButton
|
||||
value={'suspended'}
|
||||
tooltip="Suspended"
|
||||
class="whitespace-nowrap"
|
||||
icon={Hourglass}
|
||||
iconProps={{ color: 'blue' }}
|
||||
{item}
|
||||
/>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</RunOption>
|
||||
{/if}
|
||||
|
||||
<RunOption label="_" for="more-filters" noLabel>
|
||||
<Popover
|
||||
floatingConfig={{ strategy: 'absolute', placement: 'bottom-end' }}
|
||||
contentClasses="p-4"
|
||||
@@ -525,15 +636,33 @@
|
||||
usePointerDownOutside
|
||||
>
|
||||
{#snippet trigger()}
|
||||
<Button color="dark" size="xs" nonCaptureEvent={true} startIcon={{ icon: Filter }}>
|
||||
More filters
|
||||
</Button>
|
||||
<Button
|
||||
color="light"
|
||||
variant="border"
|
||||
size="xs"
|
||||
nonCaptureEvent={true}
|
||||
startIcon={{ icon: ListFilterPlus }}
|
||||
iconOnly
|
||||
></Button>
|
||||
{/snippet}
|
||||
|
||||
{#snippet content()}
|
||||
<Section label="Filters">
|
||||
<div class="w-102 flex flex-col gap-4">
|
||||
{#if mobile || true}
|
||||
{#if mobile}
|
||||
{#if $workspaceStore == 'admins'}
|
||||
<Label label="Workspaces">
|
||||
<ToggleButtonGroup
|
||||
bind:selected={allWorkspacesValue}
|
||||
on:selected={({ detail }) => (allWorkspaces = detail === 'all')}
|
||||
>
|
||||
{#snippet children({ item })}
|
||||
<ToggleButton value={'admins'} label="Admins" {item} />
|
||||
<ToggleButton value={'all'} label="All" {item} />
|
||||
{/snippet}
|
||||
</ToggleButtonGroup>
|
||||
</Label>
|
||||
{/if}
|
||||
<Label label="Filter by">
|
||||
<ToggleButtonGroup
|
||||
bind:selected={filterBy}
|
||||
@@ -739,8 +868,8 @@
|
||||
{#if filterBy === 'tag' || filterBy === 'label' || filterBy === 'worker'}
|
||||
<Toggle
|
||||
bind:checked={allowWildcards}
|
||||
size="xs"
|
||||
options={{ right: 'allow wildcards (*)' }}
|
||||
size="2xs"
|
||||
options={{ right: 'wildcards (*)', title: 'allow wildcards (*)' }}
|
||||
></Toggle>
|
||||
{/if}
|
||||
<Label label="Kind">
|
||||
@@ -816,15 +945,17 @@
|
||||
</div>
|
||||
</Label>
|
||||
|
||||
<span class="text-xs leading-6">
|
||||
{`Filter by a json being a subset of the args/result. Try '\{"foo": "bar"\}'`}
|
||||
</span>
|
||||
<Label label="Filter by args">
|
||||
<JsonEditor bind:error={argError} bind:code={copyArgFilter} />
|
||||
</Label>
|
||||
<Label label="Filter by result">
|
||||
<JsonEditor bind:error={resultError} bind:code={copyResultFilter} />
|
||||
</Label>
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-xs leading-6">
|
||||
{`Filter by a json being a subset of the args/result. Try '\{"foo": "bar"\}'`}
|
||||
</span>
|
||||
<Label label="Filter by args">
|
||||
<JsonEditor bind:error={argError} bind:code={copyArgFilter} />
|
||||
</Label>
|
||||
<Label label="Filter by result">
|
||||
<JsonEditor bind:error={resultError} bind:code={copyResultFilter} />
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-row gap-2 justify-between">
|
||||
<Button
|
||||
@@ -853,4 +984,4 @@
|
||||
</Section>
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
</RunOption>
|
||||
|
||||
@@ -1,36 +1,122 @@
|
||||
|
||||
|
||||
<script lang="ts">
|
||||
import type { Tweened } from 'svelte/motion'
|
||||
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { Button } from '../common'
|
||||
import { FilterX, ListFilter } from 'lucide-svelte'
|
||||
import Tooltip from '../Tooltip.svelte'
|
||||
import { Bot, Hourglass, ListFilterPlus, X } from 'lucide-svelte'
|
||||
import RunOption from './RunOption.svelte'
|
||||
import { Popover } from '../meltComponents'
|
||||
|
||||
export let queue_count: Tweened<number> | undefined = undefined
|
||||
export let suspended_count: Tweened<number> | undefined = undefined
|
||||
export let success: string | undefined
|
||||
const dispatch = createEventDispatcher()
|
||||
interface Props {
|
||||
queue_count?: Tweened<number> | undefined
|
||||
suspended_count?: Tweened<number> | undefined
|
||||
success: string | undefined
|
||||
small?: boolean
|
||||
onJobsWaiting?: () => void
|
||||
onJobsSuspended?: () => void
|
||||
}
|
||||
|
||||
let {
|
||||
queue_count = undefined,
|
||||
suspended_count = undefined,
|
||||
success,
|
||||
small = false,
|
||||
onJobsWaiting,
|
||||
onJobsSuspended
|
||||
}: Props = $props()
|
||||
</script>
|
||||
|
||||
<div class="flex gap-1 relative max-w-36 min-w-[50px] items-baseline">
|
||||
<div class="text-xs absolute -top-4 truncate flex items-baseline gap-1">Waiting for workers <Tooltip small>Jobs waiting for a worker being available to be executed</Tooltip></div>
|
||||
<div class="mt-1">{queue_count ? ($queue_count ?? 0).toFixed(0) : '...'}</div>
|
||||
<div class="truncate text-2xs !text-secondary mt-0.5">
|
||||
{#if queue_count && ($queue_count ?? 0) > 0}
|
||||
<Button size="xs2" color="light" on:click={() => dispatch('jobs_waiting')}>{#if success == 'waiting'}<FilterX size={12}></FilterX>{:else}<ListFilter size={12}></ListFilter>{/if}</Button>
|
||||
{/if}
|
||||
<div class="flex flex-row gap-4 items-center">
|
||||
{#if small}
|
||||
<Popover contentClasses="p-4" openOnHover debounceDelay={100}>
|
||||
{#snippet trigger()}
|
||||
<div class="relative">
|
||||
<Bot size={16} />
|
||||
{#if queue_count && ($queue_count ?? 0) > 0}
|
||||
<div
|
||||
class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 bg-yellow-500 rounded-full text-white text-xs h-4 w-4"
|
||||
>
|
||||
{queue_count ? ($queue_count ?? 0).toFixed(0) : '...'}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
</div>
|
||||
{#snippet content()}
|
||||
{@render queuedContent()}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
{@render queuedContent()}
|
||||
{/if}
|
||||
|
||||
{#if small}
|
||||
<Popover contentClasses="p-4" openOnHover debounceDelay={100}>
|
||||
{#snippet trigger()}
|
||||
<div class="relative">
|
||||
<Hourglass size={16} />
|
||||
<div
|
||||
class="absolute top-0 right-0 translate-x-1/2 -translate-y-1/2 bg-surface-secondary-inverse rounded-full text-primary-inverse text-xs h-4 w-4"
|
||||
>
|
||||
{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet content()}
|
||||
{@render suspendedContent()}
|
||||
{/snippet}
|
||||
</Popover>
|
||||
{:else}
|
||||
{@render suspendedContent()}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if suspended_count && ($suspended_count ?? 0) > 0}
|
||||
<div class="flex gap-1 relative max-w-36 min-w-[50px] items-baseline ml-20 mr-8">
|
||||
<div class="text-xs absolute -top-4 truncate flex items-baseline gap-1">Suspended <Tooltip small>Jobs waiting for an event or approval before being resumed</Tooltip></div>
|
||||
<div class="mt-1">{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}</div>
|
||||
{#snippet queuedContent()}
|
||||
<RunOption label="Waiting for workers">
|
||||
{#snippet tooltip()}
|
||||
Jobs waiting for a worker being available to be executed
|
||||
{/snippet}
|
||||
<div
|
||||
class={queue_count && ($queue_count ?? 0) > 0
|
||||
? 'bg-yellow-500 text-white rounded-full w-6 h-6 flex center-center'
|
||||
: ''}>{queue_count ? ($queue_count ?? 0).toFixed(0) : '...'}</div
|
||||
>
|
||||
<div class="truncate text-2xs !text-secondary mt-0.5">
|
||||
<Button size="xs2" color="light" on:click={() => dispatch('jobs_suspended')}>{#if success == 'waiting'}<FilterX size={12}></FilterX>{:else}<ListFilter size={12}></ListFilter>{/if}</Button>
|
||||
<Button size="xs2" color="light" on:click={() => onJobsWaiting?.()}>
|
||||
{#if success == 'waiting'}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
Reset filter
|
||||
<X size={12} />
|
||||
</div>
|
||||
{:else}
|
||||
<ListFilterPlus size={14} />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</RunOption>
|
||||
{/snippet}
|
||||
|
||||
{#snippet suspendedContent()}
|
||||
{#if suspended_count && ($suspended_count ?? 0) > 0}
|
||||
<RunOption label="Suspended">
|
||||
{#snippet tooltip()}
|
||||
Jobs waiting for an event or approval before being resumed
|
||||
{/snippet}
|
||||
<div
|
||||
class={suspended_count && ($suspended_count ?? 0) > 0
|
||||
? 'bg-surface-secondary-inverse text-primary-inverse rounded-full w-6 h-6 flex center-center'
|
||||
: ''}>{suspended_count ? ($suspended_count ?? 0).toFixed(0) : '...'}</div
|
||||
>
|
||||
<div class="truncate text-2xs !text-secondary">
|
||||
<Button size="xs2" color="light" on:click={() => onJobsSuspended?.()}>
|
||||
{#if success == 'suspended'}
|
||||
<div class="flex flex-row items-center gap-1">
|
||||
Reset filter
|
||||
<X size={12} />
|
||||
</div>
|
||||
{:else}
|
||||
<ListFilterPlus size={14} />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</RunOption>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
<script lang="ts">
|
||||
import { createBubbler } from 'svelte/legacy'
|
||||
|
||||
const bubble = createBubbler()
|
||||
import type { Job } from '$lib/gen'
|
||||
import RunRow from './RunRow.svelte'
|
||||
import VirtualList from '@tutorlatin/svelte-tiny-virtual-list'
|
||||
@@ -10,9 +7,8 @@
|
||||
import { AlertTriangle } from 'lucide-svelte'
|
||||
import Popover from '../Popover.svelte'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { isJobSelectable } from '$lib/utils'
|
||||
import type { RunsSelectionMode } from './RunsBatchActionsDropdown.svelte'
|
||||
import './runs-grid.css'
|
||||
|
||||
interface Props {
|
||||
//import InfiniteLoading from 'svelte-infinite-loading'
|
||||
@@ -138,25 +134,6 @@
|
||||
}
|
||||
*/
|
||||
|
||||
let selectableJobCount = $derived.by(() => {
|
||||
if (!selectionMode) return 0
|
||||
return jobs?.filter(isJobSelectable(selectionMode)).length ?? 0
|
||||
})
|
||||
let allSelected = $derived.by(() => {
|
||||
return selectionMode && selectedIds.length === selectableJobCount
|
||||
})
|
||||
|
||||
function selectAll() {
|
||||
if (!selectionMode) return
|
||||
if (allSelected) {
|
||||
allSelected = false
|
||||
selectedIds = []
|
||||
} else {
|
||||
allSelected = true
|
||||
selectedIds = jobs?.filter(isJobSelectable(selectionMode)).map((j) => j.id) ?? []
|
||||
}
|
||||
}
|
||||
|
||||
function jobCountString(jobCount: number | undefined, lastFetchWentToEnd: boolean): string {
|
||||
if (jobCount === undefined) {
|
||||
return ''
|
||||
@@ -213,64 +190,67 @@
|
||||
}
|
||||
return nstickyIndices
|
||||
})
|
||||
|
||||
const showTag = $derived(containerWidth > 700)
|
||||
</script>
|
||||
|
||||
<svelte:window onresize={() => computeHeight()} />
|
||||
|
||||
<div
|
||||
class="divide-y min-w-[640px] h-full"
|
||||
class="divide-y h-full border min-w-[650px]"
|
||||
id="runs-table-wrapper"
|
||||
bind:clientWidth={containerWidth}
|
||||
>
|
||||
<div bind:clientHeight={headerHeight}>
|
||||
{#if selectionMode && selectableJobCount}
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class={twMerge(
|
||||
'hover:bg-surface-hover bg-surface-primary cursor-pointer',
|
||||
allSelected ? 'bg-blue-50 dark:bg-blue-900/50' : '',
|
||||
'flex flex-row items-center sticky w-full p-2 pr-4 top-0 font-semibold border-t text-sm'
|
||||
)}
|
||||
onclick={selectAll}
|
||||
>
|
||||
<div class="px-2">
|
||||
<input onfocus={bubble('focus')} type="checkbox" checked={allSelected} />
|
||||
</div>
|
||||
Select all
|
||||
</div>
|
||||
{/if}
|
||||
<div class="flex flex-row bg-surface-secondary sticky top-0 w-full p-2 pr-4">
|
||||
{#if showExternalJobs && externalJobs.length > 0}
|
||||
<div class="w-1/12 text-2xs">
|
||||
<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-runs-table-with-labels-selection-no-tag={containsLabel &&
|
||||
selectionMode &&
|
||||
!showTag}
|
||||
>
|
||||
{#if selectionMode}
|
||||
<div class="text-xs font-semibold pl-4"></div>
|
||||
{/if}
|
||||
<div class="text-2xs px-2 flex flex-row items-center gap-2">
|
||||
{#if showExternalJobs && externalJobs.length > 0}
|
||||
<div class="flex flex-row">
|
||||
{jobs
|
||||
? jobCountString(jobs.length + externalJobs.length, lastFetchWentToEnd)
|
||||
: ''}<Tooltip>{externalJobs.length} jobs obscured</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{:else if $workspaceStore !== 'admins' && omittedObscuredJobs}
|
||||
<div class="w-1/12 text-2xs flex flex-row">
|
||||
{:else if $workspaceStore !== 'admins' && omittedObscuredJobs}
|
||||
<div class="flex flex-row">
|
||||
{jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''}
|
||||
<Popover>
|
||||
<AlertTriangle size={16} class="ml-0.5 text-yellow-500" />
|
||||
{#snippet text()}
|
||||
Too specific filtering may have caused the omission of obscured jobs. This is done
|
||||
for security reasons. To see obscured jobs, try removing some filters.
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{:else}
|
||||
{jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''}
|
||||
<Popover>
|
||||
<AlertTriangle size={16} class="ml-0.5 text-yellow-500" />
|
||||
{#snippet text()}
|
||||
Too specific filtering may have caused the omission of obscured jobs. This is done for
|
||||
security reasons. To see obscured jobs, try removing some filters.
|
||||
{/snippet}
|
||||
</Popover>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="w-1/12 text-2xs"
|
||||
>{jobs ? jobCountString(jobs.length, lastFetchWentToEnd) : ''}</div
|
||||
>
|
||||
{/if}
|
||||
<div class="w-4/12 text-xs font-semibold"></div>
|
||||
<div class="w-4/12 text-xs font-semibold">Path</div>
|
||||
{/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>
|
||||
{#if containsLabel}
|
||||
<div class="w-3/12 text-xs font-semibold">Label</div>
|
||||
<div class="text-xs font-semibold">Label</div>
|
||||
{/if}
|
||||
<div class="w-3/12 text-xs font-semibold">Triggered by</div>
|
||||
<div class="text-xs font-semibold">Triggered by</div>
|
||||
{#if showTag}
|
||||
<div class="text-xs font-semibold">Tag</div>
|
||||
{/if}
|
||||
<div class=""></div>
|
||||
</div>
|
||||
</div>
|
||||
{#if jobs?.length == 0 && (!showExternalJobs || externalJobs?.length == 0)}
|
||||
@@ -294,13 +274,16 @@
|
||||
|
||||
{#if jobOrDate}
|
||||
{#if jobOrDate?.type === 'date'}
|
||||
<div class="bg-surface-secondary py-2 border-b font-semibold text-xs pl-5">
|
||||
<div
|
||||
class="bg-surface-secondary py-2 border-b font-semibold text-xs pl-2 h-[42px] flex items-center"
|
||||
>
|
||||
{jobOrDate.date}
|
||||
</div>
|
||||
{: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}
|
||||
@@ -314,9 +297,18 @@
|
||||
selectedIds = selectedIds
|
||||
}
|
||||
} else {
|
||||
selectedWorkspace = jobOrDate.job.workspace_id
|
||||
selectedIds = [jobOrDate.job.id]
|
||||
dispatch('select')
|
||||
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}
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
/* Shared CSS Grid Styles for RunsTable and RunRow components */
|
||||
|
||||
/* Grid layouts with tag column */
|
||||
.grid-runs-table {
|
||||
grid-template-columns:
|
||||
60px /* Status (fixed width) */
|
||||
minmax(100px, 0.8fr) /* Started time (~10%) */
|
||||
minmax(60px, 0.5fr) /* Duration (~7%) */
|
||||
minmax(200px, 2.3fr) /* Path (~33% reduced for wider tag) */
|
||||
minmax(150px, 1.8fr) /* Triggered by (~28% reduced for wider tag) */
|
||||
minmax(70px, 0.5fr) /* Tag (~7% wider) */
|
||||
minmax(40px, 0.3fr); /* Actions (~5%) */
|
||||
}
|
||||
|
||||
.grid-runs-table-with-labels {
|
||||
grid-template-columns:
|
||||
60px /* Status (fixed width) */
|
||||
minmax(100px, 0.8fr) /* Started time (~10%) */
|
||||
minmax(60px, 0.5fr) /* Duration (~7%) */
|
||||
minmax(150px, 1.6fr) /* Path (~25% reduced for labels and wider tag) */
|
||||
minmax(120px, 1.3fr) /* Labels (~22% reduced for wider tag) */
|
||||
minmax(110px, 1.3fr) /* Triggered by (~22% reduced for wider tag) */
|
||||
minmax(70px, 0.5fr) /* Tag (~7% wider) */
|
||||
minmax(40px, 0.3fr); /* Actions (~5%) */
|
||||
}
|
||||
|
||||
.grid-runs-table-selection {
|
||||
grid-template-columns:
|
||||
50px /* Selection checkbox (fixed width) */
|
||||
80px /* Status (fixed width) */
|
||||
minmax(100px, 0.8fr) /* Started time (~10%) */
|
||||
minmax(60px, 0.5fr) /* Duration (~7%) */
|
||||
minmax(200px, 2.3fr) /* Path (~33% reduced for wider tag) */
|
||||
minmax(150px, 1.8fr) /* Triggered by (~28% reduced for wider tag) */
|
||||
minmax(70px, 0.5fr) /* Tag (~7% wider) */
|
||||
minmax(40px, 0.3fr); /* Actions (~5%) */
|
||||
}
|
||||
|
||||
.grid-runs-table-with-labels-selection {
|
||||
grid-template-columns:
|
||||
50px /* Selection checkbox (fixed width) */
|
||||
80px /* Status (fixed width) */
|
||||
minmax(100px, 0.8fr) /* Started time (~10%) */
|
||||
minmax(60px, 0.5fr) /* Duration (~7%) */
|
||||
minmax(150px, 1.6fr) /* Path (~25% reduced for labels and wider tag) */
|
||||
minmax(120px, 1.3fr) /* Labels (~22% reduced for wider tag) */
|
||||
minmax(110px, 1.3fr) /* Triggered by (~22% reduced for wider tag) */
|
||||
minmax(70px, 0.5fr) /* Tag (~7% wider) */
|
||||
minmax(40px, 0.3fr); /* Actions (~5%) */
|
||||
}
|
||||
|
||||
/* Grid layouts without tag column */
|
||||
.grid-runs-table-no-tag {
|
||||
grid-template-columns:
|
||||
60px /* Status (fixed width) */
|
||||
minmax(100px, 0.8fr) /* Started time (~10%) */
|
||||
minmax(60px, 0.5fr) /* Duration (~7%) */
|
||||
minmax(200px, 2.8fr) /* Path (~38% expanded without tag) */
|
||||
minmax(150px, 2.2fr) /* Triggered by (~33% expanded without tag) */
|
||||
minmax(40px, 0.3fr); /* Actions (~5%) */
|
||||
}
|
||||
|
||||
.grid-runs-table-with-labels-no-tag {
|
||||
grid-template-columns:
|
||||
60px /* Status (fixed width) */
|
||||
minmax(100px, 0.8fr) /* Started time (~10%) */
|
||||
minmax(60px, 0.5fr) /* Duration (~7%) */
|
||||
minmax(150px, 1.8fr) /* Path (~30% expanded without tag) */
|
||||
minmax(120px, 1.8fr) /* Labels (~30% expanded without tag) */
|
||||
minmax(110px, 1.8fr) /* Triggered by (~30% expanded without tag) */
|
||||
minmax(40px, 0.3fr); /* Actions (~5%) */
|
||||
}
|
||||
|
||||
.grid-runs-table-selection-no-tag {
|
||||
grid-template-columns:
|
||||
40px /* Selection checkbox (fixed width) */
|
||||
60px /* Status (fixed width) */
|
||||
minmax(100px, 0.8fr) /* Started time (~10%) */
|
||||
minmax(60px, 0.5fr) /* Duration (~7%) */
|
||||
minmax(200px, 2.8fr) /* Path (~38% expanded without tag) */
|
||||
minmax(150px, 2.2fr) /* Triggered by (~33% expanded without tag) */
|
||||
minmax(40px, 0.3fr); /* Actions (~5%) */
|
||||
}
|
||||
|
||||
.grid-runs-table-with-labels-selection-no-tag {
|
||||
grid-template-columns:
|
||||
50px /* Selection checkbox (fixed width) */
|
||||
60px /* Status (fixed width) */
|
||||
minmax(100px, 0.8fr) /* Started time (~10%) */
|
||||
minmax(60px, 0.5fr) /* Duration (~7%) */
|
||||
minmax(150px, 1.8fr) /* Path (~30% expanded without tag) */
|
||||
minmax(120px, 1.8fr) /* Labels (~30% expanded without tag) */
|
||||
minmax(110px, 1.8fr) /* Triggered by (~30% expanded without tag) */
|
||||
minmax(40px, 0.3fr); /* Actions (~5%) */
|
||||
}
|
||||
@@ -28,6 +28,7 @@
|
||||
createText,
|
||||
noItemsMsg,
|
||||
open = $bindable(false),
|
||||
id,
|
||||
groupBy,
|
||||
sortBy,
|
||||
onFocus,
|
||||
@@ -53,6 +54,7 @@
|
||||
createText?: string
|
||||
noItemsMsg?: string
|
||||
open?: boolean
|
||||
id?: string
|
||||
groupBy?: (item: Item) => string
|
||||
sortBy?: (a: Item, b: Item) => number
|
||||
onFocus?: () => void
|
||||
@@ -137,6 +139,7 @@
|
||||
autocomplete="off"
|
||||
onpointerdown={() => (open = true)}
|
||||
bind:this={inputEl}
|
||||
{id}
|
||||
/>
|
||||
<SelectDropdown
|
||||
{disablePortal}
|
||||
|
||||
@@ -229,7 +229,7 @@ export function removeTriggerKindIfUnused(
|
||||
return usedTriggerKinds
|
||||
}
|
||||
|
||||
export function msToReadableTime(ms: number | undefined): string {
|
||||
export function msToReadableTime(ms: number | undefined, maximumFractionDigits?: number): string {
|
||||
if (ms === undefined) return '?'
|
||||
|
||||
const seconds = Math.floor(ms / 1000)
|
||||
@@ -244,7 +244,7 @@ export function msToReadableTime(ms: number | undefined): string {
|
||||
} else if (minutes > 0) {
|
||||
return `${minutes}m ${seconds % 60}s`
|
||||
} else {
|
||||
return `${msToSec(ms)}s`
|
||||
return `${msToSec(ms, maximumFractionDigits)}s`
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1307,6 +1307,7 @@ export type Item = {
|
||||
type?: 'action' | 'delete'
|
||||
hide?: boolean | undefined
|
||||
extra?: Snippet
|
||||
id?: string
|
||||
}
|
||||
|
||||
export function isObjectTooBig(obj: any): boolean {
|
||||
|
||||
@@ -22,9 +22,7 @@
|
||||
encodeState,
|
||||
isFlowPreview,
|
||||
isNotFlow,
|
||||
isScriptPreview,
|
||||
truncateHash,
|
||||
truncateRev
|
||||
isScriptPreview
|
||||
} from '$lib/utils'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
|
||||
@@ -82,20 +80,21 @@
|
||||
import { json } from 'svelte-highlight/languages'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import WorkflowTimeline from '$lib/components/WorkflowTimeline.svelte'
|
||||
import Tooltip from '$lib/components/meltComponents/Tooltip.svelte'
|
||||
|
||||
import HighlightTheme from '$lib/components/HighlightTheme.svelte'
|
||||
import PreprocessedArgsDisplay from '$lib/components/runs/PreprocessedArgsDisplay.svelte'
|
||||
|
||||
import ExecutionDuration from '$lib/components/ExecutionDuration.svelte'
|
||||
import CustomPopover from '$lib/components/CustomPopover.svelte'
|
||||
import { isWindmillTooBigObject } from '$lib/components/job_args'
|
||||
import ScheduleEditor from '$lib/components/triggers/schedules/ScheduleEditor.svelte'
|
||||
import { setContext, untrack } from 'svelte'
|
||||
import WorkerHostname from '$lib/components/WorkerHostname.svelte'
|
||||
|
||||
import FlowAssetsHandler, {
|
||||
initFlowGraphAssetsCtx
|
||||
} from '$lib/components/flows/FlowAssetsHandler.svelte'
|
||||
import JobAssetsViewer from '$lib/components/assets/JobAssetsViewer.svelte'
|
||||
import { page } from '$app/state'
|
||||
import RunBadges from '$lib/components/runs/RunBadges.svelte'
|
||||
let job: (Job & { result?: any; result_stream?: string }) | undefined = $state()
|
||||
let jobUpdateLastFetch: Date | undefined = $state()
|
||||
|
||||
@@ -764,96 +763,14 @@
|
||||
{/if}
|
||||
{job.script_path ?? (job.job_kind == 'dependencies' ? 'lock dependencies' : 'No path')}
|
||||
<div class="flex flex-row gap-2 items-center flex-wrap">
|
||||
{#if job.script_hash && job.job_kind !== 'aiagent'}
|
||||
{#if job.job_kind == 'script'}
|
||||
<a href="{base}/scripts/get/{job.script_hash}?workspace={$workspaceStore}"
|
||||
><Badge color="gray">{truncateHash(job.script_hash)}</Badge></a
|
||||
>
|
||||
{:else}
|
||||
<div>
|
||||
<Badge color="gray">{truncateHash(job.script_hash)}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if job && 'job_kind' in job}
|
||||
<div>
|
||||
<Badge color="blue">{job.job_kind}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if job && job.flow_status && job.job_kind === 'script'}
|
||||
<PreprocessedArgsDisplay preprocessed={job.preprocessed} />
|
||||
{/if}
|
||||
{#if persistentScriptDefinition}
|
||||
<button onclick={() => persistentScriptDrawer?.open?.(persistentScriptDefinition)}
|
||||
><Badge color="red">persistent</Badge></button
|
||||
>
|
||||
{/if}
|
||||
{#if job && 'priority' in job}
|
||||
<div>
|
||||
<Badge color="blue">priority: {job.priority}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if job.tag && !['deno', 'python3', 'flow', 'other', 'go', 'postgresql', 'mysql', 'bigquery', 'snowflake', 'mssql', 'graphql', 'oracledb', 'nativets', 'bash', 'powershell', 'php', 'rust', 'other', 'ansible', 'csharp', 'nu', 'java', 'duckdb', 'dependency', 'ruby'].includes(job.tag)}
|
||||
<!-- for related places search: ADD_NEW_LANG -->
|
||||
<div>
|
||||
<Badge color="indigo">Tag: {job.tag}</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if !job.visible_to_owner}
|
||||
<div>
|
||||
<Badge color="red">
|
||||
only visible to you
|
||||
<Tooltip>
|
||||
{#snippet text()}
|
||||
The option to hide this run from the owner of this script or flow was
|
||||
activated
|
||||
{/snippet}
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
</div>
|
||||
{/if}
|
||||
{#if job?.['labels'] && Array.isArray(job?.['labels']) && job?.['labels'].length > 0}
|
||||
{#each job?.['labels'] as label}
|
||||
<div>
|
||||
<Badge>Label: {label}</Badge>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if concurrencyKey}
|
||||
<div>
|
||||
<Tooltip notClickable>
|
||||
{#snippet text()}
|
||||
This job has concurrency limits enabled with the key
|
||||
<a
|
||||
href={`${base}/runs/?job_kinds=all&graph=ConcurrencyChart&concurrency_key=${concurrencyKey}`}
|
||||
>
|
||||
{concurrencyKey}
|
||||
</a>
|
||||
{/snippet}
|
||||
<a
|
||||
href={`${base}/runs/?job_kinds=all&graph=ConcurrencyChart&concurrency_key=${concurrencyKey}`}
|
||||
>
|
||||
<Badge>Concurrency: {truncateRev(concurrencyKey, 20)}</Badge></a
|
||||
>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
{#if job?.worker}
|
||||
<div>
|
||||
<Tooltip notClickable>
|
||||
{#snippet text()}
|
||||
worker:
|
||||
<a href={`${base}/runs/?job_kinds=all&worker=${job?.worker}`}>
|
||||
{job?.worker}
|
||||
</a><br />
|
||||
<WorkerHostname worker={job?.worker!} minTs={job?.['created_at']} />
|
||||
{/snippet}
|
||||
<a href={`${base}/runs/?job_kinds=all&worker=${job?.worker}`}>
|
||||
<Badge>Worker: {truncateRev(job?.worker, 20)}</Badge></a
|
||||
>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{/if}
|
||||
<RunBadges
|
||||
{job}
|
||||
displayPersistentScriptDefinition={!!persistentScriptDefinition}
|
||||
openPersistentScriptDrawer={() => {
|
||||
persistentScriptDrawer?.open?.(persistentScriptDefinition)
|
||||
}}
|
||||
{concurrencyKey}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -973,7 +890,7 @@
|
||||
<HighlightCode lines language={job.language} code={job.raw_code} />
|
||||
</div>
|
||||
{:else if job}
|
||||
No code is available
|
||||
<span class="text-sm">No code available</span>
|
||||
{:else}
|
||||
<Skeleton layout={[[5]]} />
|
||||
{/if}
|
||||
@@ -981,7 +898,7 @@
|
||||
<div class="w-full">
|
||||
<MemoryFootprintViewer jobId={job.id} bind:jobUpdateLastFetch />
|
||||
</div>
|
||||
{:else if job !== undefined && (job.result || job.result_stream)}
|
||||
{:else if job !== undefined && (job.result_stream || (job.type == 'CompletedJob' && job.result !== undefined))}
|
||||
<DisplayResult
|
||||
workspaceId={job?.workspace_id}
|
||||
result_stream={job.result_stream}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user