Compare commits

...

6 Commits

Author SHA1 Message Date
Clement Zhang
81c53ff31d todo: fix RunForm render, rm newArgs, impl run factorised 2024-11-21 17:20:33 +01:00
Clement Zhang
91f167a28e v0 ok 2024-11-18 17:47:50 +01:00
Clement Zhang
259617ebe4 Merge branch 'main' into feat/batch-jobs-ui 2024-11-18 12:37:43 +01:00
Clement Zhang
9d8a2cd823 batch execution logic (simple case) 2024-11-14 12:37:02 +01:00
Clement Zhang
3ac4bb0eb4 Merge remote-tracking branch 'upstream/main' into feat/batch-jobs-ui 2024-11-14 10:36:16 +01:00
Clement Zhang
b329b3ba1e in modal, basic interface: select jobs, set args, reset args 2024-11-08 18:49:52 +01:00
5 changed files with 359 additions and 11 deletions

View File

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

View File

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

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

View File

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

View File

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