From 9d4046b3bb539e3fdc492286a5a5c0172210f518 Mon Sep 17 00:00:00 2001 From: Artem Golub Date: Fri, 13 Feb 2026 18:10:54 +0200 Subject: [PATCH] connectors for volumes --- services/backend/src/app.py | 51 ++- services/backend/src/utils.py | 41 +++ .../frontend/src/components/Project/index.tsx | 299 ++++++++++++++---- .../frontend/src/components/useJsPlumb.ts | 115 ++++++- services/frontend/src/index.css | 34 +- services/frontend/src/utils/data/libraries.ts | 2 +- 6 files changed, 444 insertions(+), 98 deletions(-) diff --git a/services/backend/src/app.py b/services/backend/src/app.py index 4f7ce43..c815c96 100644 --- a/services/backend/src/app.py +++ b/services/backend/src/app.py @@ -30,9 +30,11 @@ from utils import ( build_network_node, build_pagination_urls, build_service_node, + build_volume_node, can_edit_project, can_read_project, extract_depends_on, + extract_service_volume_mounts, get_current_user, get_optional_current_user, get_payload, @@ -53,8 +55,6 @@ app.add_middleware( ) - - def init_database() -> None: Base.metadata.create_all(bind=engine) @@ -141,39 +141,50 @@ async def import_project( raise HTTPException(status_code=400, detail=str(error)) from error imported = yaml.safe_load(response.content) or {} + if not isinstance(imported, dict): raise HTTPException(status_code=400, detail="Imported YAML must be an object") project_data = copy.deepcopy(DEFAULT_PROJECT) - node_name_uuid: dict[str, str] = {} node_order: dict[str, int] = {} - services = ( imported.get("services", {}) if isinstance(imported.get("services"), dict) else {} ) + volumes = ( + imported.get("volumes", {}) if isinstance(imported.get("volumes"), dict) else {} + ) networks = ( imported.get("networks", {}) if isinstance(imported.get("networks"), dict) else {} ) - + service_name_uuid: dict[str, str] = {} + volume_name_uuid: dict[str, str] = {} + network_name_uuid: dict[str, str] = {} counter = 0 + for service_name in services: generated_uuid = f"service-{uuid.uuid4()}" - node_name_uuid[service_name] = generated_uuid + service_name_uuid[service_name] = generated_uuid + node_order[generated_uuid] = counter + counter += 1 + + for volume_name in volumes: + generated_uuid = f"volume-{uuid.uuid4()}" + volume_name_uuid[volume_name] = generated_uuid node_order[generated_uuid] = counter counter += 1 for network_name in networks: generated_uuid = f"network-{uuid.uuid4()}" - node_name_uuid[network_name] = generated_uuid + network_name_uuid[network_name] = generated_uuid node_order[generated_uuid] = counter counter += 1 for service_name, service_config in services.items(): - service_uuid = node_name_uuid[service_name] + service_uuid = service_name_uuid[service_name] project_data["canvas"]["nodes"][service_uuid] = build_service_node( service_name, service_uuid, @@ -182,14 +193,34 @@ async def import_project( ) for dependency in extract_depends_on(service_config.get("depends_on")): - dependency_uuid = node_name_uuid.get(dependency) + dependency_uuid = service_name_uuid.get(dependency) + if dependency_uuid: project_data["canvas"]["connections"].append( [service_uuid, dependency_uuid] ) + for volume_mount in extract_service_volume_mounts( + service_config.get("volumes") + ): + volume_uuid = volume_name_uuid.get(volume_mount) + + if volume_uuid: + project_data["canvas"]["connections"].append( + [volume_uuid, service_uuid] + ) + + for volume_name, volume_config in volumes.items(): + volume_uuid = volume_name_uuid[volume_name] + project_data["canvas"]["nodes"][volume_uuid] = build_volume_node( + volume_name, + volume_uuid, + volume_config if isinstance(volume_config, dict) else {}, + node_order[volume_uuid], + ) + for network_name, network_config in networks.items(): - network_uuid = node_name_uuid[network_name] + network_uuid = network_name_uuid[network_name] project_data["canvas"]["nodes"][network_uuid] = build_network_node( network_name, network_uuid, diff --git a/services/backend/src/utils.py b/services/backend/src/utils.py index 1584212..70c1961 100644 --- a/services/backend/src/utils.py +++ b/services/backend/src/utils.py @@ -168,6 +168,30 @@ def extract_depends_on(depends_on: Any) -> list[str]: return [] +def extract_service_volume_mounts(volumes: Any) -> list[str]: + if not isinstance(volumes, list): + return [] + + names: list[str] = [] + seen: set[str] = set() + + for mount in volumes: + source_name = "" + + if isinstance(mount, str): + source_name = mount.split(":", 1)[0].strip() + elif isinstance(mount, dict): + raw_source = mount.get("source") + if isinstance(raw_source, str): + source_name = raw_source.strip() + + if source_name and source_name not in seen: + names.append(source_name) + seen.add(source_name) + + return names + + def normalize_position(index: int) -> dict[str, int]: offset = 200 step = 150 @@ -210,6 +234,23 @@ def build_network_node( } +def build_volume_node( + node_name: str, + node_uuid: str, + volume_config: dict[str, Any], + index: int, +) -> dict[str, Any]: + return { + "key": node_uuid, + "type": "VOLUME", + "position": normalize_position(index), + "inputs": [], + "outputs": [f"op_{node_uuid}"], + "canvasConfig": {"node_name": node_name}, + "volumeConfig": volume_config, + } + + def build_pagination_urls( request: Request, limit: int, diff --git a/services/frontend/src/components/Project/index.tsx b/services/frontend/src/components/Project/index.tsx index af22248..a0575d6 100644 --- a/services/frontend/src/components/Project/index.tsx +++ b/services/frontend/src/components/Project/index.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, useRef } from "react"; import { useParams } from "react-router-dom"; -import { Dictionary, omit, remove } from "lodash"; +import { Dictionary, omit } from "lodash"; import { GlobeAltIcon, CubeIcon, @@ -223,39 +223,119 @@ export default function Project(props: IProjectProps) { return dependsOnKeys; }; - const onUpdateEndpoint = (nodeItem: IServiceNodeItem) => { + const getVolumeNodeName = (node: IVolumeNodeItem | undefined): string => { + if (!node) { + return ""; + } + + return (node.canvasConfig.node_name || + node.volumeConfig.name || + "") as string; + }; + + const getVolumeMountSourceName = (mount: any): string | null => { + if (typeof mount === "string") { + const sourceName = mount.split(":", 1)[0].trim(); + return sourceName || null; + } + + if ( + mount && + mount.constructor === Object && + typeof mount.source === "string" + ) { + const sourceName = mount.source.trim(); + return sourceName || null; + } + + return null; + }; + + const getServiceVolumeMountNames = (volumesData: any): string[] => { + if (!Array.isArray(volumesData)) { + return []; + } + + const names: string[] = []; + volumesData.forEach((mount) => { + const sourceName = getVolumeMountSourceName(mount); + if (sourceName && !names.includes(sourceName)) { + names.push(sourceName); + } + }); + + return names; + }; + + const removeServiceVolumeMount = ( + volumesData: any, + volumeName: string + ): any[] => { + if (!Array.isArray(volumesData)) { + return []; + } + + return volumesData.filter((mount) => { + return getVolumeMountSourceName(mount) !== volumeName; + }); + }; + + const onUpdateEndpoint = (nodeItem: IServiceNodeItem | IVolumeNodeItem) => { const key = nodeItem.key; - if (connections.length) { - const _connections = [...connections]; + if (nodeItem.type === "SERVICE") { + const serviceNode = nodeItem as IServiceNodeItem; + + if (connections.length) { + const filteredConnections = connections.filter((conn: any) => { + if (key === conn[0]) { + return false; + } + + if (key === conn[1] && nodes[conn[0]]?.type === "VOLUME") { + return false; + } + + return true; + }) as any; - _connections.forEach((conn: any) => { - if (key === conn[0]) { - const filtered = connections.filter((conn: any) => { - return key !== conn[0]; - }) as any; + setConnections(filteredConnections); + stateConnectionsRef.current = filteredConnections; + } - setConnections(filtered); - stateConnectionsRef.current = filtered; + const dependsOnData = serviceNode.serviceConfig.depends_on; + const dependsOnKeys = getDependsOnKeys(dependsOnData); + + dependsOnKeys.forEach((dep: string) => { + const depObject = Object.keys(nodes).find((nodeKey: string) => { + const node = nodes[nodeKey]; + return node.type === "SERVICE" && node.canvasConfig.node_name === dep; + }); + + if (depObject) { + onConnectionAttached([key, depObject]); } }); - } - const dependsOnData = nodeItem.serviceConfig.depends_on; - const dependsOnKeys = getDependsOnKeys(dependsOnData); + const volumeNames = getServiceVolumeMountNames( + serviceNode.serviceConfig.volumes + ); + + volumeNames.forEach((volumeName) => { + const sourceVolumeKey = Object.keys(nodes).find((nodeKey: string) => { + const node = nodes[nodeKey]; + if (node.type !== "VOLUME") { + return false; + } + + return getVolumeNodeName(node as IVolumeNodeItem) === volumeName; + }); - dependsOnKeys.forEach((dep: string) => { - const depObject = Object.keys(nodes).find((key: string) => { - const node = nodes[key]; - if (node.canvasConfig.node_name === dep) { - return node; + if (sourceVolumeKey) { + onConnectionAttached([sourceVolumeKey, key]); } }); - - if (depObject) { - onConnectionAttached([key, depObject]); - } - }); + } setNodes({ ...nodes, [nodeItem.key]: nodeItem }); }; @@ -269,32 +349,88 @@ export default function Project(props: IProjectProps) { } if (stateNodesRef.current) { - const sourceNode = { - ...stateNodesRef.current[data[0]] - } as IServiceNodeItem; + const sourceNode = stateNodesRef.current[data[0]]; const targetNode = stateNodesRef.current[data[1]]; - const targetServiceName = targetNode.canvasConfig.node_name; - const sourceDependsOn = sourceNode.serviceConfig.depends_on as any; + const updatedNodes = { ...stateNodesRef.current }; + let hasNodeUpdates = false; - if (targetServiceName) { - const dependsOnKeys = getDependsOnKeys(sourceDependsOn); + if (sourceNode?.type === "SERVICE" && targetNode?.type === "SERVICE") { + const sourceServiceNode = sourceNode as IServiceNodeItem; + const targetServiceName = targetNode.canvasConfig.node_name; + let sourceDependsOn = sourceServiceNode.serviceConfig.depends_on as any; + + if (targetServiceName) { + const dependsOnKeys = getDependsOnKeys(sourceDependsOn); - dependsOnKeys.forEach((key: string) => { - if (key === targetServiceName) { + if (dependsOnKeys.includes(targetServiceName)) { if (Array.isArray(sourceDependsOn)) { - remove(sourceDependsOn, (key) => key === targetServiceName); + sourceDependsOn = sourceDependsOn.filter( + (name: string) => name !== targetServiceName + ); } if (sourceDependsOn && sourceDependsOn.constructor === Object) { - delete sourceDependsOn[key]; + sourceDependsOn = { ...sourceDependsOn }; + delete sourceDependsOn[targetServiceName]; } + + const nextServiceConfig = { + ...sourceServiceNode.serviceConfig + } as any; + + if (getDependsOnKeys(sourceDependsOn).length) { + nextServiceConfig.depends_on = sourceDependsOn; + } else { + delete nextServiceConfig.depends_on; + } + + updatedNodes[data[0]] = { + ...sourceServiceNode, + serviceConfig: nextServiceConfig + }; + hasNodeUpdates = true; } - }); + } + } + + if (sourceNode?.type === "VOLUME" && targetNode?.type === "SERVICE") { + const volumeNodeName = getVolumeNodeName(sourceNode as IVolumeNodeItem); + const targetServiceNode = targetNode as IServiceNodeItem; + + if (volumeNodeName) { + const nextVolumes = removeServiceVolumeMount( + targetServiceNode.serviceConfig.volumes, + volumeNodeName + ); + + if ( + Array.isArray(targetServiceNode.serviceConfig.volumes) && + nextVolumes.length !== + targetServiceNode.serviceConfig.volumes.length + ) { + const nextServiceConfig = { + ...targetServiceNode.serviceConfig + } as any; + + if (nextVolumes.length) { + nextServiceConfig.volumes = nextVolumes; + } else { + delete nextServiceConfig.volumes; + } - if (!getDependsOnKeys(sourceDependsOn).length) { - delete sourceNode.serviceConfig.depends_on; + updatedNodes[data[1]] = { + ...targetServiceNode, + serviceConfig: nextServiceConfig + }; + hasNodeUpdates = true; + } } } + + if (hasNodeUpdates) { + setNodes(updatedNodes); + stateNodesRef.current = updatedNodes as any; + } } const _connections: [[string, string]] = [ @@ -311,35 +447,82 @@ export default function Project(props: IProjectProps) { const onConnectionAttached = (data: any) => { if (stateNodesRef.current) { - const sourceNode = { - ...stateNodesRef.current[data[0]] - } as IServiceNodeItem; + const sourceNode = stateNodesRef.current[data[0]]; const targetNode = stateNodesRef.current[data[1]]; - const targetServiceName = targetNode.canvasConfig.node_name; - let sourceDependsOn = sourceNode.serviceConfig.depends_on as any; - const dependsOnKeys = getDependsOnKeys(sourceDependsOn); + const updatedNodes = { ...stateNodesRef.current }; + let hasNodeUpdates = false; + + if (sourceNode?.type === "SERVICE" && targetNode?.type === "SERVICE") { + const sourceServiceNode = sourceNode as IServiceNodeItem; + const targetServiceName = targetNode.canvasConfig.node_name; + let sourceDependsOn = sourceServiceNode.serviceConfig.depends_on as any; + const dependsOnKeys = getDependsOnKeys(sourceDependsOn); - if (sourceDependsOn) { if (targetServiceName) { - if (!dependsOnKeys.includes(targetServiceName)) { - if (Array.isArray(sourceDependsOn)) { - sourceDependsOn.push(targetServiceName); - } + if (sourceDependsOn) { + if (!dependsOnKeys.includes(targetServiceName)) { + if (Array.isArray(sourceDependsOn)) { + sourceDependsOn = [...sourceDependsOn, targetServiceName]; + } - if (sourceDependsOn.constructor === Object) { - sourceDependsOn[targetServiceName] = { - condition: "service_healthy" - }; + if (sourceDependsOn.constructor === Object) { + sourceDependsOn = { ...sourceDependsOn }; + sourceDependsOn[targetServiceName] = { + condition: "service_healthy" + }; + } } + } else { + sourceDependsOn = [targetServiceName]; } + + updatedNodes[data[0]] = { + ...sourceServiceNode, + serviceConfig: { + ...sourceServiceNode.serviceConfig, + depends_on: sourceDependsOn + } + }; + hasNodeUpdates = true; } - } else { - if (targetServiceName) { - sourceDependsOn = [targetServiceName]; + } + + if (sourceNode?.type === "VOLUME" && targetNode?.type === "SERVICE") { + const sourceVolumeName = getVolumeNodeName( + sourceNode as IVolumeNodeItem + ); + const targetServiceNode = targetNode as IServiceNodeItem; + const currentVolumeNames = getServiceVolumeMountNames( + targetServiceNode.serviceConfig.volumes + ); + + if ( + sourceVolumeName && + !currentVolumeNames.includes(sourceVolumeName) + ) { + const currentVolumes = Array.isArray( + targetServiceNode.serviceConfig.volumes + ) + ? [...targetServiceNode.serviceConfig.volumes] + : []; + + currentVolumes.push(sourceVolumeName); + + updatedNodes[data[1]] = { + ...targetServiceNode, + serviceConfig: { + ...targetServiceNode.serviceConfig, + volumes: currentVolumes + } + }; + hasNodeUpdates = true; } } - sourceNode.serviceConfig.depends_on = sourceDependsOn; + if (hasNodeUpdates) { + setNodes(updatedNodes); + stateNodesRef.current = updatedNodes as any; + } } if (stateConnectionsRef.current && stateConnectionsRef.current.length > 0) { @@ -351,8 +534,10 @@ export default function Project(props: IProjectProps) { _connections.push(data); } setConnections(_connections); + stateConnectionsRef.current = _connections; } else { setConnections([data]); + stateConnectionsRef.current = [data] as any; } }; diff --git a/services/frontend/src/components/useJsPlumb.ts b/services/frontend/src/components/useJsPlumb.ts index 59d6d4e..9c24be0 100644 --- a/services/frontend/src/components/useJsPlumb.ts +++ b/services/frontend/src/components/useJsPlumb.ts @@ -31,6 +31,31 @@ import { IServiceNodeItem, IVolumeNodeItem } from "../types"; import { Dictionary, isEqual } from "lodash"; import { IAnchor, CallbackFunction } from "../types"; +const defaultConnectionPaintStyle = { + strokeWidth: 2, + stroke: "#61B7CF" +}; + +const defaultConnectionHoverStyle = { + strokeWidth: 3, + stroke: "#216477" +}; + +const volumeConnectionPaintStyle = { + strokeWidth: 2, + stroke: "#ad35ff" +}; + +const volumeConnectionHoverStyle = { + strokeWidth: 3, + stroke: "#7d0fc8" +}; + +interface IPaintableConnection { + setPaintStyle: (style: any) => void; + setHoverPaintStyle: (style: any) => void; +} + export const useJsPlumb = ( nodes: Dictionary, connections: Array<[string, string]>, @@ -42,7 +67,7 @@ export const useJsPlumb = ( (containerElement: HTMLDivElement) => void, (zoom: number) => void, (style: any) => void, - (node: IServiceNodeItem) => void + (node: IServiceNodeItem | IVolumeNodeItem) => void ] => { const [instance, setInstance] = useState(null as any); const containerRef = useRef(); @@ -133,7 +158,10 @@ export const useJsPlumb = ( ); }; - const getOverlayObject = (instance: BrowserJsPlumbInstance) => { + const getOverlayObject = ( + instance: BrowserJsPlumbInstance, + isVolumeConnection = false + ) => { return { type: "Label", options: { @@ -141,18 +169,54 @@ export const useJsPlumb = ( location: 0.5, id: "remove-conn", cssClass: ` - block jtk-overlay remove-conn-btn text-xs leading-normal cursor-pointer + block jtk-overlay ${ + isVolumeConnection ? "remove-conn-btn-volume" : "remove-conn-btn" + } text-xs leading-normal cursor-pointer text-white font-bold rounded-full w-5 h-5 z-20 flex justify-center `, events: { click: (e: any) => { - instance.deleteConnection(e.overlay.component as any); + const connection = e?.overlay?.component as any; + const activeInstance = + instanceRef.current || connection?.instance || instance; + + if ( + activeInstance && + typeof (activeInstance as any).deleteConnection === "function" + ) { + (activeInstance as any).deleteConnection(connection); + } } } } }; }; + const isVolumeToServiceConnection = ( + sourceId: string, + targetId: string + ): boolean => { + const sourceNode = stateRef.current?.[sourceId]; + const targetNode = stateRef.current?.[targetId]; + + return sourceNode?.type === "VOLUME" && targetNode?.type === "SERVICE"; + }; + + const applyConnectionStyle = ( + connection: IPaintableConnection, + sourceId: string, + targetId: string + ) => { + if (isVolumeToServiceConnection(sourceId, targetId)) { + connection.setPaintStyle(volumeConnectionPaintStyle as any); + connection.setHoverPaintStyle(volumeConnectionHoverStyle as any); + return; + } + + connection.setPaintStyle(defaultConnectionPaintStyle as any); + connection.setHoverPaintStyle(defaultConnectionHoverStyle as any); + }; + const setZoom = useCallback( (zoom: number) => { if (instance) { @@ -312,16 +376,24 @@ export const useJsPlumb = ( } }); + (instance.getConnections({}, true) as Connection[]).forEach((conn) => { + applyConnectionStyle(conn, conn.sourceId, conn.targetId); + }); + connections.forEach((x) => { const c = currentConnectionUuids.find((y) => { return isEqual([`op_${x[0]}`, `ip_${x[1]}`], y); }); if (!c) { - instance.connect({ + const connection = instance.connect({ uuids: [`op_${x[0]}`, `ip_${x[1]}`], - overlays: [getOverlayObject(instance)] + overlays: [ + getOverlayObject(instance, isVolumeToServiceConnection(x[0], x[1])) + ] }); + + applyConnectionStyle(connection as Connection, x[0], x[1]); } }); }, [connections, instance]); @@ -349,38 +421,47 @@ export const useJsPlumb = ( jsPlumbInstance.bind( EVENT_CONNECTION_DETACHED, - function ( - this: BrowserJsPlumbInstance, - params: ConnectionDetachedParams - ) { + function (params: ConnectionDetachedParams) { onConnectionDetached([params.sourceId, params.targetId]); onGraphUpdate({ nodes: stateRef.current, - connections: getConnections(this.getConnections({}, true) as any) + connections: getConnections( + jsPlumbInstance.getConnections({}, true) as any + ) }); } ); jsPlumbInstance.bind( EVENT_CONNECTION, - function ( - this: BrowserJsPlumbInstance, - params: ConnectionEstablishedParams - ) { + function (params: ConnectionEstablishedParams) { if ( !Object.prototype.hasOwnProperty.call( params.connection.overlays, "remove-conn" ) ) { - params.connection.addOverlay(getOverlayObject(this)); + params.connection.addOverlay( + getOverlayObject( + jsPlumbInstance, + isVolumeToServiceConnection(params.sourceId, params.targetId) + ) + ); onConnectionAttached([params.sourceId, params.targetId]); } + applyConnectionStyle( + params.connection, + params.sourceId, + params.targetId + ); + onGraphUpdate({ nodes: stateRef.current, - connections: getConnections(this.getConnections({}, true) as any) + connections: getConnections( + jsPlumbInstance.getConnections({}, true) as any + ) }); } ); diff --git a/services/frontend/src/index.css b/services/frontend/src/index.css index 0014b76..32882df 100644 --- a/services/frontend/src/index.css +++ b/services/frontend/src/index.css @@ -6,9 +6,9 @@ body { margin: 0; height: 100%; overflow: hidden; - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", + "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } @@ -21,9 +21,9 @@ body { .jsplumb-box { background-size: 16px 16px; - background-image: - linear-gradient(to right, #80808014 1px, transparent 1px), - linear-gradient(to bottom, #80808014 1px, transparent 1px); + background-image: + linear-gradient(to right, #80808014 1px, transparent 1px), + linear-gradient(to bottom, #80808014 1px, transparent 1px); position: relative; width: 100%; height: calc(100vh - 64px); /* 64px is the bar above the canvas */ @@ -75,15 +75,23 @@ path, } .remove-conn-btn { - background-color: #61B7CF; + background-color: #61b7cf; } .remove-conn-btn:hover { background-color: #ce4551; } +.remove-conn-btn-volume { + background-color: #ad35ff; +} + +.remove-conn-btn-volume:hover { + background-color: #ce4551; +} + .code-column { - background-color: #1F2937; + background-color: #1f2937; } .cke_reset_all .CodeMirror-scroll * { @@ -114,7 +122,7 @@ path, @apply inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-indigo-700 bg-indigo-100 hover:bg-indigo-200 focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-indigo-500; } .lbl-util { - @apply block text-xs font-bold text-gray-700 mb-1 + @apply block text-xs font-bold text-gray-700 mb-1; } .btn-util-red { @apply inline-flex items-center px-2.5 py-1.5 border border-transparent text-xs font-medium rounded text-red-700 bg-red-100 hover:bg-red-200 focus:outline-none focus:ring-1 focus:ring-offset-1 focus:ring-red-500; @@ -123,14 +131,14 @@ path, @apply text-white bg-indigo-500 hover:bg-indigo-500 focus:ring-indigo-500; } .input-util { - @apply shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md px-2 py-1 + @apply shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md px-2 py-1; } .checkbox-util { - @apply shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block sm:text-sm border-gray-300 rounded-md px-2 py-1 + @apply shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block sm:text-sm border-gray-300 rounded-md px-2 py-1; } } code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; + font-family: + source-code-pro, Menlo, Monaco, Consolas, "Courier New", monospace; } diff --git a/services/frontend/src/utils/data/libraries.ts b/services/frontend/src/utils/data/libraries.ts index 588d622..fa48741 100644 --- a/services/frontend/src/utils/data/libraries.ts +++ b/services/frontend/src/utils/data/libraries.ts @@ -22,7 +22,7 @@ export const nodeLibraries: INodeGroup[] = [ type: "VOLUME", description: "Volume node", noInputs: 0, - noOutputs: 0, + noOutputs: 1, isActive: true } ]