feat: add timeline progress bars to flows" (#2464)
* add timeline * progress * all * all
This commit is contained in:
@@ -143,10 +143,10 @@
|
||||
}))
|
||||
} catch {}
|
||||
}}
|
||||
size="md"
|
||||
size="sm"
|
||||
btnClasses="w-full max-w-lg"
|
||||
>
|
||||
<Loader2 class="animate-spin mr-2" />
|
||||
<Loader2 size={18} class="animate-spin mr-2" />
|
||||
Cancel
|
||||
</Button>
|
||||
{:else}
|
||||
@@ -199,7 +199,13 @@
|
||||
</div>
|
||||
<div class="pt-4 grow">
|
||||
{#if jobId}
|
||||
<FlowStatusViewer bind:flowState={$flowStateStore} {jobId} bind:job />
|
||||
<FlowStatusViewer
|
||||
{flowStateStore}
|
||||
{jobId}
|
||||
on:jobsLoaded={({ detail }) => {
|
||||
job = detail
|
||||
}}
|
||||
/>
|
||||
{:else}
|
||||
<div class="italic text-tertiary h-full grow"> Flow status will be displayed here </div>
|
||||
{/if}
|
||||
|
||||
@@ -1,596 +1,62 @@
|
||||
<script lang="ts">
|
||||
import { FlowStatusModule, Job, JobService } from '$lib/gen'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
import FlowJobResult from './FlowJobResult.svelte'
|
||||
import FlowPreviewStatus from './preview/FlowPreviewStatus.svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import { faChevronDown, faChevronUp, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { writable, type Writable } from 'svelte/store'
|
||||
import FlowStatusViewerInner from './FlowStatusViewerInner.svelte'
|
||||
import type { FlowState } from './flows/flowState'
|
||||
import { Badge, Button, Tab } from './common'
|
||||
import DisplayResult from './DisplayResult.svelte'
|
||||
import Tabs from './common/tabs/Tabs.svelte'
|
||||
import { FlowGraph, type GraphModuleState } from './graph'
|
||||
import ModuleStatus from './ModuleStatus.svelte'
|
||||
import { emptyString, isOwner, msToSec, pluralize, truncateRev } from '$lib/utils'
|
||||
import JobArgs from './JobArgs.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import FlowStatusWaitingForEvents from './FlowStatusWaitingForEvents.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import FlowTimeline from './FlowTimeline.svelte'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
import { createEventDispatcher, setContext } from 'svelte'
|
||||
import type { FlowStatusViewerContext } from './graph'
|
||||
import { isOwner as loadIsOwner } from '$lib/utils'
|
||||
import { userStore, workspaceStore } from '$lib/stores'
|
||||
|
||||
export let jobId: string
|
||||
export let workspaceId: string | undefined = undefined
|
||||
export let flowState: FlowState | undefined = undefined
|
||||
export let flowJobIds:
|
||||
| {
|
||||
moduleId: string
|
||||
flowJobs: string[]
|
||||
}
|
||||
| undefined = undefined
|
||||
export let job: Job | undefined = undefined
|
||||
export let flowStateStore: Writable<FlowState> | undefined = undefined
|
||||
|
||||
export let flowModuleStates: Record<string, GraphModuleState> = {}
|
||||
export let isOwner = false
|
||||
|
||||
let localFlowModuleStates: Record<string, GraphModuleState> = {}
|
||||
export let retry_status: Record<string, number> = {}
|
||||
export let suspend_status: number | undefined = undefined
|
||||
export let render = true
|
||||
let lastJobId: string = jobId
|
||||
|
||||
export let is_owner = false
|
||||
|
||||
let selectedNode: string | undefined = undefined
|
||||
|
||||
let jobResults: any[] = []
|
||||
let jobFailures: boolean[] = []
|
||||
|
||||
let forloop_selected = ''
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
$: localFlowModuleStates && updateFlowModuleStates()
|
||||
|
||||
function updateFlowModuleStates() {
|
||||
Object.entries(localFlowModuleStates).forEach(([moduleId, state]) => {
|
||||
if (
|
||||
flowModuleStates[moduleId] !== state &&
|
||||
flowModuleStates[moduleId]?.type !== FlowStatusModule.type.FAILURE
|
||||
) {
|
||||
flowModuleStates[moduleId] = state
|
||||
}
|
||||
})
|
||||
}
|
||||
let lastSize = 0
|
||||
$: {
|
||||
let len = (flowJobIds?.flowJobs ?? []).length
|
||||
if (len != lastSize) {
|
||||
updateForloop(len)
|
||||
}
|
||||
}
|
||||
|
||||
function updateForloop(len: number) {
|
||||
forloop_selected = flowJobIds?.flowJobs[len - 1] ?? ''
|
||||
lastSize = len
|
||||
}
|
||||
|
||||
$: updateFailCount(job?.flow_status?.retry?.fail_count)
|
||||
$: suspend_status = job?.flow_status?.modules?.[job?.flow_status.step]?.count
|
||||
|
||||
function updateFailCount(count?: number) {
|
||||
if (count) {
|
||||
retry_status[jobId ?? ''] = count
|
||||
} else {
|
||||
delete retry_status[jobId ?? '']
|
||||
}
|
||||
}
|
||||
|
||||
$: innerModules =
|
||||
job?.flow_status?.modules.concat(
|
||||
job?.flow_status.failure_module.type != 'WaitingForPriorSteps'
|
||||
? job?.flow_status.failure_module
|
||||
: []
|
||||
) ?? []
|
||||
|
||||
$: innerModules && updateInnerModules()
|
||||
|
||||
function updateInnerModules() {
|
||||
if (localFlowModuleStates) {
|
||||
innerModules.forEach((mod, i) => {
|
||||
if (
|
||||
mod.type === FlowStatusModule.type.WAITING_FOR_EVENTS &&
|
||||
localFlowModuleStates?.[innerModules?.[i - 1]?.id ?? '']?.type ==
|
||||
FlowStatusModule.type.SUCCESS
|
||||
) {
|
||||
localFlowModuleStates[mod.id ?? ''] = { type: mod.type, args: job?.args }
|
||||
} else if (
|
||||
mod.type === FlowStatusModule.type.WAITING_FOR_EXECUTOR &&
|
||||
localFlowModuleStates[mod.id ?? '']?.scheduled_for == undefined
|
||||
) {
|
||||
console.debug('updating', mod.job)
|
||||
JobService.getJob({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
id: mod.job ?? ''
|
||||
})
|
||||
.then((job) => {
|
||||
const newState = {
|
||||
type: mod.type,
|
||||
scheduled_for: job?.['scheduled_for'],
|
||||
job_id: job?.id,
|
||||
parent_module: mod['parent_module'],
|
||||
args: job?.args
|
||||
}
|
||||
if (!deepEqual(newState, localFlowModuleStates[mod.id ?? ''])) {
|
||||
localFlowModuleStates[mod.id ?? ''] = newState
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(`Could not load inner module for job ${mod.job}`, e)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let errorCount = 0
|
||||
async function loadJobInProgress() {
|
||||
if (jobId != '00000000-0000-0000-0000-000000000000') {
|
||||
try {
|
||||
const newJob = await JobService.getJob({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
id: jobId ?? ''
|
||||
})
|
||||
if (!deepEqual(job, newJob)) {
|
||||
job = newJob
|
||||
}
|
||||
errorCount = 0
|
||||
} catch (e) {
|
||||
errorCount += 1
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
if (job?.type !== 'CompletedJob' && errorCount < 4) {
|
||||
timeout = setTimeout(() => loadJobInProgress(), 500)
|
||||
}
|
||||
}
|
||||
|
||||
$: job && dispatch('jobsLoaded', job)
|
||||
|
||||
async function updateJobId() {
|
||||
if (jobId !== job?.id) {
|
||||
retry_status = {}
|
||||
flowModuleStates = {}
|
||||
localFlowModuleStates = {}
|
||||
await loadJobInProgress()
|
||||
job?.script_path && loadOwner(job.script_path)
|
||||
}
|
||||
}
|
||||
|
||||
$: jobId && updateJobId()
|
||||
|
||||
$: isListJob = flowJobIds != undefined && Array.isArray(flowJobIds?.flowJobs)
|
||||
|
||||
onDestroy(() => {
|
||||
timeout && clearTimeout(timeout)
|
||||
let flowModuleStates = writable({})
|
||||
let retryStatus = writable({})
|
||||
let suspendStatus = writable({})
|
||||
let durationStatuses = writable({})
|
||||
setContext<FlowStatusViewerContext>('FlowStatusViewer', {
|
||||
flowStateStore,
|
||||
flowModuleStates,
|
||||
retryStatus,
|
||||
suspendStatus,
|
||||
durationStatuses
|
||||
})
|
||||
|
||||
function loadOwner(path: string) {
|
||||
is_owner = isOwner(path, $userStore!, workspaceId ?? $workspaceStore!)
|
||||
isOwner = loadIsOwner(path, $userStore!, workspaceId ?? $workspaceStore!)
|
||||
}
|
||||
|
||||
$: selected = isListJob ? 'sequence' : 'graph'
|
||||
|
||||
function isSuccess(arg: any): boolean | undefined {
|
||||
if (arg == undefined) {
|
||||
return undefined
|
||||
} else {
|
||||
return arg == true
|
||||
async function updateJobId() {
|
||||
if (jobId !== lastJobId) {
|
||||
lastJobId = jobId
|
||||
$flowModuleStates = {}
|
||||
$retryStatus = {}
|
||||
$suspendStatus = {}
|
||||
$durationStatuses = {}
|
||||
}
|
||||
}
|
||||
|
||||
function onJobsLoaded(mod: FlowStatusModule, job: Job): void {
|
||||
if (mod.id && (mod.flow_jobs ?? []).length == 0) {
|
||||
if (flowState && flowState[mod.id]) {
|
||||
flowState[mod.id].previewResult = job['result']
|
||||
flowState[mod.id].previewArgs = job.args
|
||||
}
|
||||
if (job.type == 'QueuedJob') {
|
||||
localFlowModuleStates[mod.id] = {
|
||||
type: FlowStatusModule.type.IN_PROGRESS,
|
||||
logs: job.logs,
|
||||
args: job.args,
|
||||
started_at: job.started_at ? new Date(job.started_at).getTime() : undefined,
|
||||
parent_module: mod['parent_module']
|
||||
}
|
||||
} else {
|
||||
localFlowModuleStates[mod.id] = {
|
||||
args: job.args,
|
||||
type: job['success'] ? FlowStatusModule.type.SUCCESS : FlowStatusModule.type.FAILURE,
|
||||
logs: job.logs,
|
||||
result: job['result'],
|
||||
job_id: job.id,
|
||||
parent_module: mod['parent_module'],
|
||||
duration_ms: job['duration_ms'],
|
||||
started_at: job.started_at ? new Date(job.started_at).getTime() : undefined,
|
||||
iteration_total: mod.iterator?.itered?.length
|
||||
// retries: flowState?.raw_flow
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let showEmbeddeds = -20
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let lastScriptPath: string | undefined = undefined
|
||||
|
||||
$: jobId && updateJobId()
|
||||
</script>
|
||||
|
||||
{#if job}
|
||||
<div class="flow-root w-full space-y-4">
|
||||
{#if innerModules.length > 0}
|
||||
<h3 class="text-md leading-6 font-bold text-primay border-b pb-2">Flow result</h3>
|
||||
{/if}
|
||||
{#if isListJob}
|
||||
{#if (flowJobIds?.flowJobs.length ?? 0) > 20}
|
||||
<p class="text-tertiary italic">
|
||||
For performance reasons, only the last 20 items are shown. <button
|
||||
class="text-primary underline"
|
||||
on:click={() => {
|
||||
showEmbeddeds -= 20
|
||||
}}
|
||||
>Load 20 prior
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
{#if render}
|
||||
<div class="w-full h-full border border-gray-600 bg-surface p-1 overflow-auto">
|
||||
<DisplayResult workspaceId={job?.workspace_id} {jobId} result={jobResults} />
|
||||
</div>
|
||||
{/if}
|
||||
{:else if render}
|
||||
<div class={innerModules.length > 0 ? 'border rounded-md shadow p-2' : ''}>
|
||||
<FlowPreviewStatus {job} />
|
||||
{#if `result` in job}
|
||||
<div class="w-full h-full">
|
||||
<FlowJobResult
|
||||
workspaceId={job?.workspace_id}
|
||||
jobId={job?.id}
|
||||
loading={job['running'] == true}
|
||||
result={job.result}
|
||||
logs={job.logs ?? ''}
|
||||
/>
|
||||
</div>
|
||||
{:else if job.flow_status?.modules?.[job?.flow_status?.step]?.type === FlowStatusModule.type.WAITING_FOR_EVENTS}
|
||||
<FlowStatusWaitingForEvents {workspaceId} {job} {is_owner} />
|
||||
{:else if job.logs}
|
||||
<div class="text-xs p-4 bg-gray-50 overflow-auto max-h-80 border">
|
||||
<pre class="w-full">{job.logs}</pre>
|
||||
</div>
|
||||
{:else if innerModules?.length > 0}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each innerModules as mod, i (mod.id)}
|
||||
{#if mod.type == FlowStatusModule.type.IN_PROGRESS}
|
||||
{@const rawMod = job.raw_flow?.modules[i]}
|
||||
|
||||
<div
|
||||
><span class="inline-flex gap-1"
|
||||
><Badge color="indigo">{mod.id}</Badge>
|
||||
<span class="font-medium text-primary">
|
||||
{#if !emptyString(rawMod?.summary)}
|
||||
{rawMod?.summary ?? ''}
|
||||
{:else if rawMod?.value.type == 'script'}
|
||||
{rawMod.value.path ?? ''}
|
||||
{:else if rawMod?.value.type}
|
||||
{rawMod?.value.type}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<Loader2 class="animate-spin" /></span
|
||||
></div
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if render}
|
||||
{#if innerModules.length > 0 && !isListJob}
|
||||
<Tabs bind:selected>
|
||||
<Tab value="graph"><span class="font-semibold text-md">Graph</span></Tab>
|
||||
<!-- <Tab value="timeline"><span class="font-semibold">Timeline</span></Tab> -->
|
||||
<Tab value="sequence"><span class="font-semibold">Details</span></Tab>
|
||||
</Tabs>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if render && selected == 'timeline'}
|
||||
<FlowTimeline {flowModuleStates} />
|
||||
{/if}
|
||||
<div class={selected != 'sequence' ? 'hidden' : ''}>
|
||||
{#if isListJob}
|
||||
<h3 class="text-md leading-6 font-bold text-tertiary border-b mb-4">
|
||||
Embedded flows: ({flowJobIds?.flowJobs.length} items)
|
||||
</h3>
|
||||
{#if (flowJobIds?.flowJobs.length ?? 0) > 20}
|
||||
<p class="text-tertiary italic">
|
||||
For performance reasons, only the last 20 items are shown. <button
|
||||
class="text-primary underline"
|
||||
on:click={() => {
|
||||
showEmbeddeds -= 20
|
||||
}}
|
||||
>Load 20 prior
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
{#each (flowJobIds?.flowJobs.length ?? 0) > 20 ? flowJobIds?.flowJobs?.slice(showEmbeddeds) ?? [] : flowJobIds?.flowJobs ?? [] as loopJobId, j}
|
||||
{#if render}
|
||||
<Button
|
||||
variant={forloop_selected === loopJobId ? 'contained' : 'border'}
|
||||
color={jobFailures[j] === true
|
||||
? 'red'
|
||||
: forloop_selected === loopJobId
|
||||
? 'dark'
|
||||
: 'light'}
|
||||
btnClasses="w-full flex justify-start"
|
||||
on:click={() => {
|
||||
if (forloop_selected == loopJobId) {
|
||||
forloop_selected = ''
|
||||
} else {
|
||||
forloop_selected = loopJobId
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="truncate">
|
||||
#{(flowJobIds?.flowJobs.length ?? 0) > 20
|
||||
? (flowJobIds?.flowJobs.length ?? 0) + showEmbeddeds + j + 1
|
||||
: j + 1}: {loopJobId}
|
||||
</span>
|
||||
|
||||
<Icon
|
||||
class="ml-2"
|
||||
data={forloop_selected == loopJobId ? faChevronUp : faChevronDown}
|
||||
scale={0.8}
|
||||
/>
|
||||
</Button>
|
||||
{/if}
|
||||
<div class="border p-6" class:hidden={forloop_selected != loopJobId}>
|
||||
<svelte:self
|
||||
render={forloop_selected == loopJobId && selected == 'sequence' && render}
|
||||
{workspaceId}
|
||||
bind:suspend_status
|
||||
bind:retry_status
|
||||
bind:flowState
|
||||
bind:flowModuleStates={localFlowModuleStates}
|
||||
jobId={loopJobId}
|
||||
on:jobsLoaded={(e) => {
|
||||
if (flowJobIds?.moduleId) {
|
||||
if (flowState?.[flowJobIds.moduleId]) {
|
||||
if (
|
||||
!flowState[flowJobIds.moduleId].previewResult ||
|
||||
!Array.isArray(flowState[flowJobIds.moduleId]?.previewResult)
|
||||
) {
|
||||
flowState[flowJobIds.moduleId].previewResult = []
|
||||
}
|
||||
flowState[flowJobIds.moduleId].previewResult[j] = e.detail.result
|
||||
flowState[flowJobIds.moduleId].previewArgs = e.detail.args
|
||||
jobResults[j] =
|
||||
e.detail.type == 'QueuedJob' ? 'Job in progress ...' : e.detail.result
|
||||
jobFailures[j] = e.detail.success === false
|
||||
}
|
||||
if (e.detail.type == 'QueuedJob') {
|
||||
localFlowModuleStates[flowJobIds.moduleId] = {
|
||||
type: FlowStatusModule.type.IN_PROGRESS,
|
||||
started_at: e.detail.started_at
|
||||
? new Date(e.detail.started_at).getTime()
|
||||
: undefined,
|
||||
logs: e.detail.logs,
|
||||
job_id: e.detail.id,
|
||||
args: e.detail.args,
|
||||
iteration_total: flowJobIds?.flowJobs.length
|
||||
}
|
||||
} else {
|
||||
localFlowModuleStates[flowJobIds.moduleId] = {
|
||||
started_at: e.detail.started_at
|
||||
? new Date(e.detail.started_at).getTime()
|
||||
: undefined,
|
||||
args: e.detail.args,
|
||||
type: e.detail.success
|
||||
? FlowStatusModule.type.SUCCESS
|
||||
: FlowStatusModule.type.FAILURE,
|
||||
logs: 'All jobs completed',
|
||||
result: jobResults,
|
||||
job_id: e.detail.id,
|
||||
iteration_total: flowJobIds?.flowJobs.length,
|
||||
duration_ms: e.detail.duration_ms
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{:else if innerModules.length > 0}
|
||||
<ul class="w-full">
|
||||
<h3 class="text-md leading-6 font-bold text-primary border-b mb-4 py-2">
|
||||
Step-by-step results
|
||||
</h3>
|
||||
|
||||
{#each innerModules as mod, i}
|
||||
{#if render}
|
||||
<div class="line w-8 h-10" />
|
||||
<h3 class="text-tertiary mb-2 w-full">
|
||||
{#if job?.raw_flow?.modules && i < job?.raw_flow?.modules.length}
|
||||
Step
|
||||
<span class="font-medium text-primary">
|
||||
{i + 1}
|
||||
</span>
|
||||
out of
|
||||
<span class="font-medium text-primary">{job?.raw_flow?.modules.length}</span>
|
||||
{#if job.raw_flow?.modules[i]?.summary}
|
||||
: <span class="font-medium text-primary">
|
||||
{job.raw_flow?.modules[i]?.summary ?? ''}
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<h3>Failure module</h3>
|
||||
{/if}
|
||||
</h3>
|
||||
<div class="line w-8 h-10" />
|
||||
{/if}
|
||||
<li class="w-full border p-6 space-y-2 bg-blue-50/50 dark:bg-frost-900/50">
|
||||
{#if [FlowStatusModule.type.IN_PROGRESS, FlowStatusModule.type.SUCCESS, FlowStatusModule.type.FAILURE].includes(mod.type)}
|
||||
{#if job.raw_flow?.modules[i]?.value.type == 'flow'}
|
||||
<svelte:self
|
||||
render={selected == 'sequence' && render}
|
||||
{workspaceId}
|
||||
jobId={mod.job}
|
||||
bind:suspend_status
|
||||
bind:retry_status
|
||||
on:jobsLoaded={(e) => onJobsLoaded(mod, e.detail)}
|
||||
/>
|
||||
{:else}
|
||||
<svelte:self
|
||||
render={selected == 'sequence' && render}
|
||||
{workspaceId}
|
||||
bind:suspend_status
|
||||
bind:retry_status
|
||||
bind:flowState
|
||||
bind:flowModuleStates={localFlowModuleStates}
|
||||
jobId={mod.job}
|
||||
flowJobIds={mod.flow_jobs
|
||||
? {
|
||||
moduleId: mod.id,
|
||||
flowJobs: mod.flow_jobs
|
||||
}
|
||||
: undefined}
|
||||
on:jobsLoaded={(e) => onJobsLoaded(mod, e.detail)}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<ModuleStatus
|
||||
type={mod.type}
|
||||
scheduled_for={localFlowModuleStates?.[mod.id ?? '']?.scheduled_for}
|
||||
/>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if render}
|
||||
{#if job.raw_flow && !isListJob}
|
||||
<div class="{selected != 'graph' ? 'hidden' : ''} mt-4">
|
||||
<div class="grid grid-cols-3 border">
|
||||
<div class="col-span-2 bg-surface-secondary">
|
||||
<div class="flex flex-col">
|
||||
{#each Object.values(retry_status) as count}
|
||||
<span class="text-sm">
|
||||
Retry in progress, # of failed attempts: {count}
|
||||
</span>
|
||||
{/each}
|
||||
{#if suspend_status}
|
||||
<span class="text-sm">
|
||||
Flow suspended, waiting for {pluralize(suspend_status, 'approval')}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<FlowGraph
|
||||
download
|
||||
success={isSuccess(job?.['success'])}
|
||||
flowModuleStates={localFlowModuleStates}
|
||||
on:select={(e) => {
|
||||
if (typeof e.detail == 'string') {
|
||||
if (e.detail == 'Input') {
|
||||
selectedNode = 'start'
|
||||
} else if (e.detail == 'Result') {
|
||||
selectedNode = 'end'
|
||||
} else {
|
||||
selectedNode = e.detail
|
||||
}
|
||||
} else {
|
||||
selectedNode = e.detail.id
|
||||
}
|
||||
}}
|
||||
modules={job.raw_flow?.modules ?? []}
|
||||
failureModule={job.raw_flow?.failure_module}
|
||||
/>
|
||||
</div>
|
||||
<div class="border-l border-gray-400 pt-1 overflow-auto min-h-[800px] flex flex-col">
|
||||
{#if selectedNode}
|
||||
{@const node = localFlowModuleStates[selectedNode]}
|
||||
{#if selectedNode == 'end'}
|
||||
<FlowJobResult
|
||||
workspaceId={job?.workspace_id}
|
||||
jobId={job?.id}
|
||||
filename={job.id}
|
||||
loading={job['running']}
|
||||
noBorder
|
||||
col
|
||||
result={job['result']}
|
||||
logs={job.logs ?? ''}
|
||||
/>
|
||||
{:else if selectedNode == 'start'}
|
||||
{#if job.args}
|
||||
<div class="p-2">
|
||||
<JobArgs args={job.args} />
|
||||
</div>
|
||||
{:else}
|
||||
<p class="p-2">No arguments</p>
|
||||
{/if}
|
||||
{:else if node}
|
||||
<div class="px-2 flex gap-2 min-w-0 overflow-hidden w-full">
|
||||
<ModuleStatus type={node.type} scheduled_for={node.scheduled_for} />
|
||||
{#if node.duration_ms}
|
||||
<Badge>
|
||||
<Icon data={faHourglassHalf} scale={0.6} class="mr-2" />
|
||||
{msToSec(node.duration_ms)} s
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if node.job_id}
|
||||
<div class="grow w-full flex flex-row-reverse">
|
||||
<a
|
||||
class="text-right text-xs"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
href="/run/{node.job_id ?? ''}?workspace={job?.workspace_id}"
|
||||
>
|
||||
{truncateRev(node.job_id ?? '', 10)}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="px-1 py-1">
|
||||
<JobArgs args={node.args} />
|
||||
</div>
|
||||
|
||||
<FlowJobResult
|
||||
workspaceId={job?.workspace_id}
|
||||
jobId={node.job_id}
|
||||
noBorder
|
||||
loading={false}
|
||||
col
|
||||
result={node.result}
|
||||
logs={node.logs ?? ''}
|
||||
/>
|
||||
{:else}
|
||||
<p class="p-2 text-tertiary italic"
|
||||
>The execution of this node has no information attached to it. The job likely did
|
||||
not run yet</p
|
||||
>
|
||||
{/if}
|
||||
{:else}<p class="p-2 text-tertiary italic">Select a node to see its details here</p
|
||||
>{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
Job loading...
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.line {
|
||||
background: repeating-linear-gradient(to bottom, transparent 0 4px, #bbb 4px 8px) 50%/1px 100%
|
||||
no-repeat;
|
||||
}
|
||||
</style>
|
||||
<FlowStatusViewerInner
|
||||
on:jobsLoaded={({ detail }) => {
|
||||
if (detail.script_path != lastScriptPath && detail.script_path) {
|
||||
lastScriptPath = detail.script_path
|
||||
loadOwner(lastScriptPath ?? '')
|
||||
}
|
||||
dispatch('jobsLoaded', detail)
|
||||
}}
|
||||
{jobId}
|
||||
{workspaceId}
|
||||
{isOwner}
|
||||
/>
|
||||
|
||||
624
frontend/src/lib/components/FlowStatusViewerInner.svelte
Normal file
624
frontend/src/lib/components/FlowStatusViewerInner.svelte
Normal file
@@ -0,0 +1,624 @@
|
||||
<script lang="ts">
|
||||
import { FlowStatusModule, Job, JobService, type FlowStatus } from '$lib/gen'
|
||||
import { workspaceStore } from '$lib/stores'
|
||||
import FlowJobResult from './FlowJobResult.svelte'
|
||||
import FlowPreviewStatus from './preview/FlowPreviewStatus.svelte'
|
||||
import Icon from 'svelte-awesome'
|
||||
import { faChevronDown, faChevronUp, faHourglassHalf } from '@fortawesome/free-solid-svg-icons'
|
||||
import { createEventDispatcher, getContext } from 'svelte'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { Badge, Button, Tab } from './common'
|
||||
import DisplayResult from './DisplayResult.svelte'
|
||||
import Tabs from './common/tabs/Tabs.svelte'
|
||||
import { FlowGraph, type FlowStatusViewerContext } from './graph'
|
||||
import ModuleStatus from './ModuleStatus.svelte'
|
||||
import { emptyString, msToSec, truncateRev } from '$lib/utils'
|
||||
import JobArgs from './JobArgs.svelte'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
import FlowStatusWaitingForEvents from './FlowStatusWaitingForEvents.svelte'
|
||||
import { deepEqual } from 'fast-equals'
|
||||
import FlowTimeline from './FlowTimeline.svelte'
|
||||
import { dfs } from './flows/flowStore'
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
let { flowStateStore, flowModuleStates, retryStatus, suspendStatus, durationStatuses } =
|
||||
getContext<FlowStatusViewerContext>('FlowStatusViewer')
|
||||
|
||||
export let jobId: string
|
||||
export let workspaceId: string | undefined = undefined
|
||||
export let flowJobIds:
|
||||
| {
|
||||
moduleId: string
|
||||
flowJobs: string[]
|
||||
}
|
||||
| undefined = undefined
|
||||
export let job: Job | undefined = undefined
|
||||
|
||||
export let render = true
|
||||
|
||||
export let isOwner = false
|
||||
|
||||
let selectedNode: string | undefined = undefined
|
||||
|
||||
let jobResults: any[] = []
|
||||
let jobFailures: boolean[] = []
|
||||
|
||||
let forloop_selected = ''
|
||||
let timeout: NodeJS.Timeout
|
||||
|
||||
let lastSize = 0
|
||||
$: {
|
||||
let len = (flowJobIds?.flowJobs ?? []).length
|
||||
if (len != lastSize) {
|
||||
updateForloop(len)
|
||||
}
|
||||
}
|
||||
|
||||
function updateForloop(len: number) {
|
||||
forloop_selected = flowJobIds?.flowJobs[len - 1] ?? ''
|
||||
lastSize = len
|
||||
}
|
||||
|
||||
let innerModules: FlowStatusModule[] = []
|
||||
|
||||
function updateStatus(status: FlowStatus) {
|
||||
innerModules =
|
||||
status?.modules?.concat(
|
||||
status.failure_module.type != 'WaitingForPriorSteps' ? status.failure_module : []
|
||||
) ?? []
|
||||
updateInnerModules()
|
||||
|
||||
let count = status.retry?.fail_count
|
||||
if (count) {
|
||||
$retryStatus[jobId ?? ''] = count
|
||||
} else if ($retryStatus[jobId ?? ''] != undefined) {
|
||||
delete $retryStatus[jobId ?? '']
|
||||
}
|
||||
$suspendStatus[jobId ?? ''] = job?.flow_status?.modules?.[job?.flow_status.step]?.count
|
||||
}
|
||||
|
||||
function updateInnerModules() {
|
||||
if ($flowModuleStates) {
|
||||
innerModules.forEach((mod, i) => {
|
||||
if (
|
||||
mod.type === FlowStatusModule.type.WAITING_FOR_EVENTS &&
|
||||
$flowModuleStates?.[innerModules?.[i - 1]?.id ?? '']?.type ==
|
||||
FlowStatusModule.type.SUCCESS
|
||||
) {
|
||||
$flowModuleStates[mod.id ?? ''] = { type: mod.type, args: job?.args }
|
||||
} else if (
|
||||
mod.type === FlowStatusModule.type.WAITING_FOR_EXECUTOR &&
|
||||
$flowModuleStates[mod.id ?? '']?.scheduled_for == undefined
|
||||
) {
|
||||
console.debug('updating', mod.job)
|
||||
JobService.getJob({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
id: mod.job ?? ''
|
||||
})
|
||||
.then((job) => {
|
||||
const newState = {
|
||||
type: mod.type,
|
||||
scheduled_for: job?.['scheduled_for'],
|
||||
job_id: job?.id,
|
||||
parent_module: mod['parent_module'],
|
||||
args: job?.args
|
||||
}
|
||||
if (!deepEqual(newState, $flowModuleStates[mod.id ?? ''])) {
|
||||
$flowModuleStates[mod.id ?? ''] = newState
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(`Could not load inner module for job ${mod.job}`, e)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
let errorCount = 0
|
||||
async function loadJobInProgress() {
|
||||
if (jobId != '00000000-0000-0000-0000-000000000000') {
|
||||
try {
|
||||
const newJob = await JobService.getJob({
|
||||
workspace: workspaceId ?? $workspaceStore ?? '',
|
||||
id: jobId ?? ''
|
||||
})
|
||||
if (!deepEqual(job, newJob)) {
|
||||
job = newJob
|
||||
console.log('LOAD')
|
||||
job?.flow_status && updateStatus(job?.flow_status)
|
||||
dispatch('jobsLoaded', job)
|
||||
}
|
||||
errorCount = 0
|
||||
} catch (e) {
|
||||
errorCount += 1
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
if (job?.type !== 'CompletedJob' && errorCount < 4) {
|
||||
timeout = setTimeout(() => loadJobInProgress(), 500)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateJobId() {
|
||||
if (jobId !== job?.id) {
|
||||
flowTimeline?.reset()
|
||||
timeout && clearTimeout(timeout)
|
||||
innerModules = []
|
||||
await loadJobInProgress()
|
||||
}
|
||||
}
|
||||
|
||||
$: jobId && updateJobId()
|
||||
|
||||
$: isListJob = flowJobIds != undefined && Array.isArray(flowJobIds?.flowJobs)
|
||||
|
||||
onDestroy(() => {
|
||||
timeout && clearTimeout(timeout)
|
||||
})
|
||||
|
||||
$: selected = isListJob ? 'sequence' : 'graph'
|
||||
|
||||
function isSuccess(arg: any): boolean | undefined {
|
||||
if (arg == undefined) {
|
||||
return undefined
|
||||
} else {
|
||||
return arg == true
|
||||
}
|
||||
}
|
||||
|
||||
function onJobsLoaded(mod: FlowStatusModule, job: Job): void {
|
||||
if (mod.id && (mod.flow_jobs ?? []).length == 0) {
|
||||
if ($flowStateStore?.[mod.id]) {
|
||||
$flowStateStore[mod.id] = {
|
||||
...$flowStateStore[mod.id],
|
||||
previewResult: job['result'],
|
||||
previewArgs: job.args
|
||||
}
|
||||
}
|
||||
if ($durationStatuses[mod.id] == undefined) {
|
||||
$durationStatuses[mod.id] = {}
|
||||
}
|
||||
let started_at = job.started_at ? new Date(job.started_at).getTime() : undefined
|
||||
if (job.type == 'QueuedJob') {
|
||||
$flowModuleStates[mod.id] = {
|
||||
type: FlowStatusModule.type.IN_PROGRESS,
|
||||
job_id: job.id,
|
||||
logs: job.logs,
|
||||
args: job.args,
|
||||
started_at,
|
||||
parent_module: mod['parent_module']
|
||||
}
|
||||
$durationStatuses[mod.id][job.id] = { started_at }
|
||||
console.log('A', started_at)
|
||||
} else {
|
||||
$flowModuleStates[mod.id] = {
|
||||
args: job.args,
|
||||
type: job['success'] ? FlowStatusModule.type.SUCCESS : FlowStatusModule.type.FAILURE,
|
||||
logs: job.logs,
|
||||
result: job['result'],
|
||||
job_id: job.id,
|
||||
parent_module: mod['parent_module'],
|
||||
duration_ms: job['duration_ms'],
|
||||
started_at: started_at,
|
||||
iteration_total: mod.iterator?.itered?.length
|
||||
// retries: $flowStateStore?.raw_flow
|
||||
}
|
||||
$durationStatuses[mod.id][job.id] = { started_at, duration_ms: job['duration_ms'] }
|
||||
console.log('B', started_at)
|
||||
}
|
||||
}
|
||||
}
|
||||
let showEmbeddeds = -20
|
||||
|
||||
let flowTimeline: FlowTimeline
|
||||
|
||||
let rightColumnSelect: 'timeline' | 'detail' = 'timeline'
|
||||
</script>
|
||||
|
||||
{#if job}
|
||||
<div class="flow-root w-full space-y-4">
|
||||
<!-- {#if innerModules.length > 0 && true}
|
||||
<h3 class="text-md leading-6 font-bold text-primay border-b pb-2">Flow result</h3>
|
||||
{:else}
|
||||
<div class="h-8" />
|
||||
{/if} -->
|
||||
{#if isListJob}
|
||||
{#if (flowJobIds?.flowJobs.length ?? 0) > 20}
|
||||
<p class="text-tertiary italic">
|
||||
For performance reasons, only the last 20 items are shown. <button
|
||||
class="text-primary underline"
|
||||
on:click={() => {
|
||||
showEmbeddeds -= 20
|
||||
}}
|
||||
>Load 20 prior
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
{#if render}
|
||||
<div class="w-full h-full border border-gray-600 bg-surface p-1 overflow-auto">
|
||||
<DisplayResult workspaceId={job?.workspace_id} {jobId} result={jobResults} />
|
||||
</div>
|
||||
{/if}
|
||||
{:else if render}
|
||||
<div class={'border rounded-md shadow p-2'}>
|
||||
<FlowPreviewStatus {job} />
|
||||
{#if !job}
|
||||
<div>
|
||||
<Loader2 class="animate-spin" />
|
||||
</div>
|
||||
{:else if `result` in job}
|
||||
<div class="w-full h-full">
|
||||
<FlowJobResult
|
||||
workspaceId={job?.workspace_id}
|
||||
jobId={job?.id}
|
||||
loading={job['running'] == true}
|
||||
result={job.result}
|
||||
logs={job.logs ?? ''}
|
||||
/>
|
||||
</div>
|
||||
{:else if job.flow_status?.modules?.[job?.flow_status?.step]?.type === FlowStatusModule.type.WAITING_FOR_EVENTS}
|
||||
<FlowStatusWaitingForEvents {workspaceId} {job} {isOwner} />
|
||||
{:else if job.logs}
|
||||
<div class="text-xs p-4 bg-gray-50 overflow-auto max-h-80 border">
|
||||
<pre class="w-full">{job.logs}</pre>
|
||||
</div>
|
||||
{:else if innerModules?.length > 0}
|
||||
<div class="flex flex-col gap-1">
|
||||
{#each innerModules as mod, i (mod.id)}
|
||||
{#if mod.type == FlowStatusModule.type.IN_PROGRESS}
|
||||
{@const rawMod = job.raw_flow?.modules[i]}
|
||||
|
||||
<div
|
||||
><span class="inline-flex gap-1"
|
||||
><Badge color="indigo">{mod.id}</Badge>
|
||||
<span class="font-medium text-primary">
|
||||
{#if !emptyString(rawMod?.summary)}
|
||||
{rawMod?.summary ?? ''}
|
||||
{:else if rawMod?.value.type == 'script'}
|
||||
{rawMod.value.path ?? ''}
|
||||
{:else if rawMod?.value.type}
|
||||
{rawMod?.value.type}
|
||||
{/if}
|
||||
</span>
|
||||
|
||||
<Loader2 class="animate-spin" /></span
|
||||
></div
|
||||
>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{#if render}
|
||||
{#if innerModules.length > 0 && !isListJob}
|
||||
<Tabs bind:selected>
|
||||
<Tab value="graph"><span class="font-semibold text-md">Graph</span></Tab>
|
||||
<Tab value="sequence"><span class="font-semibold">Details</span></Tab>
|
||||
</Tabs>
|
||||
{/if}
|
||||
{/if}
|
||||
<div class={selected != 'sequence' ? 'hidden' : ''}>
|
||||
{#if isListJob}
|
||||
<h3 class="text-md leading-6 font-bold text-tertiary border-b mb-4">
|
||||
Embedded flows: ({flowJobIds?.flowJobs.length} items)
|
||||
</h3>
|
||||
{#if (flowJobIds?.flowJobs.length ?? 0) > 20}
|
||||
<p class="text-tertiary italic">
|
||||
For performance reasons, only the last 20 items are shown. <button
|
||||
class="text-primary underline"
|
||||
on:click={() => {
|
||||
showEmbeddeds -= 20
|
||||
}}
|
||||
>Load 20 prior
|
||||
</button>
|
||||
</p>
|
||||
{/if}
|
||||
{#each (flowJobIds?.flowJobs.length ?? 0) > 20 ? flowJobIds?.flowJobs?.slice(showEmbeddeds) ?? [] : flowJobIds?.flowJobs ?? [] as loopJobId, j}
|
||||
{#if render}
|
||||
<Button
|
||||
variant={forloop_selected === loopJobId ? 'contained' : 'border'}
|
||||
color={jobFailures[j] === true
|
||||
? 'red'
|
||||
: forloop_selected === loopJobId
|
||||
? 'dark'
|
||||
: 'light'}
|
||||
btnClasses="w-full flex justify-start"
|
||||
on:click={() => {
|
||||
if (forloop_selected == loopJobId) {
|
||||
forloop_selected = ''
|
||||
} else {
|
||||
forloop_selected = loopJobId
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span class="truncate">
|
||||
#{(flowJobIds?.flowJobs.length ?? 0) > 20
|
||||
? (flowJobIds?.flowJobs.length ?? 0) + showEmbeddeds + j + 1
|
||||
: j + 1}: {loopJobId}
|
||||
</span>
|
||||
|
||||
<Icon
|
||||
class="ml-2"
|
||||
data={forloop_selected == loopJobId ? faChevronUp : faChevronDown}
|
||||
scale={0.8}
|
||||
/>
|
||||
</Button>
|
||||
{/if}
|
||||
<div class="border p-6" class:hidden={forloop_selected != loopJobId}>
|
||||
<svelte:self
|
||||
render={forloop_selected == loopJobId && selected == 'sequence' && render}
|
||||
{workspaceId}
|
||||
jobId={loopJobId}
|
||||
on:jobsLoaded={(e) => {
|
||||
let modId = flowJobIds?.moduleId
|
||||
if (modId) {
|
||||
if ($flowStateStore?.[modId]) {
|
||||
if (
|
||||
!$flowStateStore[modId].previewResult ||
|
||||
!Array.isArray($flowStateStore[modId]?.previewResult)
|
||||
) {
|
||||
$flowStateStore[modId].previewResult = []
|
||||
}
|
||||
$flowStateStore[modId].previewResult[j] = e.detail.result
|
||||
$flowStateStore[modId].previewArgs = e.detail.args
|
||||
if (e.detail.type == 'QueuedJob') {
|
||||
jobResults[j] = 'Job in progress ...'
|
||||
} else {
|
||||
jobResults[j] = e.detail.result
|
||||
jobFailures[j] = e.detail.success === false
|
||||
}
|
||||
}
|
||||
|
||||
let started_at = e.detail.started_at
|
||||
? new Date(e.detail.started_at).getTime()
|
||||
: undefined
|
||||
|
||||
let job_id = e.detail.id
|
||||
if ($durationStatuses[modId] == undefined) {
|
||||
$durationStatuses[modId] = {}
|
||||
}
|
||||
if (e.detail.type == 'QueuedJob') {
|
||||
$flowModuleStates[modId] = {
|
||||
type: FlowStatusModule.type.IN_PROGRESS,
|
||||
started_at,
|
||||
logs: e.detail.logs,
|
||||
job_id,
|
||||
args: e.detail.args,
|
||||
iteration_total: flowJobIds?.flowJobs.length,
|
||||
duration_ms: undefined
|
||||
}
|
||||
|
||||
$durationStatuses[modId][job_id] = { started_at }
|
||||
console.log('C', started_at)
|
||||
} else {
|
||||
$flowModuleStates[modId] = {
|
||||
started_at,
|
||||
args: e.detail.args,
|
||||
type: e.detail.success
|
||||
? FlowStatusModule.type.SUCCESS
|
||||
: FlowStatusModule.type.FAILURE,
|
||||
logs: 'All jobs completed',
|
||||
result: jobResults,
|
||||
job_id,
|
||||
iteration_total: flowJobIds?.flowJobs.length,
|
||||
duration_ms: e.detail.duration_ms,
|
||||
isListJob: true
|
||||
}
|
||||
console.log('D', started_at)
|
||||
$durationStatuses[modId][job_id] = {
|
||||
started_at,
|
||||
duration_ms: e.detail.duration_ms
|
||||
}
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/each}
|
||||
{:else if innerModules.length > 0}
|
||||
<ul class="w-full">
|
||||
<h3 class="text-md leading-6 font-bold text-primary border-b mb-4 py-2">
|
||||
Step-by-step results
|
||||
</h3>
|
||||
|
||||
{#each innerModules as mod, i}
|
||||
{#if render}
|
||||
<div class="line w-8 h-10" />
|
||||
<h3 class="text-tertiary mb-2 w-full">
|
||||
{#if job?.raw_flow?.modules && i < job?.raw_flow?.modules.length}
|
||||
Step
|
||||
<span class="font-medium text-primary">
|
||||
{i + 1}
|
||||
</span>
|
||||
out of
|
||||
<span class="font-medium text-primary">{job?.raw_flow?.modules.length}</span>
|
||||
{#if job.raw_flow?.modules[i]?.summary}
|
||||
: <span class="font-medium text-primary">
|
||||
{job.raw_flow?.modules[i]?.summary ?? ''}
|
||||
</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<h3>Failure module</h3>
|
||||
{/if}
|
||||
</h3>
|
||||
<div class="line w-8 h-10" />
|
||||
{/if}
|
||||
<li class="w-full border p-6 space-y-2 bg-blue-50/50 dark:bg-frost-900/50">
|
||||
{#if [FlowStatusModule.type.IN_PROGRESS, FlowStatusModule.type.SUCCESS, FlowStatusModule.type.FAILURE].includes(mod.type)}
|
||||
{#if job.raw_flow?.modules[i]?.value.type == 'flow'}
|
||||
<svelte:self
|
||||
render={selected == 'sequence' && render}
|
||||
{workspaceId}
|
||||
jobId={mod.job}
|
||||
on:jobsLoaded={(e) => onJobsLoaded(mod, e.detail)}
|
||||
/>
|
||||
{:else}
|
||||
<svelte:self
|
||||
render={selected == 'sequence' && render}
|
||||
{workspaceId}
|
||||
jobId={mod.job}
|
||||
flowJobIds={mod.flow_jobs
|
||||
? {
|
||||
moduleId: mod.id,
|
||||
flowJobs: mod.flow_jobs
|
||||
}
|
||||
: undefined}
|
||||
on:jobsLoaded={(e) => onJobsLoaded(mod, e.detail)}
|
||||
/>
|
||||
{/if}
|
||||
{:else}
|
||||
<ModuleStatus
|
||||
type={mod.type}
|
||||
scheduled_for={$flowModuleStates?.[mod.id ?? '']?.scheduled_for}
|
||||
/>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{#if render}
|
||||
{#if job.raw_flow && !isListJob}
|
||||
<div class="{selected != 'graph' ? 'hidden' : ''} mt-4">
|
||||
<div class="grid grid-cols-3 border">
|
||||
<div class="col-span-2 bg-surface-secondary">
|
||||
<div class="flex flex-col">
|
||||
{#each Object.values($retryStatus) as count}
|
||||
{#if count}
|
||||
<span class="text-sm">
|
||||
Retry in progress, # of failed attempts: {count}
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
{#each Object.values($suspendStatus) as count}
|
||||
{#if count}
|
||||
<span class="text-sm">
|
||||
Flow suspended, waiting for {count} events
|
||||
</span>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<FlowGraph
|
||||
download
|
||||
success={isSuccess(job?.['success'])}
|
||||
flowModuleStates={$flowModuleStates}
|
||||
on:select={(e) => {
|
||||
rightColumnSelect = 'detail'
|
||||
if (typeof e.detail == 'string') {
|
||||
if (e.detail == 'Input') {
|
||||
selectedNode = 'start'
|
||||
} else if (e.detail == 'Result') {
|
||||
selectedNode = 'end'
|
||||
} else {
|
||||
selectedNode = e.detail
|
||||
}
|
||||
} else {
|
||||
selectedNode = e.detail.id
|
||||
}
|
||||
}}
|
||||
modules={job.raw_flow?.modules ?? []}
|
||||
failureModule={job.raw_flow?.failure_module}
|
||||
/>
|
||||
</div>
|
||||
<div class="border-l border-gray-400 pt-1 overflow-auto min-h-[800px] flex flex-col">
|
||||
<Tabs bind:selected={rightColumnSelect}>
|
||||
<Tab value="timeline"><span class="font-semibold text-md">Timeline</span></Tab>
|
||||
<Tab value="detail"><span class="font-semibold">Details</span></Tab>
|
||||
</Tabs>
|
||||
{#if rightColumnSelect == 'timeline'}
|
||||
<FlowTimeline
|
||||
bind:this={flowTimeline}
|
||||
flowModules={dfs(job.raw_flow?.modules ?? [], (x) => x.id)}
|
||||
durationStatuses={$durationStatuses}
|
||||
/>
|
||||
{:else if rightColumnSelect == 'detail'}
|
||||
<div class="pt-2">
|
||||
{#if selectedNode}
|
||||
{@const node = $flowModuleStates[selectedNode]}
|
||||
|
||||
{#if selectedNode == 'end'}
|
||||
<FlowJobResult
|
||||
workspaceId={job?.workspace_id}
|
||||
jobId={job?.id}
|
||||
filename={job.id}
|
||||
loading={job['running']}
|
||||
noBorder
|
||||
col
|
||||
result={job['result']}
|
||||
logs={job.logs ?? ''}
|
||||
/>
|
||||
{:else if selectedNode == 'start'}
|
||||
{#if job.args}
|
||||
<div class="p-2">
|
||||
<JobArgs args={job.args} />
|
||||
</div>
|
||||
{:else}
|
||||
<p class="p-2">No arguments</p>
|
||||
{/if}
|
||||
{:else if node}
|
||||
<div class="px-2 flex gap-2 min-w-0 overflow-hidden w-full">
|
||||
<ModuleStatus type={node.type} scheduled_for={node.scheduled_for} />
|
||||
{#if node.duration_ms}
|
||||
<Badge>
|
||||
<Icon data={faHourglassHalf} scale={0.6} class="mr-2" />
|
||||
{msToSec(node.duration_ms)} s
|
||||
</Badge>
|
||||
{/if}
|
||||
{#if node.job_id}
|
||||
<div class="grow w-full flex flex-row-reverse">
|
||||
<a
|
||||
class="text-right text-xs"
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
href="/run/{node.job_id ?? ''}?workspace={job?.workspace_id}"
|
||||
>
|
||||
{truncateRev(node.job_id ?? '', 10)}
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !node.isListJob}
|
||||
<div class="px-1 py-1">
|
||||
<JobArgs args={node.args} />
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<FlowJobResult
|
||||
workspaceId={job?.workspace_id}
|
||||
jobId={node.job_id}
|
||||
noBorder
|
||||
loading={false}
|
||||
col
|
||||
result={node.result}
|
||||
logs={node.logs ?? ''}
|
||||
/>
|
||||
{:else}
|
||||
<p class="p-2 text-tertiary italic"
|
||||
>The execution of this node has no information attached to it. The job likely
|
||||
did not run yet</p
|
||||
>
|
||||
{/if}
|
||||
{:else}<p class="p-2 text-tertiary italic">Select a node to see its details here</p
|
||||
>{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
{:else}
|
||||
<Loader2 class="animate-spin" />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.line {
|
||||
background: repeating-linear-gradient(to bottom, transparent 0 4px, #bbb 4px 8px) 50%/1px 100%
|
||||
no-repeat;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,7 @@
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import { Button } from './common'
|
||||
|
||||
export let is_owner: boolean
|
||||
export let isOwner: boolean
|
||||
export let workspaceId: string | undefined
|
||||
export let job: Job
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<div class="w-full h-full mt-2 text-sm text-tertiary">
|
||||
<p>Waiting to be resumed</p>
|
||||
<div>
|
||||
{#if is_owner}
|
||||
{#if isOwner}
|
||||
<div class="flex flex-row gap-2 mt-2">
|
||||
<div>
|
||||
<Button
|
||||
|
||||
@@ -1,92 +1,145 @@
|
||||
<script lang="ts">
|
||||
import { FlowStatusModule } from '$lib/gen'
|
||||
import { Skeleton } from './common'
|
||||
import type { GraphModuleState } from './graph'
|
||||
import { debounce, displayDate, msToSec } from '$lib/utils'
|
||||
import FlowTimelineBar from './FlowTimelineBar.svelte'
|
||||
import { onDestroy } from 'svelte'
|
||||
import { getDbClockNow } from '$lib/forLater'
|
||||
import { Loader2 } from 'lucide-svelte'
|
||||
|
||||
export let flowModuleStates: Record<string, GraphModuleState> = {}
|
||||
|
||||
let items = flowModuleStates ? computeItems(flowModuleStates) : undefined
|
||||
|
||||
$: computeItems(flowModuleStates)
|
||||
export let flowModules: string[]
|
||||
export let durationStatuses: Record<
|
||||
string,
|
||||
Record<string, { started_at?: number; duration_ms?: number }>
|
||||
>
|
||||
|
||||
let min: undefined | number = undefined
|
||||
let max: undefined | number = undefined
|
||||
function computeItems(flowModuleStates: Record<string, GraphModuleState>): any {
|
||||
let total: number | undefined = undefined
|
||||
|
||||
let items:
|
||||
| Record<string, Array<{ started_at?: number; duration_ms?: number; id: string }>>
|
||||
| undefined = undefined
|
||||
|
||||
let debounced = debounce(() => computeItems(durationStatuses), 30)
|
||||
$: durationStatuses && debounced()
|
||||
|
||||
export function reset() {
|
||||
min = undefined
|
||||
max = undefined
|
||||
items = computeItems(durationStatuses)
|
||||
}
|
||||
|
||||
function computeItems(
|
||||
durationStatuses: Record<string, Record<string, { started_at?: number; duration_ms?: number }>>
|
||||
): any {
|
||||
let nmin: undefined | number = undefined
|
||||
let nmax: undefined | number = undefined
|
||||
|
||||
let isStillRunning = false
|
||||
Object.values(flowModuleStates).forEach((v) => {
|
||||
if (v.started_at) {
|
||||
if (!nmin) {
|
||||
nmin = v.started_at
|
||||
} else {
|
||||
nmin = Math.min(nmin, v.started_at)
|
||||
}
|
||||
}
|
||||
if (v.type == FlowStatusModule.type.IN_PROGRESS) {
|
||||
isStillRunning = true
|
||||
}
|
||||
if (!isStillRunning) {
|
||||
if (v.started_at && v.duration_ms) {
|
||||
let lmax = v.started_at + v.duration_ms
|
||||
if (!nmax) {
|
||||
nmax = lmax
|
||||
|
||||
let cnt = 0
|
||||
let nitems = {}
|
||||
Object.entries(durationStatuses).forEach(([k, o]) => {
|
||||
Object.values(o).forEach((v) => {
|
||||
cnt++
|
||||
if (v.started_at) {
|
||||
if (!nmin) {
|
||||
nmin = v.started_at
|
||||
} else {
|
||||
nmax = Math.max(nmax, lmax)
|
||||
nmin = Math.min(nmin, v.started_at)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
if (v.duration_ms == undefined) {
|
||||
isStillRunning = true
|
||||
}
|
||||
if (!isStillRunning) {
|
||||
if (v.started_at && v.duration_ms) {
|
||||
let lmax = v.started_at + v.duration_ms
|
||||
if (!nmax) {
|
||||
nmax = lmax
|
||||
} else {
|
||||
nmax = Math.max(nmax, lmax)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
let arr = Object.entries(o).map(([k, v]) => ({ ...v, id: k }))
|
||||
arr.sort((x, y) => {
|
||||
if (!x.started_at) {
|
||||
return -1
|
||||
} else if (!y.started_at) {
|
||||
return 1
|
||||
} else {
|
||||
return x.started_at - y.started_at
|
||||
}
|
||||
})
|
||||
|
||||
let total = (isStillRunning || !nmax ? Date.now() : nmax) - (nmin ?? Date.now())
|
||||
|
||||
const nentries = Object.entries(flowModuleStates).map(([k, v]) => {
|
||||
let started_at = v.started_at && nmin ? ((v.started_at - nmin) / total) * 100 : undefined
|
||||
let len = 0
|
||||
if (v.duration_ms) {
|
||||
len = v.duration_ms
|
||||
} else if (v.started_at) {
|
||||
len = Date.now() - v.started_at
|
||||
} else {
|
||||
len = 0
|
||||
}
|
||||
if (total) {
|
||||
len *= 100 / total
|
||||
} else {
|
||||
len = 0
|
||||
}
|
||||
return { name: k, started_at, len }
|
||||
nitems[k] = arr
|
||||
})
|
||||
items = nitems
|
||||
min = nmin
|
||||
max = nmax
|
||||
return nentries
|
||||
max = isStillRunning || cnt < flowModules.length ? undefined : nmax
|
||||
if (max && min) {
|
||||
total = max - min
|
||||
}
|
||||
}
|
||||
|
||||
let now = getDbClockNow().getTime()
|
||||
|
||||
let i = 0
|
||||
let interval = setInterval((x) => {
|
||||
if (!max) {
|
||||
i++
|
||||
now = getDbClockNow().getTime()
|
||||
}
|
||||
if (min && (!max || total == undefined)) {
|
||||
total = max ? max - min : Math.max(now - min, 2000)
|
||||
}
|
||||
}, 30)
|
||||
|
||||
onDestroy(() => {
|
||||
interval && clearInterval(interval)
|
||||
})
|
||||
</script>
|
||||
|
||||
{#if items}
|
||||
<div class="border rounded-md divide-y">
|
||||
<div class="divide-y">
|
||||
<div class="px-2 py-2 grid grid-cols-12 w-full"
|
||||
><div />
|
||||
<div class="col-span-11 pt-1 px-2 flex justify-between"><div>{min}</div><div>{max}</div></div>
|
||||
</div>
|
||||
{#each items as item}
|
||||
<div class="px-2 py-2 grid grid-cols-12 w-full"
|
||||
><div>{item.name}</div>
|
||||
<div class="col-span-11 pt-1 px-2 flex"
|
||||
><div style="width: {item.started_at}%" class="h-4" /><div
|
||||
style="width: {item.len}%"
|
||||
class="h-4 bg-blue-600 border center-center text-white text-xs"
|
||||
>{Math.ceil(item.len) ?? 0}%</div
|
||||
></div
|
||||
<div class="col-span-11 pt-1 px-2 flex text-2xs text-secondary justify-between"
|
||||
><div>{min ? displayDate(new Date(min), true) : ''}</div>{#if max && min}<div
|
||||
class="hidden lg:block">{msToSec(max - min)}s</div
|
||||
>
|
||||
{/if}<div class="flex gap-1 items-center"
|
||||
>{max ? displayDate(new Date(max), true) : ''}{#if !max && min}{#if now}
|
||||
{msToSec(now - min)}s
|
||||
{/if}<Loader2 size={14} class="animate-spin" />{/if}</div
|
||||
></div
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{#key i}
|
||||
{#each Object.values(flowModules) as k}
|
||||
<div class="px-2 py-2 grid grid-cols-12 w-full"
|
||||
><div>{k}</div>
|
||||
<div class="col-span-11 pt-1 px-2 flex min-h-6 w-full"
|
||||
>{#if min && total}
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
{#each items?.[k] ?? [] as b}
|
||||
<FlowTimelineBar
|
||||
{total}
|
||||
{now}
|
||||
{min}
|
||||
started_at={b?.started_at}
|
||||
duration_ms={b?.duration_ms}
|
||||
id={b?.id}
|
||||
/>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}</div
|
||||
></div
|
||||
>
|
||||
{/each}
|
||||
{/key}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="mt-4" />
|
||||
<Skeleton layout={[[2], 1]} />
|
||||
{#each new Array(6) as _}
|
||||
<Skeleton layout={[[4], 0.5]} />
|
||||
{/each}
|
||||
<Loader2 class="animate-spin" />
|
||||
{/if}
|
||||
|
||||
23
frontend/src/lib/components/FlowTimelineBar.svelte
Normal file
23
frontend/src/lib/components/FlowTimelineBar.svelte
Normal file
@@ -0,0 +1,23 @@
|
||||
<script lang="ts">
|
||||
import TimelineBar from './TimelineBar.svelte'
|
||||
|
||||
export let total: number
|
||||
export let min: number | number
|
||||
export let now: number
|
||||
export let duration_ms: number | undefined
|
||||
export let started_at: number | undefined
|
||||
export let id: string
|
||||
</script>
|
||||
|
||||
{#if started_at}
|
||||
<!-- {started_at}
|
||||
{duration_ms} -->
|
||||
<TimelineBar
|
||||
{id}
|
||||
{total}
|
||||
{min}
|
||||
{started_at}
|
||||
len={duration_ms ?? now - started_at}
|
||||
running={duration_ms == undefined}
|
||||
/>
|
||||
{/if}
|
||||
@@ -11,6 +11,7 @@
|
||||
export let disappearTimeout = 100
|
||||
export let appearTimeout = 300
|
||||
export let documentationLink: string | undefined = undefined
|
||||
export let style: string | undefined = undefined
|
||||
|
||||
const [popperRef, popperContent] = createPopperActions({ placement })
|
||||
|
||||
@@ -49,11 +50,18 @@
|
||||
|
||||
{#if notClickable}
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<span use:popperRef on:mouseenter={open} on:mouseleave={close} class={$$props.class}>
|
||||
<span {style} use:popperRef on:mouseenter={open} on:mouseleave={close} class={$$props.class}>
|
||||
<slot />
|
||||
</span>
|
||||
{:else}
|
||||
<button use:popperRef on:mouseenter={open} on:mouseleave={close} on:click class={$$props.class}>
|
||||
<button
|
||||
{style}
|
||||
use:popperRef
|
||||
on:mouseenter={open}
|
||||
on:mouseleave={close}
|
||||
on:click
|
||||
class={$$props.class}
|
||||
>
|
||||
<slot />
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
33
frontend/src/lib/components/TimelineBar.svelte
Normal file
33
frontend/src/lib/components/TimelineBar.svelte
Normal file
@@ -0,0 +1,33 @@
|
||||
<script lang="ts">
|
||||
import { msToSec } from '$lib/utils'
|
||||
import { ExternalLink } from 'lucide-svelte'
|
||||
import Popover from './Popover.svelte'
|
||||
|
||||
export let total: number
|
||||
export let min: number | undefined
|
||||
export let started_at: number | undefined
|
||||
export let len: number
|
||||
export let id: string
|
||||
export let running: boolean
|
||||
</script>
|
||||
|
||||
{#if min && started_at}
|
||||
<div class="flex w-full">
|
||||
<div style="width: {((started_at - min) / total) * 100}%" class="h-4" />
|
||||
<Popover
|
||||
style="width: {(len / total) * 100}%"
|
||||
class="h-4 {running
|
||||
? 'bg-blue-400/90'
|
||||
: 'bg-blue-500/90'} rounded-sm center-center text-white text-2xs whitespace-nowrap hover:outline outline-1 outline-black"
|
||||
>
|
||||
<svelte:fragment slot="text"
|
||||
><a href="/run/{id}" class="inline-flex items-center gap-1" target="_blank"
|
||||
>{id} <ExternalLink size={14} /></a
|
||||
></svelte:fragment
|
||||
>
|
||||
<span class={len / total < 0.09 ? '-ml-14 text-primary' : ''}
|
||||
>{#if len}{msToSec(len, 1)}s{/if}</span
|
||||
>
|
||||
</Popover>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -767,6 +767,7 @@
|
||||
|
||||
<DarkModeObserver on:change={onThemeChange} />
|
||||
|
||||
<!-- {JSON.stringify(flowModuleStates)} -->
|
||||
<div
|
||||
bind:clientWidth={width}
|
||||
class={fullSize ? '' : 'w-full h-full overflow-hidden relative'}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { FlowStatusModule } from '$lib/gen'
|
||||
import type { Writable } from 'svelte/store'
|
||||
import type { UserNodeType } from './svelvet/types'
|
||||
import type { FlowState } from '../flows/flowState'
|
||||
|
||||
export type ModuleHost = 'workspace' | 'inline' | 'hub'
|
||||
|
||||
@@ -25,6 +27,18 @@ export type Branch = {
|
||||
|
||||
export type GraphItem = Node | Loop | Branch
|
||||
|
||||
export type GraphModuleStates = {
|
||||
job_id: string,
|
||||
states: Record<string, GraphModuleState>
|
||||
}
|
||||
|
||||
export type FlowStatusViewerContext = {
|
||||
flowStateStore?: Writable<FlowState>,
|
||||
flowModuleStates: Writable<Record<string, GraphModuleState>>
|
||||
retryStatus: Writable<Record<string, number | undefined>>
|
||||
suspendStatus: Writable<Record<string, number | undefined>>,
|
||||
durationStatuses: Writable<Record<string, Record<string, {started_at?: number, duration_ms?: number}>>>
|
||||
}
|
||||
export type GraphModuleState = {
|
||||
type: FlowStatusModule.type
|
||||
args: any
|
||||
@@ -37,6 +51,8 @@ export type GraphModuleState = {
|
||||
retries?: number
|
||||
duration_ms?: number
|
||||
started_at?: number
|
||||
suspend_count?: number
|
||||
isListJob?: boolean
|
||||
}
|
||||
|
||||
export type NestedNodes = GraphItem[]
|
||||
|
||||
@@ -86,6 +86,7 @@
|
||||
<TestJobLoader bind:job bind:watchJob on:done={onDone} />
|
||||
{/if}
|
||||
|
||||
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||
<div
|
||||
on:mouseenter={instantOpen}
|
||||
on:mouseleave={staggeredClose}
|
||||
|
||||
@@ -341,12 +341,12 @@ export function initialCode(
|
||||
return DENO_INIT_CODE
|
||||
}
|
||||
} else if (language === 'python3') {
|
||||
if (subkind === 'flow') {
|
||||
if (kind === 'trigger') {
|
||||
return PYTHON_INIT_CODE_TRIGGER
|
||||
} else if (subkind === 'flow') {
|
||||
return PYTHON_INIT_CODE_CLEAR
|
||||
} else if (kind === 'failure') {
|
||||
return PYTHON_FAILURE_MODULE_CODE
|
||||
} else if (kind === 'trigger') {
|
||||
return PYTHON_INIT_CODE_TRIGGER
|
||||
} else {
|
||||
return PYTHON_INIT_CODE
|
||||
}
|
||||
|
||||
@@ -46,9 +46,9 @@ export function displayDate(dateString: string | Date | undefined, displaySecond
|
||||
}
|
||||
}
|
||||
|
||||
export function msToSec(ms: number | undefined): string {
|
||||
export function msToSec(ms: number | undefined, maximumFractionDigits?: number): string {
|
||||
if (ms === undefined) return '?'
|
||||
return (ms / 1000).toLocaleString(undefined, { maximumFractionDigits: 3 })
|
||||
return (ms / 1000).toLocaleString(undefined, { maximumFractionDigits: maximumFractionDigits ?? 3 })
|
||||
}
|
||||
|
||||
export function getToday() {
|
||||
@@ -68,6 +68,16 @@ export function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export function addIfNotExists<T>(e: T, arr: Array<T> | undefined): Array<T> {
|
||||
if (!arr) {
|
||||
return [e]
|
||||
} else if (arr.includes(e)) {
|
||||
return arr
|
||||
} else {
|
||||
return arr.concat([e])
|
||||
}
|
||||
}
|
||||
|
||||
export function validatePassword(password: string): boolean {
|
||||
const re = /^(?=.*[\d])(?=.*[!@#$%^&*])[\w!@#$%^&*]{8,30}$/
|
||||
return re.test(password)
|
||||
|
||||
Reference in New Issue
Block a user