Compare commits
6 Commits
v1.682.0
...
feat/batch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81c53ff31d | ||
|
|
91f167a28e | ||
|
|
259617ebe4 | ||
|
|
9d8a2cd823 | ||
|
|
3ac4bb0eb4 | ||
|
|
b329b3ba1e |
@@ -48,6 +48,7 @@
|
||||
export let loading = false
|
||||
export let noVariablePicker = false
|
||||
export let viewKeybinding = false
|
||||
export let noUrlChange = false
|
||||
|
||||
export let scheduledForStr: string | undefined
|
||||
export let invisible_to_owner: boolean | undefined
|
||||
@@ -73,6 +74,9 @@
|
||||
let debounced: NodeJS.Timeout | undefined = undefined
|
||||
|
||||
function onArgsChange(args: any) {
|
||||
if (noUrlChange) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
debounced && clearTimeout(debounced)
|
||||
debounced = setTimeout(() => {
|
||||
@@ -148,7 +152,7 @@
|
||||
{#if !runnable.schema.properties || Object.keys(runnable.schema.properties).length === 0}
|
||||
<div class="text-sm py-4 italic">No arguments</div>
|
||||
{:else}
|
||||
{#key reloadArgs}
|
||||
{#key `${runnable?.hash}-${reloadArgs}`}
|
||||
<SchemaForm
|
||||
helperScript={runnable.hash
|
||||
? {
|
||||
|
||||
@@ -2,7 +2,13 @@
|
||||
import { base } from '$lib/base'
|
||||
import { goto } from '$lib/navigation'
|
||||
import type { Job } from '$lib/gen'
|
||||
import { displayDate, msToReadableTime, truncateHash, truncateRev, isJobCancelable } from '$lib/utils'
|
||||
import {
|
||||
displayDate,
|
||||
msToReadableTime,
|
||||
truncateHash,
|
||||
truncateRev,
|
||||
isJobCancelable
|
||||
} from '$lib/utils'
|
||||
import { Badge, Button } from '../common'
|
||||
import ScheduleEditor from '../ScheduleEditor.svelte'
|
||||
import BarsStaggered from '$lib/components/icons/BarsStaggered.svelte'
|
||||
@@ -34,11 +40,11 @@
|
||||
export let containsLabel: boolean = false
|
||||
export let activeLabel: string | null
|
||||
export let isSelectingJobsToCancel: boolean = false
|
||||
export let isBatch: boolean = false
|
||||
|
||||
let scheduleEditor: ScheduleEditor
|
||||
|
||||
$: isExternal = job && job.id === '-'
|
||||
|
||||
</script>
|
||||
|
||||
<Portal name="run-row">
|
||||
@@ -60,7 +66,7 @@
|
||||
}}
|
||||
>
|
||||
<div class="w-1/12 flex justify-center">
|
||||
{#if isSelectingJobsToCancel && isJobCancelable(job)}
|
||||
{#if isBatch || (isSelectingJobsToCancel && isJobCancelable(job))}
|
||||
<div class="px-2">
|
||||
<input type="checkbox" checked={selected} />
|
||||
</div>
|
||||
@@ -123,7 +129,6 @@
|
||||
Scheduled for {displayDate(job.scheduled_for)}
|
||||
{:else if job.canceled}
|
||||
Cancelling job... (created <TimeAgo agoOnlyIfRecent date={job.created_at || ''} />)
|
||||
|
||||
{:else}
|
||||
Waiting for executor (created <TimeAgo agoOnlyIfRecent date={job.created_at || ''} />)
|
||||
{/if}
|
||||
|
||||
309
frontend/src/lib/components/runs/RunsBatch.svelte
Normal file
309
frontend/src/lib/components/runs/RunsBatch.svelte
Normal file
@@ -0,0 +1,309 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
JobService,
|
||||
ScriptService,
|
||||
type Job,
|
||||
type RunScriptByHashData,
|
||||
type Script,
|
||||
type ScriptArgs
|
||||
} from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import { RefreshCw } from 'lucide-svelte'
|
||||
import Button from '../common/button/Button.svelte'
|
||||
import RunForm from '../RunForm.svelte'
|
||||
import { Tab, TabContent, Tabs } from '../common'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
|
||||
type ScriptJob = Job & { job_kind: 'script'; script_hash: string }
|
||||
type RunnableScript = {
|
||||
prior: ScriptJob
|
||||
script: Script
|
||||
newArgs: ScriptArgs | undefined
|
||||
}
|
||||
type ScriptArgsMap = {
|
||||
[hash: 'common' | string]: {
|
||||
args: ScriptArgs | undefined
|
||||
schema: { [key: string]: unknown }
|
||||
}
|
||||
}
|
||||
type RichScriptArgs = {
|
||||
args: ScriptArgs | undefined
|
||||
schema: { [key: string]: unknown }
|
||||
hash: 'common' | string
|
||||
}
|
||||
|
||||
function isScriptJob(job: Job): job is ScriptJob {
|
||||
return job.job_kind === 'script' && job.script_hash !== undefined
|
||||
}
|
||||
|
||||
export let jobs: Job[] | undefined
|
||||
export let selectedIds: string[]
|
||||
|
||||
let runnableScripts: RunnableScript[] = []
|
||||
let factorisedScriptArgs: RichScriptArgs[] = []
|
||||
let tab: 'common' | string = 'common'
|
||||
let isLoading = false
|
||||
|
||||
let runScript = () => {}
|
||||
$: handleNewJobs(jobs, selectedIds)
|
||||
|
||||
async function handleNewJobs(jobs: Job[] | undefined, selectedIds: string[]) {
|
||||
console.time('handleNewJobs')
|
||||
if (jobs === undefined || !Array.isArray(jobs)) {
|
||||
return
|
||||
}
|
||||
|
||||
isLoading = true
|
||||
const selectedIdsSet = new Set(selectedIds)
|
||||
const selectedJobs = jobs.filter((job) => selectedIdsSet.has(job.id))
|
||||
|
||||
const processedJobIds = new Set(runnableScripts.map((s) => s.prior.id))
|
||||
const newJobs = selectedJobs.filter((job) => !processedJobIds.has(job.id))
|
||||
|
||||
console.time('jobsToRunnableScripts')
|
||||
const newRunnableScripts = await jobsToRunnableScripts(newJobs)
|
||||
|
||||
console.timeEnd('jobsToRunnableScripts')
|
||||
|
||||
runnableScripts = selectedJobs
|
||||
.map((job) => [...runnableScripts, ...newRunnableScripts].find((s) => s.prior.id === job.id))
|
||||
.filter((s) => s !== undefined)
|
||||
|
||||
console.log('runnableScripts updated to', runnableScripts)
|
||||
factorisedScriptArgs = getScriptArgsMap(runnableScripts)
|
||||
console.log('scriptAegsMap updated to', factorisedScriptArgs)
|
||||
console.timeEnd('handleNewJobs')
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
async function jobsToRunnableScripts(jobs: Job[]): Promise<RunnableScript[]> {
|
||||
if (jobs.length === 0) {
|
||||
return []
|
||||
}
|
||||
const scriptJobs = jobs.filter(isScriptJob)
|
||||
const scriptHashes = [...new Set(scriptJobs.map((job) => job.script_hash))]
|
||||
|
||||
const scriptArgsPromises = scriptJobs.map((job) =>
|
||||
JobService.getJobArgs({ id: job.id, workspace: $workspaceStore! })
|
||||
)
|
||||
const scriptPromises = scriptHashes.map((scriptHash) =>
|
||||
ScriptService.getScriptByHash({
|
||||
workspace: $workspaceStore!,
|
||||
hash: scriptHash
|
||||
})
|
||||
)
|
||||
console.time('promises')
|
||||
const results = await Promise.allSettled([...scriptArgsPromises, ...scriptPromises])
|
||||
console.timeEnd('promises')
|
||||
const scriptArgsResults = results.slice(0, scriptArgsPromises.length)
|
||||
const scriptResults = results.slice(scriptArgsPromises.length)
|
||||
|
||||
const priorArgs = scriptArgsResults
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((p) => p.value) as ScriptArgs[]
|
||||
const scriptsArr = scriptResults
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((p) => p.value) as Script[]
|
||||
|
||||
const scripts = scriptHashes.reduce((acc, scriptHash, i) => {
|
||||
acc[scriptHash] = scriptsArr[i]
|
||||
return acc
|
||||
}, {} as { [script_hash: string]: Script })
|
||||
|
||||
return scriptJobs.map((job, i) => ({
|
||||
prior: { ...job, args: priorArgs[i] },
|
||||
script: scripts[job.script_hash],
|
||||
newArgs: structuredClone(priorArgs[i])
|
||||
}))
|
||||
}
|
||||
|
||||
function getScriptArgsMap(runnables: RunnableScript[]): RichScriptArgs[] {
|
||||
const scriptArgsMap: ScriptArgsMap = {
|
||||
common: { schema: { properties: {}, type: 'object' }, args: {} }
|
||||
}
|
||||
const allArgNames: Set<string> = new Set()
|
||||
|
||||
if (runnables.length === 0) return []
|
||||
|
||||
if (runnables.some((r) => r.script.schema?.$schema !== runnables[0].script.schema?.$schema)) {
|
||||
console.warn('Warning: a script with a different version of JSON schema has been found.')
|
||||
} else {
|
||||
scriptArgsMap.common.schema.$schema = runnables[0].script.schema?.$schema
|
||||
}
|
||||
|
||||
for (const runnable of runnables) {
|
||||
const schema = runnable.script.schema
|
||||
if (schema === undefined || schema.type !== 'object') continue
|
||||
|
||||
Object.keys(schema.properties as Record<string, string>).forEach((argName) => {
|
||||
allArgNames.add(argName)
|
||||
})
|
||||
}
|
||||
|
||||
const commonArgs: Record<string, any> = {}
|
||||
const commonArgsProperties: Record<string, any> = {}
|
||||
const commonRequired: string[] = []
|
||||
|
||||
for (const argName of allArgNames) {
|
||||
let isCommon = true
|
||||
let commonArgProperties: { type: string } | undefined = undefined
|
||||
let argValue: ScriptArgs[string] | undefined = undefined
|
||||
let isRequired = false
|
||||
|
||||
for (const runnable of runnables) {
|
||||
const schema = runnable.script.schema
|
||||
if (schema === undefined || schema.type !== 'object') continue
|
||||
|
||||
const argProperties = (schema.properties as Record<string, any>)[argName]
|
||||
|
||||
if (!argProperties) {
|
||||
isCommon = false
|
||||
break
|
||||
}
|
||||
|
||||
if (commonArgProperties === undefined) {
|
||||
commonArgProperties = argProperties
|
||||
argValue = runnable.prior.args![argName]
|
||||
} else {
|
||||
if (!deepEqual(commonArgProperties, argProperties)) {
|
||||
isCommon = false
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!isRequired &&
|
||||
Array.isArray(schema.required) &&
|
||||
schema.required.indexOf(argName) > -1
|
||||
) {
|
||||
isRequired = true
|
||||
}
|
||||
}
|
||||
|
||||
if (isCommon) {
|
||||
commonArgs[argName] = argValue
|
||||
commonArgsProperties[argName] = commonArgProperties
|
||||
|
||||
if (isRequired) {
|
||||
commonRequired.push(argName)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
scriptArgsMap.common.args = commonArgs
|
||||
scriptArgsMap.common.schema.properties = commonArgsProperties
|
||||
|
||||
if (commonRequired.length > 0) {
|
||||
scriptArgsMap.common.schema.required = commonRequired
|
||||
}
|
||||
|
||||
for (const runnable of runnables) {
|
||||
if (runnable.script.schema === undefined) continue
|
||||
|
||||
const specificArgs: Record<string, any> = {}
|
||||
const scriptSchema = runnable.script.schema as Record<string, any>
|
||||
|
||||
for (const argName in runnable.prior.args) {
|
||||
if (argName in scriptSchema) {
|
||||
specificArgs[argName] = runnable.prior.args[argName]
|
||||
}
|
||||
}
|
||||
|
||||
scriptArgsMap[runnable.script.hash] = {
|
||||
schema: scriptSchema,
|
||||
args: specificArgs
|
||||
}
|
||||
}
|
||||
|
||||
return Object.entries(scriptArgsMap).map(([hash, props]) => ({ ...props, hash }))
|
||||
}
|
||||
|
||||
async function runImmediatelyPriorArgs() {
|
||||
console.log('clicked on run', runnableScripts)
|
||||
|
||||
const payloads: RunScriptByHashData[] = runnableScripts.map((newJob) => ({
|
||||
hash: newJob.prior.script_hash,
|
||||
requestBody: newJob.prior.args === undefined ? {} : newJob.prior.args,
|
||||
workspace: $workspaceStore!
|
||||
}))
|
||||
console.log('payloads', payloads)
|
||||
|
||||
const promises = payloads.map((payload) => JobService.runScriptByHash(payload))
|
||||
const results = await Promise.allSettled(promises)
|
||||
console.log('results', results)
|
||||
return results
|
||||
}
|
||||
|
||||
async function runImmediatelyNewArgs() {
|
||||
console.log('clicked on run', runnableScripts)
|
||||
|
||||
const payloads: RunScriptByHashData[] = runnableScripts.map((newJob) => ({
|
||||
hash: newJob.prior.script_hash,
|
||||
requestBody: newJob.newArgs === undefined ? {} : newJob.newArgs,
|
||||
workspace: $workspaceStore!
|
||||
}))
|
||||
console.log('payloads', payloads)
|
||||
|
||||
const promises = payloads.map((payload) => JobService.runScriptByHash(payload))
|
||||
const results = await Promise.allSettled(promises)
|
||||
console.log('results', results)
|
||||
return results
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="tabs-container overflow-x-auto">
|
||||
<Button
|
||||
on:click|once={() => {
|
||||
runImmediatelyPriorArgs()
|
||||
}}
|
||||
color="blue"
|
||||
size="sm"
|
||||
startIcon={{ icon: RefreshCw }}
|
||||
wrapperClasses="m-2"
|
||||
>
|
||||
Run immediately selected scripts with prior args
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => {
|
||||
runnableScripts = runnableScripts.map((runnable) => ({
|
||||
...runnable,
|
||||
newArgs: structuredClone(runnable.prior.args)
|
||||
}))
|
||||
}}
|
||||
color="blue"
|
||||
size="sm"
|
||||
wrapperClasses="m-2"
|
||||
>
|
||||
Reset to prior args
|
||||
</Button>
|
||||
|
||||
{#if !isLoading}
|
||||
<Tabs bind:selected={tab}>
|
||||
{#each factorisedScriptArgs as factorised (`tab-${factorised.hash}`)}
|
||||
<Tab value={factorised.hash}>{factorised.hash}</Tab>
|
||||
{/each}
|
||||
|
||||
<svelte:fragment slot="content">
|
||||
{#each factorisedScriptArgs as factorised (`tabcontent-${factorised.hash}`)}
|
||||
<TabContent value={factorised.hash}>
|
||||
<div class="p-8 w-full max-w-3xl mx-auto">
|
||||
<RunForm
|
||||
scheduledForStr={undefined}
|
||||
invisible_to_owner={undefined}
|
||||
overrideTag={undefined}
|
||||
autofocus={true}
|
||||
detailed={false}
|
||||
runnable={factorised}
|
||||
runAction={runScript}
|
||||
bind:args={factorised.args}
|
||||
schedulable={false}
|
||||
noUrlChange={true}
|
||||
/>
|
||||
</div>
|
||||
</TabContent>
|
||||
{/each}
|
||||
</svelte:fragment>
|
||||
</Tabs>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -16,6 +16,7 @@
|
||||
export let omittedObscuredJobs: boolean
|
||||
export let showExternalJobs: boolean = false
|
||||
export let isSelectingJobsToCancel: boolean = false
|
||||
export let isBatch: boolean = false
|
||||
export let selectedIds: string[] = []
|
||||
export let selectedWorkspace: string | undefined = undefined
|
||||
export let activeLabel: string | null = null
|
||||
@@ -148,12 +149,27 @@
|
||||
selectedIds = []
|
||||
} else {
|
||||
allSelected = true
|
||||
if (isBatch && !isSelectingJobsToCancel) {
|
||||
selectedIds = jobs?.map((j) => j.id) ?? []
|
||||
return
|
||||
}
|
||||
selectedIds = jobs?.filter(isJobCancelable).map((j) => j.id) ?? []
|
||||
}
|
||||
}
|
||||
let cancelableJobCount: number = 0
|
||||
$: isSelectingJobsToCancel && (allSelected = selectedIds.length === cancelableJobCount)
|
||||
$: isSelectingJobsToCancel && (cancelableJobCount = jobs?.filter(isJobCancelable).length ?? 0)
|
||||
$: handleIsBatchToggle(isBatch)
|
||||
|
||||
function handleIsBatchToggle(newIsBatch: boolean) {
|
||||
if (newIsBatch) {
|
||||
allSelected = true
|
||||
selectedIds = jobs?.map((j) => j.id) ?? []
|
||||
} else {
|
||||
allSelected = false
|
||||
selectedIds = []
|
||||
}
|
||||
}
|
||||
|
||||
function jobCountString(jobCount: number | undefined, lastFetchWentToEnd: boolean): string {
|
||||
if (jobCount === undefined) {
|
||||
@@ -189,13 +205,15 @@
|
||||
|
||||
<svelte:window on:resize={() => computeHeight()} />
|
||||
|
||||
<!-- <button on:click={() => console.log(isSelectingJobs)}>console.log</button> -->
|
||||
|
||||
<div
|
||||
class="divide-y min-w-[640px] h-full"
|
||||
id="runs-table-wrapper"
|
||||
bind:clientWidth={containerWidth}
|
||||
>
|
||||
<div bind:clientHeight={header}>
|
||||
{#if isSelectingJobsToCancel && cancelableJobCount != 0}
|
||||
{#if !!isBatch || (isSelectingJobsToCancel && cancelableJobCount != 0)}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
@@ -279,9 +297,10 @@
|
||||
job={jobOrDate.job}
|
||||
selected={jobOrDate.job.id !== '-' && selectedIds.includes(jobOrDate.job.id)}
|
||||
{isSelectingJobsToCancel}
|
||||
{isBatch}
|
||||
on:select={() => {
|
||||
const jobId = jobOrDate.job.id
|
||||
if (isSelectingJobsToCancel) {
|
||||
if (isBatch || isSelectingJobsToCancel) {
|
||||
if (selectedIds.includes(jobOrDate.job.id)) {
|
||||
selectedIds = selectedIds.filter((id) => id != jobId)
|
||||
} else {
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
import { goto } from '$app/navigation'
|
||||
import { base } from '$app/paths'
|
||||
import { isJobCancelable } from '$lib/utils'
|
||||
import RunsBatch from '$lib/components/runs/RunsBatch.svelte'
|
||||
|
||||
let jobs: Job[] | undefined
|
||||
let selectedIds: string[] = []
|
||||
@@ -326,6 +327,7 @@
|
||||
selectedIds = []
|
||||
jobIdsToCancel = []
|
||||
isSelectingJobsToCancel = false
|
||||
isBatch = false
|
||||
selectedWorkspace = undefined
|
||||
jobLoader?.loadJobs(minTs, maxTs, true)
|
||||
}
|
||||
@@ -437,10 +439,12 @@
|
||||
|
||||
let jobIdsToCancel: string[] = []
|
||||
let isSelectingJobsToCancel = false
|
||||
let isBatch: boolean = false
|
||||
let fetchingFilteredJobs = false
|
||||
let selectedFiltersString: string | undefined = undefined
|
||||
|
||||
async function cancelVisibleJobs() {
|
||||
isBatch = false
|
||||
isSelectingJobsToCancel = true
|
||||
selectedIds = jobs?.filter(isJobCancelable).map((j) => j.id) ?? []
|
||||
if (selectedIds.length === 0) {
|
||||
@@ -573,7 +577,7 @@
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
title={`Confirm cancelling all jobs correspoding to the selected filters (${jobIdsToCancel.length} jobs)`}
|
||||
title={`Confirm cancelling all jobs corresponding to the selected filters (${jobIdsToCancel.length} jobs)`}
|
||||
confirmationText={`Cancel ${jobIdsToCancel.length} jobs that matched the filters`}
|
||||
open={isCancelingFilteredJobs}
|
||||
on:confirmed={async () => {
|
||||
@@ -965,6 +969,14 @@
|
||||
options={{ right: 'Auto-refresh' }}
|
||||
textClass="whitespace-nowrap"
|
||||
/>
|
||||
{#if !isSelectingJobsToCancel}
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={isBatch}
|
||||
options={{ right: 'Batch run' }}
|
||||
textClass="whitespace-nowrap"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -979,6 +991,7 @@
|
||||
showExternalJobs={!graphIsRunsChart}
|
||||
activeLabel={label}
|
||||
{isSelectingJobsToCancel}
|
||||
{isBatch}
|
||||
bind:selectedIds
|
||||
bind:selectedWorkspace
|
||||
bind:lastFetchWentToEnd
|
||||
@@ -1012,9 +1025,7 @@
|
||||
/>
|
||||
{/if}
|
||||
{:else if selectedIds.length > 1}
|
||||
<div class="text-xs m-4"
|
||||
>There are {selectedIds.length} jobs selected. Choose 1 to see detailed information</div
|
||||
>
|
||||
<RunsBatch {jobs} {selectedIds} />
|
||||
{:else}
|
||||
<div class="text-xs m-4">No job selected</div>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user