Files
windmill/frontend/src/lib/components/graph/FlowGraph.svelte
2022-12-05 13:09:50 +01:00

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>