From c349771cb28b83aa7be630017f8ef94ba684d4e9 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 25 Nov 2024 10:10:15 -0700 Subject: [PATCH] feat: graph view with cytoscape --- frontend/console/package.json | 6 + .../src/api/modules/use-stream-modules.ts | 2 +- .../src/features/console/ConsolePage.tsx | 5 +- .../src/features/graph/NewGraphPane.tsx | 270 ++++++++++++++++++ .../console/src/features/graph/graph-utils.ts | 139 +++++++++ ftl-project.toml | 3 + pnpm-lock.yaml | 66 +++++ 7 files changed, 488 insertions(+), 3 deletions(-) create mode 100644 frontend/console/src/features/graph/NewGraphPane.tsx create mode 100644 frontend/console/src/features/graph/graph-utils.ts diff --git a/frontend/console/package.json b/frontend/console/package.json index 1168379cef..b903928156 100644 --- a/frontend/console/package.json +++ b/frontend/console/package.json @@ -43,11 +43,17 @@ "@tailwindcss/forms": "^0.5.6", "@tanstack/react-query": "^5.51.23", "@tanstack/react-query-devtools": "^5.51.23", + "@types/cytoscape": "^3.21.8", + "@types/cytoscape-dagre": "^2.3.3", + "@types/dagre": "^0.7.52", "@uiw/codemirror-theme-atomone": "^4.22.0", "@uiw/codemirror-theme-github": "^4.22.0", "@vitejs/plugin-react": "^4.0.4", "codemirror-json-schema": "0.7.0", "codemirror-json5": "^1.0.3", + "cytoscape": "^3.30.3", + "cytoscape-dagre": "^2.5.0", + "dagre": "^0.8.5", "fnv1a": "^1.1.1", "fuse.js": "^7.0.0", "highlight.js": "^11.8.0", diff --git a/frontend/console/src/api/modules/use-stream-modules.ts b/frontend/console/src/api/modules/use-stream-modules.ts index 6bfd535038..8582d1c7a7 100644 --- a/frontend/console/src/api/modules/use-stream-modules.ts +++ b/frontend/console/src/api/modules/use-stream-modules.ts @@ -7,7 +7,7 @@ import type { Module, Topology } from '../../protos/xyz/block/ftl/v1/console/con const streamModulesKey = 'streamModules' -type StreamModulesResult = { +export type StreamModulesResult = { modules: Module[] topology: Topology } diff --git a/frontend/console/src/features/console/ConsolePage.tsx b/frontend/console/src/features/console/ConsolePage.tsx index f0684022be..66443c6679 100644 --- a/frontend/console/src/features/console/ConsolePage.tsx +++ b/frontend/console/src/features/console/ConsolePage.tsx @@ -4,7 +4,8 @@ import { useModules } from '../../api/modules/use-modules' import { Loader } from '../../components/Loader' import { ResizablePanels } from '../../components/ResizablePanels' import { Config, Data, Database, Enum, Module, Secret, Verb } from '../../protos/xyz/block/ftl/v1/console/console_pb' -import { type FTLNode, GraphPane } from '../graph/GraphPane' +import type { FTLNode } from '../graph/GraphPane' +import { NewGraphPane } from '../graph/NewGraphPane' import { configPanels } from '../modules/decls/config/ConfigRightPanels' import { dataPanels } from '../modules/decls/data/DataRightPanels' import { databasePanels } from '../modules/decls/database/DatabaseRightPanels' @@ -33,7 +34,7 @@ export const ConsolePage = () => { return (
} + mainContent={} rightPanelHeader={headerForNode(selectedNode)} rightPanelPanels={panelsForNode(modules.data.modules, selectedNode, navigate)} bottomPanelContent={} diff --git a/frontend/console/src/features/graph/NewGraphPane.tsx b/frontend/console/src/features/graph/NewGraphPane.tsx new file mode 100644 index 0000000000..cab591da4c --- /dev/null +++ b/frontend/console/src/features/graph/NewGraphPane.tsx @@ -0,0 +1,270 @@ +import cytoscape from 'cytoscape' +import dagre, { type DagreLayoutOptions } from 'cytoscape-dagre' +import { useEffect, useRef, useState } from 'react' +import type React from 'react' +import { useStreamModules } from '../../api/modules/use-stream-modules' +import type { FTLNode } from './GraphPane' +import { getGraphData } from './graph-utils' + +cytoscape.use(dagre) + +interface NewGraphPaneProps { + onTapped?: (item: FTLNode | null) => void +} + +export const NewGraphPane: React.FC = () => { + // Add state for tracking current group view + // const [currentGroup, setCurrentGroup] = useState(null) + + const modules = useStreamModules() + + // Modify static data to include children + const staticData = { + modules: [ + { + id: 'mod1', + title: 'Module 1', + type: 'groupNode', + children: [ + { id: 'mod1-1', title: 'Sub Module 1.1', type: 'node' }, + { id: 'mod1-2', title: 'Sub Module 1.2', type: 'node' }, + ], + childConnections: [{ source: 'mod1-1', target: 'mod1-2' }], + }, + { + id: 'mod2', + title: 'Module 2', + type: 'groupNode', + children: [ + { id: 'mod2-1', title: 'Sub Module 2.1', type: 'node' }, + { id: 'mod2-2', title: 'Sub Module 2.2', type: 'node' }, + { id: 'mod2-3', title: 'Sub Module 2.3', type: 'node' }, + ], + childConnections: [{ source: 'mod2-1', target: 'mod2-2' }], + }, + { + id: 'mod3', + title: 'Module 3', + type: 'groupNode', + children: [ + { id: 'mod3-1', title: 'Sub Module 3.1', type: 'node' }, + { id: 'mod3-2', title: 'Sub Module 3.2', type: 'node' }, + ], + childConnections: [{ source: 'mod3-1', target: 'mod3-2' }], + }, + { + id: 'mod4', + title: 'Module 4', + type: 'groupNode', + children: [ + { id: 'mod4-1', title: 'Sub Module 4.1', type: 'node' }, + { id: 'mod4-2', title: 'Sub Module 4.2', type: 'node' }, + ], + childConnections: [{ source: 'mod4-1', target: 'mod4-2' }], + }, + ], + topology: { + connections: [ + // { source: 'mod1', target: 'mod2' }, + { source: 'mod2', target: 'mod4' }, + { source: 'mod3', target: 'mod4' }, + { source: 'mod2-1', target: 'mod1-1' }, + { source: 'mod2-2', target: 'mod1-1' }, + ], + }, + } + + const cyRef = useRef(null) + const cyInstance = useRef(null) + // const [, setSelectedNode] = React.useState(null) + const [nodePositions, setNodePositions] = useState>({}) + + // Initialize Cytoscape + useEffect(() => { + if (!cyRef.current) return + + cyInstance.current = cytoscape({ + container: cyRef.current, + userZoomingEnabled: true, + userPanningEnabled: true, + boxSelectionEnabled: false, + style: [ + { + selector: 'node', + style: { + 'background-color': '#64748b', + label: 'data(label)', + 'text-valign': 'center', + 'text-halign': 'center', + shape: 'round-rectangle', + width: '120px', + height: '40px', + 'text-wrap': 'wrap', + 'text-max-width': '100px', + 'text-overflow-wrap': 'anywhere', + 'font-size': '12px', + }, + }, + { + selector: 'edge', + style: { + width: 2, + 'line-color': '#6366f1', + 'curve-style': 'bezier', + 'target-arrow-shape': 'triangle', + 'target-arrow-color': '#6366f1', + 'arrow-scale': 1, + }, + }, + { + selector: '$node > node', + style: { + 'padding-top': '10px', + 'padding-left': '10px', + 'padding-bottom': '10px', + 'padding-right': '10px', + 'text-valign': 'top', + 'text-halign': 'center', + 'background-color': '#94a3b8', + }, + }, + { + selector: 'node[type="groupNode"]', + style: { + 'background-color': '#6366f1', + 'background-opacity': 0.8, + shape: 'round-rectangle', + width: '120px', + height: '120px', + 'text-valign': 'top', // Default position at top + 'text-halign': 'center', + 'text-wrap': 'wrap', + 'text-max-width': '100px', + 'text-overflow-wrap': 'anywhere', + 'font-size': '14px', + }, + }, + { + selector: ':parent', + style: { + 'text-valign': 'top', + 'text-halign': 'center', + 'background-opacity': 0.3, + }, + }, + { + selector: '.selected', + style: { + 'background-color': '#3b82f6', + 'border-width': 2, + 'border-color': '#60a5fa', + }, + }, + { + selector: 'node[type="node"]', + style: { + 'background-color': 'data(backgroundColor)', + shape: 'round-rectangle', + width: '100px', + height: '30px', + 'border-width': '1px', + 'border-color': '#475569', + 'text-wrap': 'wrap', + 'text-max-width': '80px', + 'text-overflow-wrap': 'anywhere', + 'font-size': '11px', + }, + }, + ], + }) + + // Update zoom level event handler + cyInstance.current.on('zoom', (evt) => { + const zoom = evt.target.zoom() + const elements = evt.target.elements() + + if (zoom < 1) { + // Hide child nodes + elements.nodes('node[type != "groupNode"]').style('opacity', 0) + + // Show only module-level edges (type="moduleConnection") + elements.edges('[type = "moduleConnection"]').style('opacity', 1) + elements.edges('[type = "childConnection"]').style('opacity', 0) + + // Move text inside and make it larger when zoomed out + elements.nodes('node[type = "groupNode"]').style({ + 'text-valign': 'center', + 'text-halign': 'center', + 'font-size': '18px', + 'text-max-width': '100px', + }) + } else { + // Show all nodes + elements.nodes().style('opacity', 1) + + // Show only verb-level edges (type="childConnection") + elements.edges('[type = "moduleConnection"]').style('opacity', 0) + elements.edges('[type = "childConnection"]').style('opacity', 1) + + // Move text to top when zoomed in + elements.nodes('node[type = "groupNode"]').style({ + 'text-valign': 'top', // Move text to top + 'text-halign': 'center', // Keep text centered horizontally + 'font-size': '14px', // Original font size + 'text-max-width': '100px', + }) + } + }) + + return () => { + cyInstance.current?.destroy() + } + }, []) + + // Modify the data loading effect + useEffect(() => { + if (!cyInstance.current) return + + const elements = getGraphData(modules.data, nodePositions) + cyInstance.current.elements().remove() + cyInstance.current.add(elements) + + // Run layout if needed + if (staticData.modules.some((module) => !nodePositions[module.id])) { + const layoutOptions: DagreLayoutOptions = { + name: 'dagre', + rankDir: 'TB', + // ranker: 'network-simplex', + nodeDimensionsIncludeLabels: true, + rankSep: 5, + nodeSep: 5, + edgeSep: 20, + padding: 5, + ranker: 'tight-tree', + spacingFactor: 2, + } + + const layout = cyInstance.current.layout(layoutOptions) + layout.run() + + // Save positions after layout is complete + layout.on('layoutstop', () => { + const newPositions = { ...nodePositions } + for (const node of cyInstance.current?.nodes() || []) { + newPositions[node.id()] = node.position() + } + setNodePositions(newPositions) + }) + } + + cyInstance.current.fit() + }, [nodePositions, modules.data]) + + // Modify event handlers + + return ( +
+
+
+ ) +} diff --git a/frontend/console/src/features/graph/graph-utils.ts b/frontend/console/src/features/graph/graph-utils.ts new file mode 100644 index 0000000000..ae8603b605 --- /dev/null +++ b/frontend/console/src/features/graph/graph-utils.ts @@ -0,0 +1,139 @@ +import type { EdgeDefinition, ElementDefinition } from 'cytoscape' +import type { StreamModulesResult } from '../../api/modules/use-stream-modules' +import type { Config, Module, Secret, Subscription, Verb } from '../../protos/xyz/block/ftl/v1/console/console_pb' + +const createParentNode = (module: Module, nodePositions: Record) => ({ + group: 'nodes' as const, + data: { + id: module.name, + label: module.name, + type: 'groupNode', + item: module, + }, + ...(nodePositions[module.name] && { + position: nodePositions[module.name], + }), +}) + +const createChildNode = ( + parentName: string, + childId: string, + childLabel: string, + childType: string, + nodePositions: Record, +) => ({ + group: 'nodes' as const, + data: { + id: childId, + label: childLabel, + type: 'node', + nodeType: childType, + parent: parentName, + item: { id: childId, name: childLabel }, + backgroundColor: childType === 'verb' ? '#e3f2fd' : childType === 'config' ? '#e8f5e9' : childType === 'secret' ? '#fce4ec' : '#ffffff', // default white + }, + ...(nodePositions[childId] && { + position: nodePositions[childId], + }), +}) + +const createModuleChildren = (module: Module, nodePositions: Record) => { + const children = [ + // Create nodes for verbs + ...(module.verbs || []).map((verb: Verb) => + createChildNode(module.name, `${module.name}-verb-${verb.verb?.name}`, verb.verb?.name || '', 'verb', nodePositions), + ), + // Create nodes for configs + ...(module.configs || []).map((config: Config) => + createChildNode(module.name, `${module.name}-config-${config.config?.name}`, config.config?.name || '', 'config', nodePositions), + ), + // Create nodes for secrets + ...(module.secrets || []).map((secret: Secret) => + createChildNode(module.name, `${module.name}-secret-${secret.secret?.name}`, secret.secret?.name || '', 'secret', nodePositions), + ), + // Create nodes for subscriptions + ...(module.subscriptions || []).map((subscription: Subscription) => + createChildNode( + module.name, + `${module.name}-subscription-${subscription.subscription?.name}`, + subscription.subscription?.name || '', + 'subscription', + nodePositions, + ), + ), + ] + return children +} + +const createChildEdge = (sourceModule: string, sourceVerb: string, targetModule: string, targetVerb: string) => ({ + group: 'edges' as const, + data: { + id: `${sourceModule}-${sourceVerb}-to-${targetModule}-${targetVerb}`, + source: `${sourceModule}-verb-${sourceVerb}`, + target: `${targetModule}-verb-${targetVerb}`, + type: 'childConnection', + }, +}) + +const createModuleEdge = (sourceModule: string, targetModule: string) => ({ + group: 'edges' as const, + data: { + id: `module-${sourceModule}-to-${targetModule}`, + source: sourceModule, + target: targetModule, + type: 'moduleConnection', + }, +}) + +const createVerbEdges = (modules: Module[]) => { + const edges: EdgeDefinition[] = [] + const moduleConnections = new Set() // Track unique module connections + + for (const module of modules) { + // For each verb in the module + for (const verb of module.verbs || []) { + // For each reference in the verb + for (const ref of verb.references || []) { + // Create verb-to-verb edge + edges.push(createChildEdge(ref.module, ref.name, module.name, verb.verb?.name || '')) + + // Track module-to-module connection + // Sort module names to ensure consistent edge IDs + const [sourceModule, targetModule] = [module.name, ref.module].sort() + moduleConnections.add(`${sourceModule}-${targetModule}`) + } + } + + for (const config of module.configs || []) { + for (const ref of config.references || []) { + edges.push(createChildEdge(ref.module, ref.name, module.name, config.config?.name || '')) + + // Track module-to-module connection + // Sort module names to ensure consistent edge IDs + const [sourceModule, targetModule] = [module.name, ref.module].sort() + moduleConnections.add(`${sourceModule}-${targetModule}`) + } + } + } + + // Create module-level edges for each unique module connection + for (const connection of moduleConnections) { + const [sourceModule, targetModule] = connection.split('-') + edges.push(createModuleEdge(sourceModule, targetModule)) + } + + return edges +} + +export const getGraphData = (modules: StreamModulesResult | undefined, nodePositions: Record = {}): ElementDefinition[] => { + if (!modules) return [] + + return [ + // Add parent nodes (modules) + ...modules.modules.map((module) => createParentNode(module, nodePositions)), + // Add all child nodes + ...modules.modules.flatMap((module) => createModuleChildren(module, nodePositions)), + // Add both verb-level and module-level edges + ...createVerbEdges(modules.modules), + ] +} diff --git a/ftl-project.toml b/ftl-project.toml index f4967c7d0e..260bff6439 100644 --- a/ftl-project.toml +++ b/ftl-project.toml @@ -18,6 +18,9 @@ disable-ide-integration = true [modules.mysql] [modules.mysql.secrets] FTL_DSN_MYSQL_TESTDB = "inline://InJvb3Q6c2VjcmV0QHRjcCgxMjcuMC4wLjE6MTMzMDYpL215c3FsX3Rlc3RkYj9hbGxvd05hdGl2ZVBhc3N3b3Jkcz1UcnVlIg" + [modules.postgres] + [modules.postgres.secrets] + FTL_DSN_POSTGRES_TESTDB = "inline://InBvc3RncmVzOi8vMTI3LjAuMC4xOjE1NDMyL3Bvc3RncmVzX3Rlc3RkYj9zc2xtb2RlPWRpc2FibGVcdTAwMjZ1c2VyPXBvc3RncmVzXHUwMDI2cGFzc3dvcmQ9c2VjcmV0Ig" [modules.test] [modules.test.configuration] [modules.test.secrets] diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6c779e6855..9dde6bc30c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -50,6 +50,15 @@ importers: '@tanstack/react-query-devtools': specifier: ^5.51.23 version: 5.61.3(@tanstack/react-query@5.61.3(react@18.3.1))(react@18.3.1) + '@types/cytoscape': + specifier: ^3.21.8 + version: 3.21.8 + '@types/cytoscape-dagre': + specifier: ^2.3.3 + version: 2.3.3 + '@types/dagre': + specifier: ^0.7.52 + version: 0.7.52 '@uiw/codemirror-theme-atomone': specifier: ^4.22.0 version: 4.23.6(@codemirror/language@6.10.4)(@codemirror/state@6.4.1)(@codemirror/view@6.35.0) @@ -65,6 +74,15 @@ importers: codemirror-json5: specifier: ^1.0.3 version: 1.0.3 + cytoscape: + specifier: ^3.30.3 + version: 3.30.3 + cytoscape-dagre: + specifier: ^2.5.0 + version: 2.5.0(cytoscape@3.30.3) + dagre: + specifier: ^0.8.5 + version: 0.8.5 fnv1a: specifier: ^1.1.1 version: 1.1.1 @@ -2046,6 +2064,12 @@ packages: '@types/connect@3.4.38': resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + '@types/cytoscape-dagre@2.3.3': + resolution: {integrity: sha512-FJBsNMbBZpqNwT6rp5leVYMevWUjnyD1QS8erNMAMWoBifvaVUklXIjE+bllLDSowjM3abXuRvljliSXUU+d1A==} + + '@types/cytoscape@3.21.8': + resolution: {integrity: sha512-6Bo9ZDrv0vfwe8Sg/ERc5VL0yU0gYvP4dgZi0fAXYkKHfyHaNqWRMcwYm3mu4sLsXbB8ZuXE75sR7qnaOL5JgQ==} + '@types/d3-array@3.2.1': resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} @@ -2139,6 +2163,9 @@ packages: '@types/d3@7.4.3': resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + '@types/dagre@0.7.52': + resolution: {integrity: sha512-XKJdy+OClLk3hketHi9Qg6gTfe1F3y+UFnHxKA2rn9Dw+oXa4Gb378Ztz9HlMgZKSxpPmn4BNVh9wgkpvrK1uw==} + '@types/doctrine@0.0.9': resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} @@ -2904,6 +2931,15 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + cytoscape-dagre@2.5.0: + resolution: {integrity: sha512-VG2Knemmshop4kh5fpLO27rYcyUaaDkRw+6PiX4bstpB+QFt0p2oauMrsjVbUamGWQ6YNavh7x2em2uZlzV44g==} + peerDependencies: + cytoscape: ^3.2.22 + + cytoscape@3.30.3: + resolution: {integrity: sha512-HncJ9gGJbVtw7YXtIs3+6YAFSSiKsom0amWc33Z7QbylbY2JGMrA0yz4EwrdTScZxnwclXeEZHzO5pxoy0ZE4g==} + engines: {node: '>=0.10'} + d3-color@3.1.0: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} @@ -2942,6 +2978,9 @@ packages: resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} engines: {node: '>=12'} + dagre@0.8.5: + resolution: {integrity: sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==} + data-urls@5.0.0: resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} engines: {node: '>=18'} @@ -3442,6 +3481,9 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + graphlib@2.1.8: + resolution: {integrity: sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==} + handlebars@4.7.8: resolution: {integrity: sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==} engines: {node: '>=0.4.7'} @@ -7422,6 +7464,12 @@ snapshots: dependencies: '@types/node': 22.9.3 + '@types/cytoscape-dagre@2.3.3': + dependencies: + '@types/cytoscape': 3.21.8 + + '@types/cytoscape@3.21.8': {} + '@types/d3-array@3.2.1': {} '@types/d3-axis@3.0.6': @@ -7539,6 +7587,8 @@ snapshots: '@types/d3-transition': 3.0.9 '@types/d3-zoom': 3.0.8 + '@types/dagre@0.7.52': {} + '@types/doctrine@0.0.9': {} '@types/eslint-scope@3.7.7': @@ -8433,6 +8483,13 @@ snapshots: csstype@3.1.3: {} + cytoscape-dagre@2.5.0(cytoscape@3.30.3): + dependencies: + cytoscape: 3.30.3 + dagre: 0.8.5 + + cytoscape@3.30.3: {} + d3-color@3.1.0: {} d3-dispatch@3.0.1: {} @@ -8469,6 +8526,11 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + dagre@0.8.5: + dependencies: + graphlib: 2.1.8 + lodash: 4.17.21 + data-urls@5.0.0: dependencies: whatwg-mimetype: 4.0.0 @@ -9023,6 +9085,10 @@ snapshots: graceful-fs@4.2.11: {} + graphlib@2.1.8: + dependencies: + lodash: 4.17.21 + handlebars@4.7.8: dependencies: minimist: 1.2.8