402 lines
10 KiB
Svelte
402 lines
10 KiB
Svelte
<script lang="ts">
|
|
import Svelvet, { type Edge } from 'svelvet'
|
|
import { sugiyama, dagStratify, decrossOpt, coordCenter } from 'd3-dag'
|
|
import { type FlowModule, FlowStatusModule, type ForloopFlow, RawScript } from '../../gen'
|
|
import {
|
|
NODE,
|
|
createIdGenerator,
|
|
isNode,
|
|
isLoop,
|
|
isBranch,
|
|
type GraphItem,
|
|
type Node,
|
|
type Loop,
|
|
type Branch,
|
|
type NestedNodes,
|
|
type ModuleHost
|
|
} from '.'
|
|
import { defaultIfEmptyString, truncateRev } from '$lib/utils'
|
|
import { createEventDispatcher } from 'svelte'
|
|
import { numberToChars } from '../flows/utils'
|
|
|
|
export let modules: FlowModule[] | undefined = []
|
|
export let failureModule: FlowModule | undefined = undefined
|
|
export let minHeight: number = 0
|
|
export let notSelectable = false
|
|
export let flowModuleStates:
|
|
| Record<string, { type: FlowStatusModule.type; logs?: string; result?: any }>
|
|
| undefined = undefined
|
|
|
|
let selectedNode: string | undefined = undefined
|
|
|
|
const idGenerator = createIdGenerator()
|
|
let nestedNodes: NestedNodes
|
|
let nodes: Node[] = []
|
|
let edges: Edge[] = []
|
|
let width: number, height: number
|
|
|
|
let dispatch = createEventDispatcher()
|
|
|
|
$: {
|
|
width && height && minHeight && selectedNode && flowModuleStates
|
|
if (modules) {
|
|
createGraph(modules, failureModule)
|
|
} else {
|
|
nodes = edges = []
|
|
}
|
|
}
|
|
|
|
function createGraph(modules: FlowModule[], failureModule?: FlowModule) {
|
|
nestedNodes = nodes = []
|
|
|
|
nestedNodes.push(createVirtualNode(getParentIds(), 'Flow start'))
|
|
modules.forEach((m) => {
|
|
const item = getConvertedFlowModule(m)
|
|
item && nestedNodes.push(item)
|
|
})
|
|
const endParentIds = getParentIds()
|
|
nestedNodes.push(createVirtualNode(getParentIds(), 'Flow end'))
|
|
if (failureModule) {
|
|
nestedNodes.push(createErrorHandler(endParentIds, failureModule))
|
|
}
|
|
|
|
const flatNodes = flattenNestedNodes(nestedNodes)
|
|
const layered = layoutNodes(flatNodes)
|
|
nodes = layered.nodes
|
|
// width = layered.width
|
|
height = layered.height
|
|
edges = createEdges(nodes)
|
|
}
|
|
|
|
function getConvertedFlowModule(
|
|
module: FlowModule,
|
|
parent: NestedNodes | string | undefined = undefined,
|
|
edgeLabel: string | undefined = undefined
|
|
): GraphItem | undefined {
|
|
const type = module.value.type
|
|
const parentIds = getParentIds(parent)
|
|
|
|
if (type === 'rawscript') {
|
|
const lang = module.value.language
|
|
return flowModuleToNode(
|
|
parentIds,
|
|
module.id,
|
|
module.summary || 'Inline script',
|
|
'inline',
|
|
module,
|
|
lang,
|
|
edgeLabel
|
|
)
|
|
} else if (type === 'script') {
|
|
const isHub = module.value.path.startsWith('hub/')
|
|
return flowModuleToNode(
|
|
parentIds,
|
|
module.id,
|
|
module.summary || module.value.path,
|
|
isHub ? 'hub' : 'workspace',
|
|
module,
|
|
undefined,
|
|
edgeLabel
|
|
)
|
|
} else if (type === 'forloopflow') {
|
|
return flowModuleToLoop(module.value.modules, module, parent)
|
|
} else if (type === 'branchone') {
|
|
const branches = [module.value.default, ...module.value.branches.map((b) => b.modules)]
|
|
return flowModuleToBranch(
|
|
module,
|
|
branches,
|
|
['Default', ...module.value.branches.map((x) => `If ${truncateRev(x.expr, 20)}`)],
|
|
parent
|
|
)
|
|
} else if (type === 'branchall') {
|
|
const branches = module.value.branches.map((b) => b.modules)
|
|
return flowModuleToBranch(module, branches, [], parent)
|
|
}
|
|
return flowModuleToNode(
|
|
parentIds,
|
|
module.id,
|
|
module.summary || 'Identity step',
|
|
'inline',
|
|
module,
|
|
undefined,
|
|
edgeLabel
|
|
)
|
|
}
|
|
|
|
function getParentIds(items: string | NestedNodes | undefined = undefined): string[] {
|
|
if (typeof items == 'string') {
|
|
return [items]
|
|
}
|
|
const item = items?.at(-1) || nestedNodes.at(-1)
|
|
if (!item) return []
|
|
|
|
if (isNode(item)) {
|
|
return ['' + item.id]
|
|
} else if (isLoop(item)) {
|
|
return getParentIds(item.items)
|
|
} else if (isBranch(item)) {
|
|
return item.items.map((i) => getParentIds(i)).flat()
|
|
}
|
|
return []
|
|
}
|
|
|
|
function getStateColor(state: FlowStatusModule.type | undefined): string {
|
|
switch (state) {
|
|
case FlowStatusModule.type.SUCCESS:
|
|
return 'rgb(193, 255, 216)'
|
|
case FlowStatusModule.type.FAILURE:
|
|
return 'rgb(248 113 113)'
|
|
case FlowStatusModule.type.IN_PROGRESS:
|
|
return 'rgb(253, 240, 176)'
|
|
case FlowStatusModule.type.WAITING_FOR_EVENTS:
|
|
return 'rgb(229, 176, 253)'
|
|
default:
|
|
return '#fff'
|
|
}
|
|
}
|
|
function flowModuleToNode(
|
|
parentIds: string[],
|
|
id: string,
|
|
title: string,
|
|
host: ModuleHost,
|
|
onClickDetail: any,
|
|
lang?: RawScript.language,
|
|
edgeLabel?: string
|
|
): Node {
|
|
const langImg: Record<RawScript.language, string> = {
|
|
deno: '/icons/ts-lang.svg',
|
|
go: '/icons/go-lang.svg',
|
|
python3: '/icons/python-lang.svg',
|
|
bash: '/icons/bash-lang.svg'
|
|
}
|
|
const hostImg: Record<ModuleHost, string> = {
|
|
hub: '/icons/hub-script.svg',
|
|
workspace: '/icons/inline-script.svg',
|
|
inline: ''
|
|
}
|
|
const wrapperWidth = lang ? 'w-[calc(100%-70px)]' : 'w-[calc(100%-50px)]'
|
|
const graphId = idGenerator.next().value
|
|
return {
|
|
id: graphId,
|
|
position: { x: -1, y: -1 },
|
|
data: {
|
|
html: `
|
|
<div class="w-full flex justify-between items-center px-1">
|
|
<div class="${wrapperWidth} text-left ellipsize text-2xs truncate">
|
|
${title}
|
|
</div>
|
|
<div class="flex items-center">
|
|
${lang ? `<img src="${langImg[lang]}" class="grayscale">` : ''}
|
|
${host != 'inline' ? `<img src="${hostImg[host]}" class="grayscale">` : ''}
|
|
<span class="center-center font-semibold bg-indigo-100 text-indigo-800 rounded px-1 pb-[2px] ml-[2px]">
|
|
${id ?? numberToChars(graphId - 1)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
`
|
|
},
|
|
host,
|
|
width: NODE.width,
|
|
height: NODE.height,
|
|
borderColor: selectedNode == onClickDetail.id ? 'black' : '#999',
|
|
bgColor:
|
|
selectedNode == onClickDetail.id
|
|
? '#f5f5f5'
|
|
: getStateColor(flowModuleStates?.[onClickDetail.id]?.type),
|
|
parentIds,
|
|
clickCallback: (node) => {
|
|
if (!notSelectable) {
|
|
selectedNode = onClickDetail.id
|
|
}
|
|
dispatch('click', onClickDetail)
|
|
},
|
|
edgeLabel
|
|
}
|
|
}
|
|
|
|
function flowModuleToLoop(
|
|
modules: FlowModule[],
|
|
module: FlowModule,
|
|
parent: NestedNodes | string | undefined = undefined
|
|
): Loop {
|
|
const value = module.value as ForloopFlow
|
|
const expr = value.iterator.type == 'static' ? value.iterator.value : value.iterator.expr
|
|
const loop: Loop = {
|
|
type: 'loop',
|
|
items: [
|
|
flowModuleToNode(
|
|
getParentIds(parent),
|
|
module.id,
|
|
module.summary || `For Loop: ${defaultIfEmptyString(expr ?? '', 'TBD')}`,
|
|
'inline',
|
|
module,
|
|
undefined
|
|
)
|
|
]
|
|
}
|
|
modules.forEach((module) => {
|
|
const item = getConvertedFlowModule(module, loop.items)
|
|
item && loop.items.push(item)
|
|
})
|
|
loop.items.push(
|
|
createVirtualNode(
|
|
getParentIds(loop.items),
|
|
`Collect iterations' results of For Loop ${module.id}`
|
|
)
|
|
)
|
|
return loop
|
|
}
|
|
|
|
function flowModuleToBranch(
|
|
module: FlowModule,
|
|
branches: FlowModule[][],
|
|
edgesLabel: string[],
|
|
parent: string | NestedNodes | undefined = undefined
|
|
): Branch {
|
|
const branch: Branch = {
|
|
type: 'branch',
|
|
node: flowModuleToNode(
|
|
getParentIds(parent),
|
|
module.id,
|
|
module.summary || module.value.type == 'branchall'
|
|
? 'Run all branches'
|
|
: 'Run one branch given predicate',
|
|
'inline',
|
|
module,
|
|
undefined
|
|
),
|
|
items: []
|
|
}
|
|
const branchParent = [branch.node.id.toString()]
|
|
if (branches.length == 0) {
|
|
branch.items.push([createVirtualNode(branchParent, 'No branches')])
|
|
}
|
|
branches.forEach((modules, i) => {
|
|
const items: NestedNodes = []
|
|
if (!modules.length) {
|
|
items.push(createVirtualNode(branchParent, 'Empty branch', edgesLabel[i]))
|
|
} else {
|
|
modules.forEach((module) => {
|
|
const item = getConvertedFlowModule(
|
|
module,
|
|
items.length ? items : branch.node.id.toString(),
|
|
edgesLabel[i]
|
|
)
|
|
item && items.push(item)
|
|
})
|
|
}
|
|
items.length && branch.items.push(items)
|
|
})
|
|
return branch
|
|
}
|
|
|
|
function flattenNestedNodes(nestedNodes: NestedNodes, nodes: Node[] = []): Node[] {
|
|
const array = nodes
|
|
nestedNodes.forEach((node) => {
|
|
if (isNode(node)) {
|
|
array.push(node)
|
|
} else if (isLoop(node)) {
|
|
flattenNestedNodes(node.items, array)
|
|
} else if (isBranch(node)) {
|
|
array.push(node.node)
|
|
node.items.forEach((item) => {
|
|
flattenNestedNodes(item, array)
|
|
})
|
|
}
|
|
})
|
|
return array
|
|
}
|
|
|
|
function layoutNodes(nodes: Node[]): { nodes: Node[]; height: number } {
|
|
const stratify = dagStratify().id(({ id }: Node) => '' + id)
|
|
const dag = stratify(nodes)
|
|
const layout = sugiyama()
|
|
.decross(decrossOpt())
|
|
.coord(coordCenter())
|
|
.nodeSize(() => [NODE.width + NODE.gap.horizontal, NODE.height + NODE.gap.vertical])
|
|
const boxSize = layout(dag)
|
|
return {
|
|
nodes: dag.descendants().map((des) => ({
|
|
...des.data,
|
|
id: +des.data.id,
|
|
position: {
|
|
x: des.x ? des.x + (width - boxSize.width - NODE.width) / 2 : 0,
|
|
y: des.y || 0
|
|
}
|
|
})),
|
|
height: Math.max(boxSize.height + NODE.height, minHeight)
|
|
}
|
|
}
|
|
|
|
function createEdges(nodes: Node[]): Edge[] {
|
|
const edges: Edge[] = []
|
|
nodes.forEach((node) => {
|
|
node.parentIds.forEach((pid, i) => {
|
|
edges.push({
|
|
id: `e-${pid}-${node.id}`,
|
|
source: +pid,
|
|
target: node.id,
|
|
labelBgColor: 'white',
|
|
arrow: true,
|
|
animate: false,
|
|
noHandle: false,
|
|
label: node.edgeLabel
|
|
// type: 'smoothstep'
|
|
})
|
|
})
|
|
})
|
|
return edges
|
|
}
|
|
|
|
function createVirtualNode(parentIds: string[], label: string, edgesLabel?: string): Node {
|
|
return {
|
|
id: idGenerator.next().value,
|
|
position: { x: -1, y: -1 },
|
|
data: {
|
|
html: `
|
|
<div class="w-full max-h-full text-center ellipsize-multi-line text-2xs [-webkit-line-clamp:2] px-1">
|
|
${label}
|
|
</div>
|
|
`
|
|
},
|
|
width: NODE.width,
|
|
height: NODE.height,
|
|
borderColor: '#999',
|
|
bgColor: '#d4e4ff',
|
|
parentIds
|
|
}
|
|
}
|
|
|
|
function createErrorHandler(parentIds: string[], module: FlowModule): Node {
|
|
return {
|
|
id: -1,
|
|
position: { x: -1, y: -1 },
|
|
data: {
|
|
html: `
|
|
<div class="w-full max-h-full text-center ellipsize-multi-line text-2xs [-webkit-line-clamp:2] px-1">
|
|
Error handler
|
|
</div>
|
|
`
|
|
},
|
|
width: NODE.width,
|
|
height: NODE.height,
|
|
bgColor: 'rgb(248 113 113)',
|
|
borderColor: '#999',
|
|
|
|
parentIds,
|
|
clickCallback: (node) => {
|
|
if (!notSelectable) {
|
|
selectedNode = module.id
|
|
}
|
|
dispatch('click', module)
|
|
}
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div bind:clientWidth={width} class="w-full h-full overflow-hidden relative">
|
|
{#if width && height}
|
|
<Svelvet {nodes} {width} {edges} {height} bgColor="rgb(249 250 251)" />
|
|
{/if}
|
|
</div>
|