mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-09-07 08:02:40 +00:00
feat(frontend): Add running runs on the script detail page (#3005)
* feat(frontend): Add running runs on the script detail page * feat(frontend): Add running runs on the script detail page * feat(frontend): Fix build * feat(frontend): bump refresh rate, remove getCount call * feat(frontend): improve code readability * feat(frontend): fix getCount
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { Button } from '$lib/components/common'
|
||||
import { InputService, type Input, RunnableType, type CreateInput } from '$lib/gen/index.js'
|
||||
import { InputService, type Input, RunnableType, type CreateInput, Job } from '$lib/gen/index.js'
|
||||
import { userStore, workspaceStore } from '$lib/stores.js'
|
||||
import { classNames, displayDate, sendUserToast } from '$lib/utils.js'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
@@ -10,12 +10,30 @@
|
||||
import Toggle from './Toggle.svelte'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import TimeAgo from './TimeAgo.svelte'
|
||||
import JobLoader from './runs/JobLoader.svelte'
|
||||
|
||||
export let scriptHash: string | null = null
|
||||
export let scriptPath: string | null = null
|
||||
export let flowPath: string | null = null
|
||||
export let canSaveInputs: boolean = true
|
||||
|
||||
// Are the current Inputs valid and able to be saved?
|
||||
export let isValid: boolean
|
||||
export let args: object
|
||||
|
||||
interface EditableInput extends Input {
|
||||
isEditing?: boolean
|
||||
isSaving?: boolean
|
||||
}
|
||||
|
||||
let previousInputs: Input[] = []
|
||||
let savedInputs: EditableInput[] = []
|
||||
let selectedInput: Input | null
|
||||
let jobs: Job[] = []
|
||||
let loading: boolean = false
|
||||
let savingInputs = false
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
$: runnableId = scriptPath || flowPath || undefined
|
||||
$: runnableType = scriptHash
|
||||
? RunnableType.SCRIPT_HASH
|
||||
@@ -25,19 +43,6 @@
|
||||
? RunnableType.FLOW_PATH
|
||||
: undefined
|
||||
|
||||
// Are the current Inputs valid and able to be saved?
|
||||
export let isValid: boolean
|
||||
export let args: object
|
||||
|
||||
let previousInputs: Input[] = []
|
||||
interface EditableInput extends Input {
|
||||
isEditing?: boolean
|
||||
isSaving?: boolean
|
||||
}
|
||||
let savedInputs: EditableInput[] = []
|
||||
|
||||
let selectedInput: Input | null
|
||||
|
||||
async function loadInputHistory() {
|
||||
previousInputs = await InputService.getInputHistory({
|
||||
workspace: $workspaceStore!,
|
||||
@@ -56,8 +61,6 @@
|
||||
})
|
||||
}
|
||||
|
||||
let savingInputs = false
|
||||
|
||||
async function saveInput(args: object) {
|
||||
savingInputs = true
|
||||
|
||||
@@ -127,20 +130,34 @@
|
||||
}
|
||||
|
||||
$: {
|
||||
if ($workspaceStore && (scriptHash || scriptPath || flowPath)) {
|
||||
if ($workspaceStore && jobs && (scriptHash || scriptPath || flowPath)) {
|
||||
console.log('loading inputs')
|
||||
loadInputHistory()
|
||||
loadSavedInputs()
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
const selectArgs = (selected_args: any) => {
|
||||
function selectArgs(selected_args: any) {
|
||||
dispatch('selected_args', selected_args)
|
||||
}
|
||||
</script>
|
||||
|
||||
<JobLoader
|
||||
bind:jobs
|
||||
path={runnableId ?? null}
|
||||
isSkipped={false}
|
||||
jobKindsCat="jobs"
|
||||
jobKinds="all"
|
||||
user={null}
|
||||
folder={null}
|
||||
success="running"
|
||||
argFilter={undefined}
|
||||
bind:loading
|
||||
synUrl={false}
|
||||
syncQueuedRunsCount={false}
|
||||
refreshRate={10000}
|
||||
/>
|
||||
|
||||
<div class="min-w-[300px] h-full">
|
||||
<Splitpanes horizontal={true}>
|
||||
<Pane>
|
||||
@@ -263,6 +280,47 @@
|
||||
<div class="w-full flex flex-col gap-4 p-2">
|
||||
<span class="text-sm font-semibold">Previous runs</span>
|
||||
|
||||
<div class="w-full flex flex-col gap-1 p-0 h-full overflow-y-auto">
|
||||
{#if jobs.length > 0}
|
||||
{#each jobs as i (i.id)}
|
||||
<button
|
||||
class={classNames(
|
||||
`w-full flex items-center justify-between gap-4 py-2 px-4 text-left border rounded-sm hover:bg-surface-hover transition-a`,
|
||||
'border-orange-400'
|
||||
)}
|
||||
>
|
||||
<div
|
||||
class="w-full h-full items-center text-xs font-normal grid grid-cols-8 gap-4 min-w-0"
|
||||
>
|
||||
<div class="">
|
||||
<div class="rounded-full w-2 h-2 bg-orange-400 animate-pulse" />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
{i.created_by}
|
||||
</div>
|
||||
<div
|
||||
class="whitespace-nowrap col-span-3 !text-tertiary !text-2xs overflow-hidden text-ellipsis flex-shrink text-center"
|
||||
>
|
||||
<TimeAgo date={i.created_at ?? ''} />
|
||||
</div>
|
||||
<div class="col-span-2">
|
||||
<a
|
||||
target="_blank"
|
||||
href="/run/{i.id}?workspace={$workspaceStore}"
|
||||
class="text-right float-right text-secondary"
|
||||
title="See run detail in a new tab"
|
||||
>
|
||||
<ExternalLink size={16} />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
{/each}
|
||||
{:else}
|
||||
<div class="text-left text-tertiary text-xs">No running runs</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="w-full flex flex-col gap-1 p-0 h-full overflow-y-auto">
|
||||
{#if previousInputs.length > 0}
|
||||
{#each previousInputs as i (i.id)}
|
||||
|
||||
@@ -0,0 +1,305 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import {
|
||||
JobService,
|
||||
Job,
|
||||
CompletedJob,
|
||||
ScriptService,
|
||||
FlowService,
|
||||
UserService,
|
||||
FolderService
|
||||
} from '$lib/gen'
|
||||
|
||||
import { page } from '$app/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
|
||||
import { tweened, type Tweened } from 'svelte/motion'
|
||||
import { goto } from '$app/navigation'
|
||||
import { forLater } from '$lib/forLater'
|
||||
|
||||
export let jobs: Job[] | undefined
|
||||
export let user: string | null
|
||||
export let folder: string | null
|
||||
export let path: string | null
|
||||
export let success: 'success' | 'failure' | 'running' | undefined = undefined
|
||||
export let isSkipped: boolean = false
|
||||
export let hideSchedules: boolean = false
|
||||
export let argFilter: string | undefined
|
||||
export let resultFilter: string | undefined = undefined
|
||||
export let schedulePath: string | undefined = undefined
|
||||
export let jobKindsCat: string
|
||||
export let minTs: string | undefined = undefined
|
||||
export let maxTs: string | undefined = undefined
|
||||
export let jobKinds: string = ''
|
||||
export let queue_count: Tweened<number> | undefined = undefined
|
||||
export let autoRefresh: boolean = true
|
||||
export let paths: string[] = []
|
||||
export let usernames: string[] = []
|
||||
export let folders: string[] = []
|
||||
export let completedJobs: CompletedJob[] | undefined = undefined
|
||||
export let argError = ''
|
||||
export let resultError = ''
|
||||
export let loading: boolean = false
|
||||
export let synUrl: boolean = true
|
||||
export let refreshRate = 5000
|
||||
export let syncQueuedRunsCount: boolean = true
|
||||
|
||||
let mounted: boolean = false
|
||||
let intervalId: NodeJS.Timeout | undefined
|
||||
let sync = true
|
||||
|
||||
// This reactive statement is used to sync the url with the current state of the filters
|
||||
$: if (synUrl) {
|
||||
let searchParams = new URLSearchParams()
|
||||
|
||||
user && searchParams.set('user', user)
|
||||
folder && searchParams.set('folder', folder)
|
||||
|
||||
if (success !== undefined) {
|
||||
searchParams.set('success', success.toString())
|
||||
}
|
||||
|
||||
if (isSkipped) {
|
||||
searchParams.set('is_skipped', isSkipped.toString())
|
||||
}
|
||||
|
||||
if (hideSchedules) {
|
||||
searchParams.set('hide_scheduled', hideSchedules.toString())
|
||||
}
|
||||
|
||||
// ArgFilter is an object. Encode it to a string
|
||||
argFilter && searchParams.set('arg', encodeURIComponent(JSON.stringify(argFilter)))
|
||||
resultFilter && searchParams.set('result', encodeURIComponent(JSON.stringify(resultFilter)))
|
||||
schedulePath && searchParams.set('schedule_path', schedulePath)
|
||||
|
||||
jobKindsCat != 'runs' && searchParams.set('job_kinds', jobKindsCat)
|
||||
|
||||
minTs && searchParams.set('min_ts', minTs)
|
||||
maxTs && searchParams.set('max_ts', maxTs)
|
||||
|
||||
let newPath = path ? `/${path}` : '/'
|
||||
let newUrl = `/runs${newPath}?${searchParams.toString()}`
|
||||
|
||||
goto(newUrl)
|
||||
}
|
||||
|
||||
$: jobKinds = computeJobKinds(jobKindsCat)
|
||||
$: ($workspaceStore && loadJobs()) ||
|
||||
(path && success && isSkipped && jobKinds && user && folder && minTs && maxTs && hideSchedules)
|
||||
|
||||
$: if (mounted && !intervalId && autoRefresh) {
|
||||
intervalId = setInterval(syncer, refreshRate)
|
||||
}
|
||||
|
||||
$: if (mounted && intervalId && !autoRefresh) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = undefined
|
||||
}
|
||||
|
||||
function computeJobKinds(jobKindsCat: string | undefined): string {
|
||||
if (jobKindsCat == 'all') {
|
||||
return `${CompletedJob.job_kind.SCRIPT},${CompletedJob.job_kind.FLOW},${CompletedJob.job_kind.DEPENDENCIES},${CompletedJob.job_kind.FLOWDEPENDENCIES},${CompletedJob.job_kind.APPDEPENDENCIES},${CompletedJob.job_kind.PREVIEW},${CompletedJob.job_kind.FLOWPREVIEW},${CompletedJob.job_kind.SCRIPT_HUB},${CompletedJob.job_kind.DEPLOYMENTCALLBACK},${CompletedJob.job_kind.SINGLESCRIPTFLOW}`
|
||||
} else if (jobKindsCat == 'dependencies') {
|
||||
return `${CompletedJob.job_kind.DEPENDENCIES},${CompletedJob.job_kind.FLOWDEPENDENCIES},${CompletedJob.job_kind.APPDEPENDENCIES}`
|
||||
} else if (jobKindsCat == 'previews') {
|
||||
return `${CompletedJob.job_kind.PREVIEW},${CompletedJob.job_kind.FLOWPREVIEW}`
|
||||
} else if (jobKindsCat == 'deploymentcallbacks') {
|
||||
return `${CompletedJob.job_kind.DEPLOYMENTCALLBACK}`
|
||||
} else {
|
||||
return `${CompletedJob.job_kind.SCRIPT},${CompletedJob.job_kind.FLOW},${CompletedJob.job_kind.SINGLESCRIPTFLOW}`
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJobs(
|
||||
startedBefore: string | undefined,
|
||||
startedAfter: string | undefined
|
||||
): Promise<Job[]> {
|
||||
return JobService.listJobs({
|
||||
workspace: $workspaceStore!,
|
||||
createdOrStartedBefore: startedBefore,
|
||||
createdOrStartedAfter: startedAfter,
|
||||
schedulePath,
|
||||
scriptPathExact: path === null || path === '' ? undefined : path,
|
||||
createdBy: user === null || user === '' ? undefined : user,
|
||||
scriptPathStart: folder === null || folder === '' ? undefined : `f/${folder}/`,
|
||||
jobKinds,
|
||||
success: success == 'success' ? true : success == 'failure' ? false : undefined,
|
||||
running: success == 'running' ? true : undefined,
|
||||
isSkipped,
|
||||
isFlowStep: jobKindsCat != 'all' ? false : undefined,
|
||||
args:
|
||||
argFilter && argFilter != '{}' && argFilter != '' && argError == '' ? argFilter : undefined,
|
||||
result:
|
||||
resultFilter && resultFilter != '{}' && resultFilter != '' && resultError == ''
|
||||
? resultFilter
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
|
||||
export async function loadJobs(shouldGetCount?: boolean): Promise<void> {
|
||||
if (shouldGetCount) {
|
||||
getCount()
|
||||
}
|
||||
|
||||
loading = true
|
||||
try {
|
||||
jobs = await fetchJobs(maxTs, minTs)
|
||||
|
||||
computeCompletedJobs()
|
||||
|
||||
if (hideSchedules && !schedulePath) {
|
||||
jobs = jobs.filter(
|
||||
(job) => !(job && 'running' in job && job.scheduled_for && forLater(job.scheduled_for))
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
sendUserToast(`There was a problem fetching jobs: ${err}`, true)
|
||||
console.error(JSON.stringify(err))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function getCount() {
|
||||
const qc = (await JobService.getQueueCount({ workspace: $workspaceStore! })).database_length
|
||||
if (queue_count) {
|
||||
queue_count.set(qc)
|
||||
} else {
|
||||
queue_count = tweened(qc, { duration: 1000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function syncer() {
|
||||
if (syncQueuedRunsCount) {
|
||||
getCount()
|
||||
}
|
||||
|
||||
if (sync && jobs && maxTs == undefined) {
|
||||
if (success == 'running') {
|
||||
loadJobs()
|
||||
} else {
|
||||
let ts: string | undefined = undefined
|
||||
let cursor = 0
|
||||
|
||||
while (cursor < jobs.length && minTs == undefined) {
|
||||
let invCursor = jobs.length - 1 - cursor
|
||||
let isQueuedJob =
|
||||
cursor == jobs?.length - 1 || jobs[invCursor].type == Job.type.QUEUED_JOB
|
||||
if (isQueuedJob) {
|
||||
if (cursor > 0) {
|
||||
const date = new Date(jobs[invCursor + 1]?.created_at!)
|
||||
date.setMilliseconds(date.getMilliseconds() + 1)
|
||||
ts = date.toISOString()
|
||||
}
|
||||
break
|
||||
}
|
||||
cursor++
|
||||
}
|
||||
|
||||
loading = true
|
||||
|
||||
const newJobs = await fetchJobs(maxTs, minTs ?? ts)
|
||||
if (newJobs && newJobs.length > 0 && jobs) {
|
||||
const oldJobs = jobs?.map((x) => x.id)
|
||||
jobs = newJobs.filter((x) => !oldJobs.includes(x.id)).concat(jobs)
|
||||
newJobs
|
||||
.filter((x) => oldJobs.includes(x.id))
|
||||
.forEach((x) => (jobs![jobs?.findIndex((y) => y.id == x.id)!] = x))
|
||||
jobs = jobs
|
||||
computeCompletedJobs()
|
||||
|
||||
if (hideSchedules && !schedulePath) {
|
||||
jobs = jobs.filter(
|
||||
(job) =>
|
||||
!(job && 'running' in job && job.scheduled_for && forLater(job.scheduled_for))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateFiltersFromURL() {
|
||||
path = $page.params.path
|
||||
user = $page.url.searchParams.get('user')
|
||||
folder = $page.url.searchParams.get('folder')
|
||||
success = ($page.url.searchParams.get('success') ?? undefined) as
|
||||
| 'success'
|
||||
| 'failure'
|
||||
| 'running'
|
||||
| undefined
|
||||
isSkipped =
|
||||
$page.url.searchParams.get('is_skipped') != undefined
|
||||
? $page.url.searchParams.get('is_skipped') == 'true'
|
||||
: false
|
||||
|
||||
hideSchedules =
|
||||
$page.url.searchParams.get('hide_scheduled') != undefined
|
||||
? $page.url.searchParams.get('hide_scheduled') == 'true'
|
||||
: false
|
||||
|
||||
argFilter = $page.url.searchParams.get('arg')
|
||||
? JSON.parse(decodeURIComponent($page.url.searchParams.get('arg') ?? '{}'))
|
||||
: undefined
|
||||
resultFilter = $page.url.searchParams.get('result')
|
||||
? JSON.parse(decodeURIComponent($page.url.searchParams.get('result') ?? '{}'))
|
||||
: undefined
|
||||
|
||||
schedulePath = $page.url.searchParams.get('schedule_path') ?? undefined
|
||||
jobKindsCat = $page.url.searchParams.get('job_kinds') ?? 'runs'
|
||||
|
||||
// Handled on the main page
|
||||
minTs = $page.url.searchParams.get('min_ts') ?? undefined
|
||||
}
|
||||
|
||||
async function loadUsernames(): Promise<void> {
|
||||
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
|
||||
}
|
||||
|
||||
async function loadFolders(): Promise<void> {
|
||||
folders = await FolderService.listFolders({
|
||||
workspace: $workspaceStore!
|
||||
}).then((x) => x.map((y) => y.name))
|
||||
}
|
||||
|
||||
async function loadPaths() {
|
||||
const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $workspaceStore ?? '' })
|
||||
const npaths_flows = await FlowService.listFlowPaths({ workspace: $workspaceStore ?? '' })
|
||||
paths = npaths_scripts.concat(npaths_flows).sort()
|
||||
}
|
||||
|
||||
function computeCompletedJobs() {
|
||||
completedJobs =
|
||||
jobs?.filter((x) => x.type == 'CompletedJob').map((x) => x as CompletedJob) ?? []
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true
|
||||
loadPaths()
|
||||
loadUsernames()
|
||||
loadFolders()
|
||||
|
||||
intervalId = setInterval(syncer, refreshRate)
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
sync = false
|
||||
} else {
|
||||
sync = true
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('popstate', updateFiltersFromURL)
|
||||
return () => {
|
||||
window.removeEventListener('popstate', updateFiltersFromURL)
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,96 @@
|
||||
<script lang="ts">
|
||||
import { RefreshCw } from 'lucide-svelte'
|
||||
import { Button } from '../common'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let minTs: string | undefined
|
||||
export let maxTs: string | undefined
|
||||
export let loading: boolean = false
|
||||
export let selectedManualDate = 0
|
||||
|
||||
const manualDates = [
|
||||
{
|
||||
label: 'Last 1000 runs',
|
||||
setMinMax: () => {
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Within 30 seconds',
|
||||
setMinMax: () => {
|
||||
minTs = new Date(new Date().getTime() - 30 * 1000).toISOString()
|
||||
maxTs = new Date().toISOString()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Within last minute',
|
||||
setMinMax: () => {
|
||||
minTs = new Date(new Date().getTime() - 60 * 1000).toISOString()
|
||||
maxTs = new Date().toISOString()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Within last 5 minutes',
|
||||
setMinMax: () => {
|
||||
minTs = new Date(new Date().getTime() - 5 * 60 * 1000).toISOString()
|
||||
maxTs = new Date().toISOString()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Within last 30 minutes',
|
||||
setMinMax: () => {
|
||||
minTs = new Date(new Date().getTime() - 30 * 60 * 1000).toISOString()
|
||||
maxTs = new Date().toISOString()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Within last 24 hours',
|
||||
setMinMax: () => {
|
||||
minTs = new Date(new Date().getTime() - 24 * 60 * 60 * 1000).toISOString()
|
||||
maxTs = new Date().toISOString()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Within last 7 days',
|
||||
setMinMax: () => {
|
||||
minTs = new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||
maxTs = new Date().toISOString()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Within last month',
|
||||
setMinMax: () => {
|
||||
minTs = new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
maxTs = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
</script>
|
||||
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
wrapperClasses="border rounded-md"
|
||||
on:click={() => {
|
||||
manualDates[selectedManualDate].setMinMax()
|
||||
dispatch('loadJobs')
|
||||
}}
|
||||
dropdownItems={[
|
||||
...manualDates.map((d, i) => ({
|
||||
label: d.label,
|
||||
onClick: () => {
|
||||
selectedManualDate = i
|
||||
d.setMinMax()
|
||||
dispatch('loadJobs')
|
||||
}
|
||||
}))
|
||||
]}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<RefreshCw size={14} class={loading ? 'animate-spin' : ''} />
|
||||
{manualDates[selectedManualDate].label}
|
||||
</div>
|
||||
</Button>
|
||||
@@ -1,14 +1,5 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte'
|
||||
import {
|
||||
JobService,
|
||||
Job,
|
||||
CompletedJob,
|
||||
ScriptService,
|
||||
FlowService,
|
||||
UserService,
|
||||
FolderService
|
||||
} from '$lib/gen'
|
||||
import { JobService, Job, CompletedJob } from '$lib/gen'
|
||||
|
||||
import { page } from '$app/stores'
|
||||
import { sendUserToast } from '$lib/toast'
|
||||
@@ -18,7 +9,6 @@
|
||||
|
||||
import JobPreview from '$lib/components/runs/JobPreview.svelte'
|
||||
import Tooltip from '$lib/components/Tooltip.svelte'
|
||||
import { RefreshCw } from 'lucide-svelte'
|
||||
import CalendarPicker from '$lib/components/common/calendarPicker/CalendarPicker.svelte'
|
||||
|
||||
import RunsTable from '$lib/components/runs/RunsTable.svelte'
|
||||
@@ -27,14 +17,13 @@
|
||||
import RunsFilter from '$lib/components/runs/RunsFilter.svelte'
|
||||
import Toggle from '$lib/components/Toggle.svelte'
|
||||
import ConfirmationModal from '$lib/components/common/confirmationModal/ConfirmationModal.svelte'
|
||||
import { tweened, type Tweened } from 'svelte/motion'
|
||||
import { goto } from '$app/navigation'
|
||||
import type { Tweened } from 'svelte/motion'
|
||||
import RunsQueue from '$lib/components/runs/RunsQueue.svelte'
|
||||
import { forLater } from '$lib/forLater'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import ManuelDatePicker from '$lib/components/runs/ManuelDatePicker.svelte'
|
||||
import JobLoader from '$lib/components/runs/JobLoader.svelte'
|
||||
|
||||
let jobs: Job[] | undefined
|
||||
let intervalId: NodeJS.Timeout | undefined
|
||||
let selectedId: string | undefined = undefined
|
||||
|
||||
// All Filters
|
||||
@@ -63,350 +52,76 @@
|
||||
? JSON.parse(decodeURIComponent($page.url.searchParams.get('result') ?? '{}'))
|
||||
: undefined
|
||||
|
||||
let schedulePath = $page.url.searchParams.get('schedule_path') ?? undefined
|
||||
let jobKindsCat = $page.url.searchParams.get('job_kinds') ?? 'runs'
|
||||
|
||||
// Handled on the main page
|
||||
let minTs = $page.url.searchParams.get('min_ts') ?? undefined
|
||||
let maxTs = $page.url.searchParams.get('max_ts') ?? undefined
|
||||
|
||||
// This reactive statement is used to sync the url with the current state of the filters
|
||||
$: {
|
||||
let searchParams = new URLSearchParams()
|
||||
|
||||
user && searchParams.set('user', user)
|
||||
folder && searchParams.set('folder', folder)
|
||||
|
||||
if (success !== undefined) {
|
||||
searchParams.set('success', success.toString())
|
||||
}
|
||||
|
||||
if (isSkipped) {
|
||||
searchParams.set('is_skipped', isSkipped.toString())
|
||||
}
|
||||
|
||||
if (hideSchedules) {
|
||||
searchParams.set('hide_scheduled', hideSchedules.toString())
|
||||
}
|
||||
|
||||
// ArgFilter is an object. Encode it to a string
|
||||
argFilter && searchParams.set('arg', encodeURIComponent(JSON.stringify(argFilter)))
|
||||
resultFilter && searchParams.set('result', encodeURIComponent(JSON.stringify(resultFilter)))
|
||||
schedulePath && searchParams.set('schedule_path', schedulePath)
|
||||
|
||||
jobKindsCat != 'runs' && searchParams.set('job_kinds', jobKindsCat)
|
||||
|
||||
minTs && searchParams.set('min_ts', minTs)
|
||||
maxTs && searchParams.set('max_ts', maxTs)
|
||||
|
||||
let newPath = path ? `/${path}` : '/'
|
||||
let newUrl = `/runs${newPath}?${searchParams.toString()}`
|
||||
|
||||
goto(newUrl)
|
||||
}
|
||||
|
||||
let schedulePath = $page.url.searchParams.get('schedule_path') ?? undefined
|
||||
let jobKindsCat = $page.url.searchParams.get('job_kinds') ?? 'runs'
|
||||
let queue_count: Tweened<number> | undefined = undefined
|
||||
|
||||
$: jobKinds = computeJobKinds(jobKindsCat)
|
||||
|
||||
function computeJobKinds(jobKindsCat: string | undefined): string {
|
||||
if (jobKindsCat == 'all') {
|
||||
return `${CompletedJob.job_kind.SCRIPT},${CompletedJob.job_kind.FLOW},${CompletedJob.job_kind.DEPENDENCIES},${CompletedJob.job_kind.FLOWDEPENDENCIES},${CompletedJob.job_kind.APPDEPENDENCIES},${CompletedJob.job_kind.PREVIEW},${CompletedJob.job_kind.FLOWPREVIEW},${CompletedJob.job_kind.SCRIPT_HUB},${CompletedJob.job_kind.DEPLOYMENTCALLBACK},${CompletedJob.job_kind.SINGLESCRIPTFLOW}`
|
||||
} else if (jobKindsCat == 'dependencies') {
|
||||
return `${CompletedJob.job_kind.DEPENDENCIES},${CompletedJob.job_kind.FLOWDEPENDENCIES},${CompletedJob.job_kind.APPDEPENDENCIES}`
|
||||
} else if (jobKindsCat == 'previews') {
|
||||
return `${CompletedJob.job_kind.PREVIEW},${CompletedJob.job_kind.FLOWPREVIEW}`
|
||||
} else if (jobKindsCat == 'deploymentcallbacks') {
|
||||
return `${CompletedJob.job_kind.DEPLOYMENTCALLBACK}`
|
||||
} else {
|
||||
return `${CompletedJob.job_kind.SCRIPT},${CompletedJob.job_kind.FLOW},${CompletedJob.job_kind.SINGLESCRIPTFLOW}`
|
||||
}
|
||||
}
|
||||
|
||||
$: ($workspaceStore && loadJobs()) ||
|
||||
(path && success && isSkipped && jobKinds && user && folder && minTs && maxTs && hideSchedules)
|
||||
|
||||
async function fetchJobs(
|
||||
startedBefore: string | undefined,
|
||||
startedAfter: string | undefined
|
||||
): Promise<Job[]> {
|
||||
return JobService.listJobs({
|
||||
workspace: $workspaceStore!,
|
||||
createdOrStartedBefore: startedBefore,
|
||||
createdOrStartedAfter: startedAfter,
|
||||
schedulePath,
|
||||
scriptPathExact: path === null || path === '' ? undefined : path,
|
||||
createdBy: user === null || user === '' ? undefined : user,
|
||||
scriptPathStart: folder === null || folder === '' ? undefined : `f/${folder}/`,
|
||||
jobKinds,
|
||||
success: success == 'success' ? true : success == 'failure' ? false : undefined,
|
||||
running: success == 'running' ? true : undefined,
|
||||
isSkipped,
|
||||
isFlowStep: jobKindsCat != 'all' ? false : undefined,
|
||||
args:
|
||||
argFilter && argFilter != '{}' && argFilter != '' && argError == '' ? argFilter : undefined,
|
||||
result:
|
||||
resultFilter && resultFilter != '{}' && resultFilter != '' && resultError == ''
|
||||
? resultFilter
|
||||
: undefined
|
||||
})
|
||||
}
|
||||
|
||||
let jobKinds: string | undefined = undefined
|
||||
let loading: boolean = false
|
||||
async function loadJobs(): Promise<void> {
|
||||
loading = true
|
||||
getCount()
|
||||
try {
|
||||
jobs = await fetchJobs(maxTs, minTs)
|
||||
|
||||
computeCompletedJobs()
|
||||
|
||||
if (hideSchedules && !schedulePath) {
|
||||
jobs = jobs.filter(
|
||||
(job) => !(job && 'running' in job && job.scheduled_for && forLater(job.scheduled_for))
|
||||
)
|
||||
}
|
||||
} catch (err) {
|
||||
sendUserToast(`There was a problem fetching jobs: ${err}`, true)
|
||||
console.error(JSON.stringify(err))
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
|
||||
async function getCount() {
|
||||
const qc = (await JobService.getQueueCount({ workspace: $workspaceStore! })).database_length
|
||||
if (queue_count) {
|
||||
queue_count.set(qc)
|
||||
} else {
|
||||
queue_count = tweened(qc, { duration: 1000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function syncer() {
|
||||
getCount()
|
||||
if (sync && jobs && maxTs == undefined) {
|
||||
if (success == 'running') {
|
||||
loadJobs()
|
||||
} else {
|
||||
let ts: string | undefined = undefined
|
||||
let cursor = 0
|
||||
while (cursor < jobs.length && minTs == undefined) {
|
||||
let invCursor = jobs.length - 1 - cursor
|
||||
let isQueuedJob =
|
||||
cursor == jobs?.length - 1 || jobs[invCursor].type == Job.type.QUEUED_JOB
|
||||
if (isQueuedJob) {
|
||||
if (cursor > 0) {
|
||||
const date = new Date(jobs[invCursor + 1]?.created_at!)
|
||||
date.setMilliseconds(date.getMilliseconds() + 1)
|
||||
ts = date.toISOString()
|
||||
}
|
||||
break
|
||||
}
|
||||
cursor++
|
||||
}
|
||||
|
||||
loading = true
|
||||
const newJobs = await fetchJobs(maxTs, minTs ?? ts)
|
||||
if (newJobs && newJobs.length > 0 && jobs) {
|
||||
const oldJobs = jobs?.map((x) => x.id)
|
||||
jobs = newJobs.filter((x) => !oldJobs.includes(x.id)).concat(jobs)
|
||||
newJobs
|
||||
.filter((x) => oldJobs.includes(x.id))
|
||||
.forEach((x) => (jobs![jobs?.findIndex((y) => y.id == x.id)!] = x))
|
||||
jobs = jobs
|
||||
computeCompletedJobs()
|
||||
|
||||
if (hideSchedules && !schedulePath) {
|
||||
jobs = jobs.filter(
|
||||
(job) =>
|
||||
!(job && 'running' in job && job.scheduled_for && forLater(job.scheduled_for))
|
||||
)
|
||||
}
|
||||
}
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sync = true
|
||||
let mounted: boolean = false
|
||||
|
||||
function updateFiltersFromURL() {
|
||||
path = $page.params.path
|
||||
user = $page.url.searchParams.get('user')
|
||||
folder = $page.url.searchParams.get('folder')
|
||||
success = ($page.url.searchParams.get('success') ?? undefined) as
|
||||
| 'success'
|
||||
| 'failure'
|
||||
| 'running'
|
||||
| undefined
|
||||
isSkipped =
|
||||
$page.url.searchParams.get('is_skipped') != undefined
|
||||
? $page.url.searchParams.get('is_skipped') == 'true'
|
||||
: false
|
||||
|
||||
hideSchedules =
|
||||
$page.url.searchParams.get('hide_scheduled') != undefined
|
||||
? $page.url.searchParams.get('hide_scheduled') == 'true'
|
||||
: false
|
||||
|
||||
argFilter = $page.url.searchParams.get('arg')
|
||||
? JSON.parse(decodeURIComponent($page.url.searchParams.get('arg') ?? '{}'))
|
||||
: undefined
|
||||
resultFilter = $page.url.searchParams.get('result')
|
||||
? JSON.parse(decodeURIComponent($page.url.searchParams.get('result') ?? '{}'))
|
||||
: undefined
|
||||
|
||||
schedulePath = $page.url.searchParams.get('schedule_path') ?? undefined
|
||||
jobKindsCat = $page.url.searchParams.get('job_kinds') ?? 'runs'
|
||||
|
||||
// Handled on the main page
|
||||
minTs = $page.url.searchParams.get('min_ts') ?? undefined
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true
|
||||
loadPaths()
|
||||
loadUsernames()
|
||||
loadFolders()
|
||||
intervalId = setInterval(syncer, 5000)
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
sync = false
|
||||
} else {
|
||||
sync = true
|
||||
}
|
||||
})
|
||||
|
||||
window.addEventListener('popstate', updateFiltersFromURL)
|
||||
return () => {
|
||||
window.removeEventListener('popstate', updateFiltersFromURL)
|
||||
}
|
||||
})
|
||||
|
||||
onDestroy(() => {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
}
|
||||
})
|
||||
|
||||
$: if (mounted && !intervalId && autoRefresh) {
|
||||
intervalId = setInterval(syncer, 5000)
|
||||
}
|
||||
|
||||
$: if (mounted && intervalId && !autoRefresh) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = undefined
|
||||
}
|
||||
|
||||
let paths: string[] = []
|
||||
let usernames: string[] = []
|
||||
let folders: string[] = []
|
||||
|
||||
async function loadUsernames(): Promise<void> {
|
||||
usernames = await UserService.listUsernames({ workspace: $workspaceStore! })
|
||||
}
|
||||
|
||||
async function loadFolders(): Promise<void> {
|
||||
folders = await FolderService.listFolders({
|
||||
workspace: $workspaceStore!
|
||||
}).then((x) => x.map((y) => y.name))
|
||||
}
|
||||
|
||||
async function loadPaths() {
|
||||
const npaths_scripts = await ScriptService.listScriptPaths({ workspace: $workspaceStore ?? '' })
|
||||
const npaths_flows = await FlowService.listFlowPaths({ workspace: $workspaceStore ?? '' })
|
||||
paths = npaths_scripts.concat(npaths_flows).sort()
|
||||
}
|
||||
|
||||
let completedJobs: CompletedJob[] | undefined = undefined
|
||||
|
||||
function computeCompletedJobs() {
|
||||
completedJobs =
|
||||
jobs?.filter((x) => x.type == 'CompletedJob').map((x) => x as CompletedJob) ?? []
|
||||
}
|
||||
|
||||
let argError = ''
|
||||
let resultError = ''
|
||||
let filterTimeout: NodeJS.Timeout | undefined = undefined
|
||||
let selectedManualDate = 0
|
||||
let autoRefresh: boolean = true
|
||||
let runDrawer: Drawer
|
||||
let cancelAllJobs = false
|
||||
let innerWidth = window.innerWidth
|
||||
let jobLoader: JobLoader | undefined = undefined
|
||||
|
||||
function reloadLogsWithoutFilterError() {
|
||||
if (resultError == '' && argError == '') {
|
||||
filterTimeout && clearTimeout(filterTimeout)
|
||||
filterTimeout = setTimeout(() => {
|
||||
loadJobs()
|
||||
jobLoader?.loadJobs(true)
|
||||
}, 2000)
|
||||
}
|
||||
}
|
||||
|
||||
const manualDates = [
|
||||
{
|
||||
label: 'Last 1000 runs',
|
||||
setMinMax: () => {
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Within 30 seconds',
|
||||
setMinMax: () => {
|
||||
minTs = new Date(new Date().getTime() - 30 * 1000).toISOString()
|
||||
maxTs = new Date().toISOString()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Within last minute',
|
||||
setMinMax: () => {
|
||||
minTs = new Date(new Date().getTime() - 60 * 1000).toISOString()
|
||||
maxTs = new Date().toISOString()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Within last 5 minutes',
|
||||
setMinMax: () => {
|
||||
minTs = new Date(new Date().getTime() - 5 * 60 * 1000).toISOString()
|
||||
maxTs = new Date().toISOString()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Within last 30 minutes',
|
||||
setMinMax: () => {
|
||||
minTs = new Date(new Date().getTime() - 30 * 60 * 1000).toISOString()
|
||||
maxTs = new Date().toISOString()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Within last 24 hours',
|
||||
setMinMax: () => {
|
||||
minTs = new Date(new Date().getTime() - 24 * 60 * 60 * 1000).toISOString()
|
||||
maxTs = new Date().toISOString()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Within last 7 days',
|
||||
setMinMax: () => {
|
||||
minTs = new Date(new Date().getTime() - 7 * 24 * 60 * 60 * 1000).toISOString()
|
||||
maxTs = new Date().toISOString()
|
||||
}
|
||||
},
|
||||
{
|
||||
label: 'Within last month',
|
||||
setMinMax: () => {
|
||||
minTs = new Date(new Date().getTime() - 30 * 24 * 60 * 60 * 1000).toISOString()
|
||||
maxTs = new Date().toISOString()
|
||||
}
|
||||
}
|
||||
]
|
||||
function reset() {
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
|
||||
let selectedManualDate = 0
|
||||
let autoRefresh: boolean = true
|
||||
let runDrawer: Drawer
|
||||
let cancelAllJobs = false
|
||||
|
||||
let innerWidth = window.innerWidth
|
||||
autoRefresh = true
|
||||
jobs = undefined
|
||||
completedJobs = undefined
|
||||
selectedManualDate = 0
|
||||
selectedId = undefined
|
||||
jobLoader?.loadJobs(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<JobLoader
|
||||
bind:jobs
|
||||
bind:user
|
||||
bind:folder
|
||||
bind:path
|
||||
bind:success
|
||||
bind:isSkipped
|
||||
bind:argFilter
|
||||
bind:resultFilter
|
||||
bind:schedulePath
|
||||
bind:jobKindsCat
|
||||
bind:minTs
|
||||
bind:maxTs
|
||||
bind:jobKinds
|
||||
bind:queue_count
|
||||
bind:autoRefresh
|
||||
bind:paths
|
||||
bind:usernames
|
||||
bind:folders
|
||||
bind:completedJobs
|
||||
bind:argError
|
||||
bind:resultError
|
||||
bind:loading
|
||||
bind:this={jobLoader}
|
||||
/>
|
||||
|
||||
<ConfirmationModal
|
||||
title="Confirm cancelling all jobs"
|
||||
confirmationText="Cancel all jobs"
|
||||
@@ -414,7 +129,7 @@
|
||||
on:confirmed={async () => {
|
||||
cancelAllJobs = false
|
||||
let uuids = await JobService.cancelAll({ workspace: $workspaceStore ?? '' })
|
||||
loadJobs()
|
||||
jobLoader?.loadJobs(true)
|
||||
sendUserToast(`Canceled ${uuids.length} jobs`)
|
||||
}}
|
||||
on:canceled={() => {
|
||||
@@ -484,17 +199,15 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 md:flex-row w-full p-4">
|
||||
<div class="flex gap-2 grow mb-2">
|
||||
<div class="flex gap-2 grow flex-row">
|
||||
<RunsQueue {queue_count} />
|
||||
<div class="flex"
|
||||
><Button
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="contained"
|
||||
title="Require to be an admin. Cancel all jobs in queue"
|
||||
disabled={!$userStore?.is_admin && !$superadmin}
|
||||
on:click={async () => (cancelAllJobs = true)}>Cancel All</Button
|
||||
></div
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="contained"
|
||||
title="Require to be an admin. Cancel all jobs in queue"
|
||||
disabled={!$userStore?.is_admin && !$superadmin}
|
||||
on:click={async () => (cancelAllJobs = true)}>Cancel All</Button
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-row gap-1 w-full max-w-xl">
|
||||
@@ -532,47 +245,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
|
||||
autoRefresh = true
|
||||
jobs = undefined
|
||||
completedJobs = undefined
|
||||
selectedManualDate = 0
|
||||
selectedId = undefined
|
||||
loadJobs()
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
wrapperClasses="border rounded-md"
|
||||
on:click={() => {
|
||||
manualDates[selectedManualDate].setMinMax()
|
||||
loadJobs()
|
||||
}}
|
||||
dropdownItems={[
|
||||
...manualDates.map((d, i) => ({
|
||||
label: d.label,
|
||||
onClick: () => {
|
||||
selectedManualDate = i
|
||||
d.setMinMax()
|
||||
loadJobs()
|
||||
}
|
||||
}))
|
||||
]}
|
||||
startIcon={{ icon: RefreshCw, classes: loading ? 'animate-spin' : '' }}
|
||||
>
|
||||
{manualDates[selectedManualDate].label}
|
||||
</Button>
|
||||
|
||||
<Button size="xs" color="light" variant="border" on:click={reset}>Reset</Button>
|
||||
<ManuelDatePicker bind:minTs bind:maxTs bind:selectedManualDate {loading} />
|
||||
<Toggle
|
||||
size="xs"
|
||||
bind:checked={autoRefresh}
|
||||
@@ -671,19 +345,17 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 md:flex-row w-full p-4">
|
||||
<div class="flex gap-2 grow mb-2">
|
||||
<div class="flex items-center flex-row gap-2 grow mb-4">
|
||||
{#if queue_count}
|
||||
<RunsQueue {queue_count} />
|
||||
{/if}
|
||||
<div class="flex"
|
||||
><Button
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="contained"
|
||||
title="Require to be an admin. Cancel all jobs in queue"
|
||||
disabled={!$userStore?.is_admin && !$superadmin}
|
||||
on:click={async () => (cancelAllJobs = true)}>Cancel All</Button
|
||||
></div
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="contained"
|
||||
title="Require to be an admin. Cancel all jobs in queue"
|
||||
disabled={!$userStore?.is_admin && !$superadmin}
|
||||
on:click={async () => (cancelAllJobs = true)}>Cancel All</Button
|
||||
>
|
||||
</div>
|
||||
<div class="flex flex-row gap-1 w-full max-w-xl">
|
||||
@@ -721,48 +393,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-row gap-2 items-center">
|
||||
<Button
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
on:click={() => {
|
||||
minTs = undefined
|
||||
maxTs = undefined
|
||||
|
||||
autoRefresh = true
|
||||
jobs = undefined
|
||||
completedJobs = undefined
|
||||
selectedManualDate = 0
|
||||
selectedId = undefined
|
||||
loadJobs()
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
<Button
|
||||
color="light"
|
||||
size="xs"
|
||||
wrapperClasses="border rounded-md"
|
||||
on:click={() => {
|
||||
manualDates[selectedManualDate].setMinMax()
|
||||
loadJobs()
|
||||
}}
|
||||
dropdownItems={[
|
||||
...manualDates.map((d, i) => ({
|
||||
label: d.label,
|
||||
onClick: () => {
|
||||
selectedManualDate = i
|
||||
d.setMinMax()
|
||||
loadJobs()
|
||||
}
|
||||
}))
|
||||
]}
|
||||
>
|
||||
<div class="flex flex-row items-center gap-2">
|
||||
<RefreshCw size={14} class={loading ? 'animate-spin' : ''} />
|
||||
{manualDates[selectedManualDate].label}
|
||||
</div>
|
||||
</Button>
|
||||
<Button size="xs" color="light" variant="border" on:click={reset}>Reset</Button>
|
||||
<ManuelDatePicker bind:minTs bind:maxTs bind:selectedManualDate {loading} />
|
||||
|
||||
<Toggle
|
||||
size="xs"
|
||||
|
||||
Reference in New Issue
Block a user