Files
windmill/frontend/src/lib/components/graph/FlowGraph.svelte
2023-02-01 20:56:58 +01:00

502 lines
13 KiB
Svelte

<script lang="ts">
import Svelvet from '@windmill-labs/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,
type GraphModuleState,
type Edge
} from '.'
import { defaultIfEmptyString, truncateRev } from '$lib/utils'
import { createEventDispatcher } from 'svelte'
import { charsToNumber, 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, GraphModuleState> | undefined = undefined
let selectedNode: string | undefined = undefined
let idGenerator: Generator
let nestedNodes: NestedNodes
let nodes: Node[] = []
let edges: Edge[] = []
let width: number, height: number
let errorHandlers: Record<string, number> = {}
let dispatch = createEventDispatcher()
$: {
width && height && minHeight && selectedNode && flowModuleStates
nodes = edges = []
errorHandlers = {}
createGraph()
}
async function createGraph() {
if (modules) {
idGenerator = createIdGenerator()
} else {
nodes = edges = []
return
}
nestedNodes = nodes = []
nestedNodes.push(createVirtualNode(getParentIds(), 'Input'))
modules.forEach((m) => {
const item = getConvertedFlowModule(m)
item && nestedNodes.push(item)
})
nestedNodes.push(createVirtualNode(getParentIds(), 'Result'))
if (!flowModuleStates) {
if (failureModule) nestedNodes.push(createErrorHandler(failureModule))
} else {
Object.entries(flowModuleStates ?? [])
.filter(([k, v]) => k.startsWith('failure'))
.forEach(([k, v]) => {
nestedNodes.push(createErrorHandler({ id: k } as FlowModule, v.parent_module))
})
}
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,
insideLoop: boolean = false
): 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,
undefined,
insideLoop
)
} 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,
undefined,
insideLoop
)
} 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) => `${truncateRev(x.expr, 20)}`)],
parent,
insideLoop
)
} else if (type === 'branchall') {
const branches = module.value.branches.map((b) => b.modules)
return flowModuleToBranch(module, branches, [], parent, insideLoop)
} else if (type === 'flow') {
return flowModuleToNode(
parentIds,
module.id,
module.summary || 'Flow ' + module.value.path,
'inline',
module,
undefined,
edgeLabel,
undefined,
insideLoop
)
}
return flowModuleToNode(
parentIds,
module.id,
module.summary || 'Identity step',
'inline',
module,
undefined,
edgeLabel,
undefined,
insideLoop
)
}
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)) {
const id = numberToChars(item.id)
return [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)'
case FlowStatusModule.type.WAITING_FOR_EXECUTOR:
return 'rgb(255, 208, 193)'
default:
return '#fff'
}
}
function flowModuleToNode(
parentIds: string[],
id: string,
title: string,
host: ModuleHost,
onClickDetail: any,
lang?: RawScript.language,
edgeLabel?: string,
header?: string,
insideLoop: boolean = false
): 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)]'
let nodeId = id ?? numberToChars(idGenerator.next().value - 1)
return {
id: charsToNumber(nodeId),
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]">
${nodeId}
</span>
</div>
</div>
<div class="text-2xs absolute -top-6 text-gray-600 truncate">${
flowModuleStates?.[nodeId]?.scheduled_for ?? header ?? ''
}<div>
`
},
host,
width: insideLoop ? NODE.width * 0.8 : NODE.width,
height: NODE.height,
borderColor: selectedNode == nodeId ? 'black' : '#999',
bgColor: selectedNode == nodeId ? '#f5f5f5' : getStateColor(flowModuleStates?.[nodeId]?.type),
parentIds,
clickCallback: (node) => {
if (!notSelectable) {
selectedNode = nodeId
}
if (onClickDetail.id == undefined) {
onClickDetail.id = nodeId
}
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,
undefined,
flowModuleStates?.[module.id]?.iteration_total
? 'Iteration ' + flowModuleStates?.[module.id]?.iteration_total
: ''
)
]
}
modules.forEach((module) => {
const item = getConvertedFlowModule(module, loop.items, undefined, true)
item && loop.items.push(item)
})
loop.items.push(
createVirtualNode(
getParentIds(loop.items),
`Collect iterations' results of For Loop ${module.id ?? ''}`,
undefined,
1000
)
)
return loop
}
function flowModuleToBranch(
module: FlowModule,
branches: FlowModule[][],
edgesLabel: string[],
parent: string | NestedNodes | undefined = undefined,
insideLoop: boolean = false
): 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,
undefined,
undefined,
insideLoop
),
items: []
}
const branchParent = [numberToChars(branch.node.id)]
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, j) => {
const item = getConvertedFlowModule(
module,
items.length ? items : numberToChars(branch.node.id),
j == 0 ? edgesLabel[i] : undefined,
insideLoop
)
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) => numberToChars(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) => {
// skip virtual nodes such as collect result
if (errorHandlers[pid] && node.id < 900 && nodes.find((x) => x.id == errorHandlers[pid])) {
edges.push({
id: `e-${pid}-${node.id}`,
source: charsToNumber(pid),
target: errorHandlers[pid],
labelBgColor: 'white',
arrow: true,
animate: false,
noHandle: false,
label: node.edgeLabel
// type: 'smoothstep'
})
edges.push({
id: `e-${pid}-${node.id}`,
source: errorHandlers[pid],
target: node.id,
labelBgColor: 'white',
arrow: true,
animate: false,
noHandle: false,
label: node.edgeLabel
// type: 'smoothstep'
})
} else {
edges.push({
id: `e-${pid}-${node.id}`,
source: charsToNumber(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,
offset?: number
): Node {
const id = -idGenerator.next().value - 1 + (offset ?? 0)
return {
id,
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: selectedNode == label ? 'black' : '#999',
bgColor: selectedNode == label ? '#f5f5f5' : '#d4e4ff',
parentIds,
clickCallback: (node) => {
if (!notSelectable) {
selectedNode = label
}
dispatch('click', label)
}
}
}
function createErrorHandler(mod: FlowModule, parent_module?: string): Node {
const nId = -idGenerator.next().value - 1 + 1100
parent_module && (errorHandlers[parent_module] = nId)
return {
id: nId,
position: { x: -1, y: -1 },
data: {
html: `
<div class="w-full flex justify-between items-center px-1">
<div class="text-left ellipsize text-2xs truncate">
Error Handler
</div>
<div class="flex items-center">
<span class="center-center font-semibold bg-indigo-100 text-indigo-800 rounded px-1 pb-[2px] ml-[2px]">
${mod.id}
</span>
</div>
</div>
`
},
width: NODE.width,
height: NODE.height,
bgColor: selectedNode == mod.id ? '#f5f5f5' : getStateColor(flowModuleStates?.[mod.id]?.type),
borderColor: selectedNode == mod.id ? 'black' : '#999',
parentIds: parent_module ? [parent_module] : [],
clickCallback: (node) => {
if (!notSelectable) {
selectedNode = mod.id
}
dispatch('click', mod)
}
}
}
</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>