connectors for volumes

pull/124/head
Artem Golub 9 months ago
parent 67f75f85da
commit 9d4046b3bb

@ -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,

@ -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,

@ -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;
}
};

@ -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<IServiceNodeItem | IVolumeNodeItem>,
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<BrowserJsPlumbInstance>(null as any);
const containerRef = useRef<HTMLDivElement>();
@ -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
)
});
}
);

@ -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;
}

@ -22,7 +22,7 @@ export const nodeLibraries: INodeGroup[] = [
type: "VOLUME",
description: "Volume node",
noInputs: 0,
noOutputs: 0,
noOutputs: 1,
isActive: true
}
]

Loading…
Cancel
Save