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:
Faton Ramadani
2024-01-16 18:38:54 +01:00
committed by GitHub
parent daaeb83002
commit 8cd8902121
4 changed files with 550 additions and 459 deletions
+78 -20
View File
@@ -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"