feat: add timeline progress bars to flows" (#2464)

* add timeline

* progress

* all

* all
This commit is contained in:
Ruben Fiszel
2023-10-18 19:22:09 +02:00
committed by GitHub
parent 92c0ab21b7
commit d96f8d0d41
13 changed files with 897 additions and 656 deletions

View File

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

View File

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

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

View File

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

View File

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

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

View File

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

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

View File

@@ -767,6 +767,7 @@
<DarkModeObserver on:change={onThemeChange} />
<!-- {JSON.stringify(flowModuleStates)} -->
<div
bind:clientWidth={width}
class={fullSize ? '' : 'w-full h-full overflow-hidden relative'}

View File

@@ -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[]

View File

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

View File

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

View File

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