diff --git a/back/src/net_utils/vlan.py b/back/src/net_utils/vlan.py index 41ba4b3b..a2bbc337 100644 --- a/back/src/net_utils/vlan.py +++ b/back/src/net_utils/vlan.py @@ -2,6 +2,7 @@ from ipmininet.ipovs_switch import IPOVSSwitch from ipmininet.ipswitch import IPSwitch from network_schema import Node, NodeInterface +from node_types import NodeType def setup_vlans(net: IPNet, nodes: list[Node]) -> None: @@ -14,7 +15,7 @@ def setup_vlans(net: IPNet, nodes: list[Node]) -> None: """ for node in nodes: - if node.config.type == "l2_switch": + if node.config.type == NodeType.SWITCH: switch = net.get(node.data.id) add_bridge(switch, node.interface) diff --git a/back/src/net_utils/vxlan.py b/back/src/net_utils/vxlan.py index 0d6ff2df..082429cd 100644 --- a/back/src/net_utils/vxlan.py +++ b/back/src/net_utils/vxlan.py @@ -2,6 +2,7 @@ from ipmininet.ipnet import IPNet from network_schema import Node +from node_types import NodeType def setup_vtep_interfaces(net: IPNet, nodes: list[Node]) -> None: @@ -13,7 +14,7 @@ def setup_vtep_interfaces(net: IPNet, nodes: list[Node]) -> None: nodes (list[Node]): A list of nodes to configure. """ for node in nodes: - if node.config.type == "router": + if node.config.type == NodeType.ROUTER: router = net.get(node.data.id) # Configure VXLAN network interfaces (connection_type == 1) diff --git a/back/src/network_topology.py b/back/src/network_topology.py index a0b71caf..2eac9af4 100644 --- a/back/src/network_topology.py +++ b/back/src/network_topology.py @@ -7,6 +7,7 @@ from ipmininet.router.config import RouterConfig from network_schema import Network, Node, NodeConfig, NodeInterface from pkt_parser import is_ipv4_address +from node_types import NodeType class MiminetTopology(IPTopo): @@ -47,14 +48,17 @@ def __handle_node(self, node: Node): node_type: str = config.type # network device type node_id: str = node.data.id # network device name(label) - if node_type == "l2_switch": + if node_type == NodeType.SWITCH: self.__handle_l2_switch(node_id, config) - elif node_type in ("host", "server"): + elif node_type in (NodeType.HOST, NodeType.SERVER): self.__handle_host_or_server(node_id, config) - elif node_type == "l1_hub": + elif node_type == NodeType.HUB: self.__handle_l1_hub(node_id) - elif node_type == "router": + elif node_type == NodeType.ROUTER: self.__handle_router(node_id, config) + else: + print(f"Unknown node type: {node_type}") + return def __handle_l2_switch(self, node_id: str, config: NodeConfig): assert config.stp in (0, 1, 2), "Incorrect STP mode" @@ -127,6 +131,8 @@ def build(self, *args, **kwargs): interfaces = [] for node in self.__network.nodes: + if node.config.type == "textbox": + continue # Caches node by ID for quick lookup later self.__id_to_node[node.data.id] = node diff --git a/back/src/node_types.py b/back/src/node_types.py new file mode 100644 index 00000000..4c6d8bf5 --- /dev/null +++ b/back/src/node_types.py @@ -0,0 +1,13 @@ +from enum import Enum + + +class NodeType(str, Enum): + """ + Types of all functional network nodes + """ + + HOST = "host" + SERVER = "server" + SWITCH = "l2_switch" + HUB = "l1_hub" + ROUTER = "router" diff --git a/back/src/tasks.py b/back/src/tasks.py index afefd08f..bd77607e 100644 --- a/back/src/tasks.py +++ b/back/src/tasks.py @@ -2,7 +2,10 @@ import os import signal +from marshmallow import Schema import marshmallow_dataclass + +from node_types import NodeType from celery_app import ( SEND_NETWORK_RESPONSE_EXCHANGE, SEND_NETWORK_RESPONSE_ROUTING_KEY, @@ -12,6 +15,25 @@ from mininet.log import error, setLogLevel from network_schema import Network +_network_schema: Schema | None = None + + +def _filter_unknown_nodes(data: dict) -> dict: + allowed = set(NodeType) + data["nodes"] = [ + node + for node in data.get("nodes", []) + if node.get("config", {}).get("type") in allowed + ] + return data + + +def get_network_schema() -> Schema: + global _network_schema + if _network_schema is None: + _network_schema = marshmallow_dataclass.class_schema(Network)() + return _network_schema + def run_miminet(network_json: str): """Load network from JSON and start emulation safely. @@ -30,9 +52,9 @@ def run_miminet(network_json: str): print("Set default handler to SIGCHLD") signal.signal(signal.SIGCHLD, signal.SIG_IGN) - jnet = json.loads(network_json) - network_schema = marshmallow_dataclass.class_schema(Network)() - network_json = network_schema.load(jnet, unknown="include") + jnet = _filter_unknown_nodes(json.loads(network_json)) + schema = get_network_schema() + network_json = schema.load(jnet, unknown="include") for _ in range(4): try: diff --git a/front/src/app.py b/front/src/app.py index defa67a8..69893275 100644 --- a/front/src/app.py +++ b/front/src/app.py @@ -61,6 +61,7 @@ save_edge_config, save_host_config, save_hub_config, + save_textbox_config, save_router_config, save_server_config, save_switch_config, @@ -315,6 +316,9 @@ def get_database_uri(mode): app.add_url_rule( "/host/hub_save_config", methods=["GET", "POST"], view_func=save_hub_config ) +app.add_url_rule( + "/host/textbox_save_config", methods=["GET", "POST"], view_func=save_textbox_config +) app.add_url_rule( "/host/switch_save_config", methods=["GET", "POST"], view_func=save_switch_config ) diff --git a/front/src/configurators.py b/front/src/configurators.py index c157c245..2b27ef68 100644 --- a/front/src/configurators.py +++ b/front/src/configurators.py @@ -1,7 +1,7 @@ import ipaddress import json import uuid -from abc import abstractmethod +from abc import ABC, abstractmethod from typing import Callable, Optional from celery_app import app @@ -159,7 +159,7 @@ def configure(self) -> dict[str, object]: # add args to response for i, conf_arg in enumerate(configured_args): - response[f"arg_{i+1}"] = conf_arg + response[f"arg_{i + 1}"] = conf_arg return response @@ -179,35 +179,13 @@ def __init__(self, message): super().__init__(message) -class AbstractDeviceConfigurator: - def __init__(self, device_type: str): - self.__jobs: dict[int, JobConfigurator] = {} - self._device_type: str = device_type - self._device_node = None # current device node in miminet network - - __MAX_JOBS_COUNT: int = 30 - __SLEEP_JOB_ID: int = 7 - __MAX_SLEEP_TIME: int = 60 - - def create_job(self, job_id: int, job_sign: str) -> JobConfigurator: - """ - Create job for current network device - Args: - job_id (int): ID that is assigned to the job in the select list (see static/config*.html files) - job_sign (str): label that will be displayed above the device after adding a job - """ - if job_id in self.__jobs.keys(): - raise ValueError("This job already added") - - self.__jobs[job_id] = JobConfigurator(job_id, job_sign) - return self.__jobs[job_id] - - # [!] I have broken device configuration process into different blocks, they will be below - - def _conf_prepare(self): - """Prepare variables for configuring""" +class AbstractConfigurator(ABC): + def __init__(self, element_type: str): + self._element_type: str = element_type + self._cur_network: Network | None = None + self._json_network: dict = {} - # check request correctness + def _conf_prepare_network(self): if request.method != "POST": raise ConfigurationError("Неверный запрос: ожидался POST") @@ -226,50 +204,92 @@ def _conf_prepare(self): if not self._cur_network: raise ConfigurationError("Сеть не найдена") - # get element (host, hub, switch, ...) - element_form_id = f"{self._device_type}_id" - device_id = get_data(element_form_id) - - if not device_id: - raise ConfigurationError(f"Не указан параметр {element_form_id}") - # json representation self._json_network: dict = json.loads(self._cur_network.network) - self._nodes: list = self._json_network["nodes"] - # find all matches with device in nodes - filt_nodes = list(filter(lambda n: n["data"]["id"] == device_id, self._nodes)) + def __conf_sims_delete(self): + """Delete saved simulations. Typically used at the end of the configuration""" + sims = Simulate.query.filter(Simulate.network_id == self._cur_network.id).all() + for s in sims: + app.control.revoke(s.task_guid, terminate=False) + db.session.delete(s) - if not filt_nodes and self._device_type != "edge": - raise ConfigurationError(f"Такого '{self._device_type}' не существует") + self._cur_network.network = json.dumps(self._json_network) + db.session.commit() - # current device's node - if self._device_type != "edge": - self._device_node = filt_nodes[0] + @abstractmethod + def _configure(self) -> dict: + """Configuration main block in which the entire configuration process takes place""" + pass + + def configure(self) -> Response: + """Configure current network device""" + try: + res = self._configure() # configuration result + self.__conf_sims_delete() + return make_response(jsonify(res), 200) + except ConfigurationError as e: + return make_response(jsonify({"message": str(e)}), 400) + + +class AbstractNodeConfigurator(AbstractConfigurator): + def __init__(self, element_type: str): + super().__init__(element_type) + self._node = None + self._nodes: list = [] + + def _conf_prepare_node(self): + self._conf_prepare_network() + self._nodes = self._json_network.get("nodes", []) + + element_form_id = f"{self._element_type}_id" + node_id = get_data(element_form_id) + + if not node_id: + raise ConfigurationError(f"Не указан параметр {element_form_id}") + + filt_nodes = list(filter(lambda n: n["data"]["id"] == node_id, self._nodes)) + + if not filt_nodes and self._element_type != "edge": + raise ConfigurationError(f"Такого '{self._element_type}' не существует") + + self._node = filt_nodes[0] def _conf_label_update(self): """Update device label(name). Typically used at the end of the configuration""" # get label with device name - label = get_data(f"config_{self._device_type}_name") + label = get_data(f"config_{self._element_type}_name") if label: - self._device_node["config"]["label"] = label - self._device_node["data"]["label"] = self._device_node["config"]["label"] + self._node["config"]["label"] = label + self._node["data"]["label"] = self._node["config"]["label"] - def __conf_sims_delete(self): - """Delete saved simulations. Typically used at the end of the configuration""" - # Remove all previous simulations (after configuration update) - sims = Simulate.query.filter(Simulate.network_id == self._cur_network.id).all() - for s in sims: - app.control.revoke(s.task_guid, terminate=False) - db.session.delete(s) - self._cur_network.network = json.dumps(self._json_network) - db.session.commit() +class AbstractDeviceConfigurator(AbstractNodeConfigurator): + __MAX_JOBS_COUNT: int = 30 + __SLEEP_JOB_ID: int = 7 + __MAX_SLEEP_TIME: int = 60 + + def __init__(self, device_type: str): + super().__init__(device_type) + self.__jobs: dict[int, JobConfigurator] = {} + + def create_job(self, job_id: int, job_sign: str) -> JobConfigurator: + """ + Create job for current network device + Args: + job_id (int): ID that is assigned to the job in the select list (see static/config*.html files) + job_sign (str): label that will be displayed above the device after adding a job + """ + if job_id in self.__jobs.keys(): + raise ValueError("This job already added") + + self.__jobs[job_id] = JobConfigurator(job_id, job_sign) + return self.__jobs[job_id] def _conf_jobs(self): """Configure jobs added to the device""" - job_id_str = get_data(f"config_{self._device_type}_job_select_field") + job_id_str = get_data(f"config_{self._element_type}_job_select_field") if not job_id_str: return @@ -309,7 +329,7 @@ def _conf_jobs(self): job_level = len(self._json_network["jobs"]) job_conf_res["level"] = job_level - job_conf_res["host_id"] = self._device_node["data"]["id"] + job_conf_res["host_id"] = self._node["data"]["id"] sleep_job_list = [ job for job in self._json_network["jobs"] @@ -342,13 +362,13 @@ def _conf_ip_addresses(self): """Configurate device IP-addresses""" # all interfaces - iface_ids = request.form.getlist(f"config_{self._device_type}_iface_ids[]") + iface_ids = request.form.getlist(f"config_{self._element_type}_iface_ids[]") for iface_id in iface_ids: - if not self._device_node["interface"]: + if not self._node["interface"]: return # we have nothing to configure filtered_ifaces = list( - filter(lambda x: x["id"] == iface_id, self._device_node["interface"]) + filter(lambda x: x["id"] == iface_id, self._node["interface"]) ) if not filtered_ifaces: @@ -356,8 +376,8 @@ def _conf_ip_addresses(self): interface = filtered_ifaces[0] - ip_value = get_data(f"config_{self._device_type}_ip_{str(iface_id)}") - mask_value = get_data(f"config_{self._device_type}_mask_{str(iface_id)}") + ip_value = get_data(f"config_{self._element_type}_ip_{str(iface_id)}") + mask_value = get_data(f"config_{self._element_type}_mask_{str(iface_id)}") if not ip_value: continue @@ -388,28 +408,14 @@ def _conf_ip_addresses(self): interface["netmask"] = mask_value def _conf_gw(self): - default_gw = get_data(f"config_{self._device_type}_default_gw") + default_gw = get_data(f"config_{self._element_type}_default_gw") if default_gw: if not self.__ip_check(default_gw): raise ArgCheckError("Неверно указан IP-адрес для шлюза по умолчанию") - self._device_node["config"]["default_gw"] = default_gw + self._node["config"]["default_gw"] = default_gw else: - self._device_node["config"]["default_gw"] = "" - - @abstractmethod - def _configure(self) -> dict: - """Configuration main block in which the entire configuration process takes place""" - pass - - def configure(self) -> Response: - """Configure current network device""" - try: - res = self._configure() # configuration result - self.__conf_sims_delete() - return make_response(jsonify(res), 200) - except ConfigurationError as e: - return make_response(jsonify({"message": str(e)}), 400) + self._node["config"]["default_gw"] = "" class HubConfigurator(AbstractDeviceConfigurator): @@ -417,18 +423,52 @@ def __init__(self): super().__init__(device_type="hub") def _configure(self): - self._conf_prepare() + self._conf_prepare_node() self._conf_label_update() return {"message": "Конфигурация обновлена", "nodes": self._nodes} +class TextboxConfigurator(AbstractNodeConfigurator): + def __init__(self): + super().__init__(element_type="textbox") + + def _conf_content_update(self): + label = get_data(f"config_{self._element_type}_content") + fontsize = get_data("config_textbox_font_size") + font_color = get_data("config_textbox_font_color") + font_style = get_data("config_textbox_font_style") + font_weight = get_data("config_textbox_font_weight") + + if label: + self._node["config"]["label"] = label + self._node["data"]["label"] = self._node["config"]["label"] + + if fontsize: + self._node["config"]["tb_fontsize"] = int(fontsize) + + if font_color: + self._node["config"]["color"] = font_color + + if font_style: + self._node["config"]["fontstyle"] = font_style + + if font_weight: + self._node["config"]["fontweight"] = font_weight + + def _configure(self): + self._conf_prepare_node() + self._conf_content_update() + + return {"message": "Конфигурация обновлена", "nodes": self._nodes} + + class SwitchConfigurator(AbstractDeviceConfigurator): def __init__(self): super().__init__(device_type="switch") def _configure(self): - self._conf_prepare() + self._conf_prepare_node() self._conf_label_update() res = {} try: # catch argument check errors @@ -438,17 +478,17 @@ def _configure(self): # RSTP/STP setup switch_stp = get_data("config_rstp_stp") - self._device_node["config"]["stp"] = 0 + self._node["config"]["stp"] = 0 if switch_stp and switch_stp == "1": - self._device_node["config"]["stp"] = 1 + self._node["config"]["stp"] = 1 elif switch_stp and switch_stp == "2": - self._device_node["config"]["stp"] = 2 + self._node["config"]["stp"] = 2 stp_priority = get_data("config_stp_priority") if stp_priority: - self._device_node["config"]["priority"] = int(stp_priority) + self._node["config"]["priority"] = int(stp_priority) res.update( { "message": "Конфигурация обновлена", @@ -464,7 +504,7 @@ def __init__(self): super().__init__(device_type="host") def _configure(self): - self._conf_prepare() + self._conf_prepare_node() self._conf_label_update() res = {} @@ -491,34 +531,34 @@ class RouterConfigurator(HostConfigurator): # router has the same configuration method as host def __init__(self): super().__init__() - self._device_type = "router" + self._element_type = "router" class ServerConfigurator(HostConfigurator): # server has the same configuration method as host def __init__(self): super().__init__() - self._device_type = "server" + self._element_type = "server" -class EdgeConfigurator(AbstractDeviceConfigurator): +class EdgeConfigurator(AbstractConfigurator): def __init__(self): - super().__init__(device_type="edge") + super().__init__(element_type="edge") def _configure(self): - self._conf_prepare() + self._conf_prepare_network() self._update_network_issue() return { "message": "Конфигурация обновлена", - "edges": self._json_network["edges"], - "nodes": self._nodes, - "jobs": self._json_network["jobs"], + "edges": self._json_network.get("edges", []), + "nodes": self._json_network.get("nodes", []), + "jobs": self._json_network.get("jobs", []), } def _update_network_issue(self): - loss = int(get_data("edge_loss")) - duplicate = int(get_data("edge_duplicate")) + loss = int(get_data("edge_loss") or 0) + duplicate = int(get_data("edge_duplicate") or 0) edge_id = get_data("edge_id") for edge in self._json_network["edges"]: diff --git a/front/src/miminet_host.py b/front/src/miminet_host.py index fb2ae734..d3b6de04 100755 --- a/front/src/miminet_host.py +++ b/front/src/miminet_host.py @@ -8,6 +8,7 @@ EdgeConfigurator, HostConfigurator, HubConfigurator, + TextboxConfigurator, RouterConfigurator, ServerConfigurator, SwitchConfigurator, @@ -186,6 +187,7 @@ def build_error(error_type: str, cmd: str) -> str: router = RouterConfigurator() server = ServerConfigurator() edge = EdgeConfigurator() +textbox = TextboxConfigurator() # --- Jobs --- @@ -517,6 +519,11 @@ def save_router_config(): return router.configure() +@jwt_required() +def save_textbox_config(): + return textbox.configure() + + @jwt_required() def save_server_config(): return server.configure() diff --git a/front/src/miminet_model.py b/front/src/miminet_model.py index 2a21ecf0..f927d8c2 100644 --- a/front/src/miminet_model.py +++ b/front/src/miminet_model.py @@ -30,7 +30,7 @@ db = SQLAlchemy(metadata=metadata) -class User(db.Model, UserMixin): # type:ignore[name-defined] +class User(db.Model, UserMixin): # type: ignore[name-defined] id = db.Column(BigInteger, primary_key=True, unique=True, autoincrement=True) role = db.Column(BigInteger, default=0, nullable=True) @@ -50,7 +50,7 @@ class User(db.Model, UserMixin): # type:ignore[name-defined] ai_keys = db.Column(Text, nullable=True) -class Network(db.Model): # type:ignore[name-defined] +class Network(db.Model): # type: ignore[name-defined] id = db.Column(BigInteger, primary_key=True, autoincrement=True) author_id = db.Column(BigInteger, ForeignKey("user.id"), nullable=False) @@ -69,7 +69,7 @@ class Network(db.Model): # type:ignore[name-defined] is_task = db.Column(Boolean, default=False, nullable=False) -class Simulate(db.Model): # type:ignore[name-defined] +class Simulate(db.Model): # type: ignore[name-defined] id = db.Column(BigInteger, primary_key=True, autoincrement=True) network_id = db.Column(BigInteger, ForeignKey("network.id"), nullable=False) task_guid = db.Column(Text, nullable=True, default="") @@ -81,7 +81,7 @@ class Simulate(db.Model): # type:ignore[name-defined] # Add new record to this table when you put a new simulation # Set ready flag to True when simulation is over # simulate_end will autp-update -class SimulateLog(db.Model): # type:ignore[name-defined] +class SimulateLog(db.Model): # type: ignore[name-defined] id = db.Column(BigInteger, primary_key=True) author_id = db.Column(BigInteger, nullable=False) network_guid = db.Column(Text, nullable=False) diff --git a/front/src/quiz/entity/entity.py b/front/src/quiz/entity/entity.py index a6c42648..e6ac8a92 100644 --- a/front/src/quiz/entity/entity.py +++ b/front/src/quiz/entity/entity.py @@ -91,7 +91,7 @@ def created_by_user(cls): return db.relationship("User") -class Organization(db.Model): # type:ignore[name-defined] +class Organization(db.Model): # type: ignore[name-defined] __tablename__ = "organization" id = db.Column(BigInteger, primary_key=True) @@ -112,7 +112,7 @@ class Test( SoftDeleteMixin, TimeMixin, CreatedByMixin, - db.Model, # type:ignore[name-defined] + db.Model, # type: ignore[name-defined] ): __tablename__ = "test" @@ -136,7 +136,7 @@ class Section( SoftDeleteMixin, TimeMixin, CreatedByMixin, - db.Model, # type:ignore[name-defined] + db.Model, # type: ignore[name-defined] ): __tablename__ = "section" @@ -161,7 +161,7 @@ def get_id(self): return self.id -class QuestionImage(db.Model): # type:ignore[name-defined] +class QuestionImage(db.Model): # type: ignore[name-defined] __tablename__ = "question_image" id = db.Column(BigInteger, primary_key=True) question_id = db.Column(BigInteger, ForeignKey("question.id")) @@ -175,7 +175,7 @@ class Question( SoftDeleteMixin, TimeMixin, CreatedByMixin, - db.Model, # type:ignore[name-defined] + db.Model, # type: ignore[name-defined] ): __tablename__ = "question" @@ -212,7 +212,7 @@ class QuizSession( SoftDeleteMixin, TimeMixin, CreatedByMixin, - db.Model, # type:ignore[name-defined] + db.Model, # type: ignore[name-defined] ): __tablename__ = "quiz_session" @@ -230,7 +230,7 @@ class SessionQuestion( SoftDeleteMixin, TimeMixin, CreatedByMixin, - db.Model, # type:ignore[name-defined] + db.Model, # type: ignore[name-defined] ): __tablename__ = "session_question" @@ -252,7 +252,7 @@ class Answer( SoftDeleteMixin, TimeMixin, CreatedByMixin, - db.Model, # type:ignore[name-defined] + db.Model, # type: ignore[name-defined] ): __tablename__ = "answer" @@ -272,7 +272,7 @@ class PracticeQuestion( SoftDeleteMixin, TimeMixin, CreatedByMixin, - db.Model, # type:ignore[name-defined] + db.Model, # type: ignore[name-defined] ): __tablename__ = "practice_question" @@ -293,7 +293,7 @@ class PracticeQuestion( # Table for question categories. -class QuestionCategory(db.Model): # type:ignore[name-defined] +class QuestionCategory(db.Model): # type: ignore[name-defined] id = db.Column(BigInteger, primary_key=True) name = db.Column(Text, nullable=False, default="Тестовая категория") diff --git a/front/src/static/assets/css/workspace.css b/front/src/static/assets/css/workspace.css index acdf7de7..0e6dbbea 100644 --- a/front/src/static/assets/css/workspace.css +++ b/front/src/static/assets/css/workspace.css @@ -50,7 +50,7 @@ display: block; margin-bottom: 10px; padding: 10px; - width: 130px; + width: 170px; background-color: white; border-radius: 7px; box-shadow: 0px 0px 15px rgba(0, 0, 0, 0.1); @@ -63,13 +63,14 @@ display: block; max-height: calc(80vh - 140px); padding: 10px; - width: 130px; + width: 170px; background-color: white; border-radius: 7px; box-shadow: 0px 0px 15px rgba(0, 0, 0, 0.1); text-align: center; color: #222; overflow-y: auto; + margin-bottom: 10px; } .ws-menu-settings-name { @@ -107,4 +108,38 @@ font-weight: 500; color: #222; } + + .side-menu-header { + position: relative; + text-align: center; + padding: 6px 28px; + } + + .side-menu-header span { + font-size: inherit; + user-select: none; + } + + .side-menu-arrow { + position: absolute; + top: 50%; + transform: translateY(-50%); + cursor: pointer; + font-size: 1.2rem; + color: #6c757d; + opacity: 0.6; + } + + .side-menu-arrow.left { + left: 6px; + } + + .side-menu-arrow.right { + right: 6px; + } + + .side-menu-arrow:hover { + color: #000; + opacity: 1; + } } \ No newline at end of file diff --git a/front/src/static/config_devices.js b/front/src/static/config_devices.js index 52954b62..25a3d274 100644 --- a/front/src/static/config_devices.js +++ b/front/src/static/config_devices.js @@ -6,6 +6,7 @@ $('#config_router').load(ExternalUrlFor("/config_router.html")); $('#config_server').load(ExternalUrlFor("/config_server.html")); $('#config_vlan').load(ExternalUrlFor("/config_vlan.html")); $('#config_vxlan').load(ExternalUrlFor("/config_vxlan.html")); +$("#config_textbox").load(ExternalUrlFor("/config_textbox.html")) const config_content_id = "#config_content"; const config_main_form_id = "#config_main_form"; @@ -103,10 +104,60 @@ const UpdateHostConfigurationForm = function(host_id) { DeleteAndSaveJob('host', UpdateHostConfiguration, data, host_id); }; -const ConfigHostForm = function(host_id){ - var form = document.getElementById('config_host_main_form_script').innerHTML; - var button = document.getElementById('config_host_save_script').innerHTML; - var banner = document.getElementById('config_host_edit_banner_script').innerHTML; +const UpdateTextboxConfigurationForm = function (textbox_id) { + let data = $("#config_textbox_main_form").serialize(); + + // Disable all input fields + $("#config_textbox_main_form :input").prop("disabled", true); + + // Set loading spinner + $("#config_textbox_main_form_submit_button").text(""); + $("#config_textbox_main_form_submit_button").append( + 'Сохранение...', + ); + + UpdateTextboxConfiguration(data, textbox_id); +}; + +const ConfigTextboxForm = function (textbox_id) { + + var form = document.getElementById( + "config_textbox_main_form_script", + ).innerHTML; + var button = document.getElementById( + "config_textbox_save_script", + ).innerHTML; + + $(config_content_id).empty(); + $(config_content_save_tag).empty(); + + document.getElementById(config_content_save_id).style.display = "block"; + + $(config_content_id).append(form); + $(config_content_save_tag).append(button); + + $("#textbox_id").val(textbox_id); + $("#net_guid").val(network_guid); + + function handleTextboxClick(event) { + event.preventDefault(); + UpdateTextboxConfigurationForm(textbox_id); + } + + $("#config_textbox_main_form_submit_button, #config_textbox_end_form").on( + "click", + handleTextboxClick, + ); +}; + +const ConfigHostForm = function (host_id) { + var form = document.getElementById( + "config_host_main_form_script", + ).innerHTML; + var button = document.getElementById("config_host_save_script").innerHTML; + var banner = document.getElementById( + "config_host_edit_banner_script", + ).innerHTML; // Clear all child $(config_content_id).empty(); @@ -377,8 +428,123 @@ const ConfigHubName = function (hostname) { var text = document.getElementById('config_hub_name_script').innerHTML; - $(config_hub_main_form_id).prepend((text)); - $('#config_hub_name').val(hostname); + $(config_hub_main_form_id).prepend(text); + $("#config_hub_name").val(hostname); +}; + +const ConfigTextboxContent = function(textbox_name) { + var text = document.getElementById("config_textbox_content_script").innerHTML; + + $("#config_textbox_main_form").prepend(text); + $("#config_textbox_content").val(textbox_name) +} + +const ConfigTextboxFontColor = function(textbox_font_color) { + var colorScript = document.getElementById("config_textbox_font_color_script").innerHTML; + $("#config_textbox_main_form").prepend(colorScript); + + var input = $("#config_textbox_font_color"); + + var currentColor = textbox_font_color || "#000000"; + input.val(currentColor); + + const highlightColor = (selectedColor) => { + if (!selectedColor) return; + selectedColor = selectedColor.toLowerCase(); + + var $form = $("#config_textbox_main_form"); + var $presets = $form.find(".color-preset"); + var $customWrapper = $form.find(".custom-color-wrapper"); + + $presets.add($customWrapper).css({ + "outline": "none" + }); + + let foundPreset = false; + $presets.each(function() { + var $el = $(this); + var presetColor = $el.data("color"); + if (presetColor && presetColor.toLowerCase() === selectedColor) { + $el.css({ + "outline": "2px solid #0d6efd", + "outline-offset": "2px" + }); + foundPreset = true; + return false; + } + }); + + if (!foundPreset) { + $customWrapper.css({ + "outline": "2px solid #0d6efd", + "outline-offset": "2px" + }); + } + }; + + highlightColor(currentColor); + + $("#config_textbox_main_form").on("click", ".color-preset", function() { + var selectedColor = $(this).data("color"); + input.val(selectedColor); + highlightColor(selectedColor); + }); + + input.on("input change", function() { + highlightColor($(this).val()); + }); +} + +const ConfigTextboxFontControls = function(size, style, weight) { + if ($("#config_textbox_font_size").length === 0) { + var controls = document.getElementById("config_textbox_font_controls_script").innerHTML; + $("#config_textbox_main_form").prepend(controls); + } + + $("#config_textbox_font_size").val(size); + + var inputStyle = $("#config_textbox_font_style"); + var btnStyle = $("#btn_toggle_style"); + var inputWeight = $("#config_textbox_font_weight"); + var btnWeight = $("#btn_toggle_weight"); + + inputStyle.val(style || 'normal'); + if (style === 'italic') { + btnStyle.removeClass('btn-outline-secondary').addClass('btn-primary active'); + } else { + btnStyle.removeClass('btn-primary active').addClass('btn-outline-secondary'); + } + + inputWeight.val(weight || 'normal'); + if (weight === 'bold') { + btnWeight.removeClass('btn-outline-secondary').addClass('btn-primary active'); + } else { + btnWeight.removeClass('btn-primary active').addClass('btn-outline-secondary'); + } + + $("#config_textbox_main_form").off('click', '#btn_toggle_style').on('click', '#btn_toggle_style', function() { + var btn = $(this); + var input = $("#config_textbox_font_style"); + if (input.val() === 'italic') { + input.val('normal'); + btn.removeClass('btn-primary active').addClass('btn-outline-secondary'); + } else { + input.val('italic'); + btn.removeClass('btn-outline-secondary').addClass('btn-primary active'); + } + }); + + $("#config_textbox_main_form").off('click', '#btn_toggle_weight').on('click', '#btn_toggle_weight', function() { + var btn = $(this); + var input = $("#config_textbox_font_weight"); + if (input.val() === 'bold') { + input.val('normal'); + btn.removeClass('btn-primary active').addClass('btn-outline-secondary'); + } else { + input.val('bold'); + btn.removeClass('btn-outline-secondary').addClass('btn-primary active'); + } + }); } const ConfigEdgeNetworkIssues = function (edge_loss, edge_duplicate) { diff --git a/front/src/static/config_textbox.html b/front/src/static/config_textbox.html new file mode 100644 index 00000000..3321551d --- /dev/null +++ b/front/src/static/config_textbox.html @@ -0,0 +1,61 @@ + + + + + + + + + \ No newline at end of file diff --git a/front/src/static/icons.js b/front/src/static/icons.js index 6dd9f1cd..cca063f9 100644 --- a/front/src/static/icons.js +++ b/front/src/static/icons.js @@ -16,4 +16,5 @@ const DiagramIcons = { document: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAQAAAAAYLlVAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAAmJLR0QAAKqNIzIAAAAJcEhZcwAACxMAAAsTAQCanBgAAAH8SURBVGje7Zm/SyNBFMc/k12JP8ATVLjr/FGJ+AcoCNan2EnigWAtYiVaqIWoYHGNlYVicXCFhWAngojYWenJXWEhCmqws7kcaNSxMI3KZt9kdxLCzTdFwszb2c+878tjhwWn/10qYLya+girZvmHloX6AeODrJMr8vYJtpnnMlpmRtARPo/8oEVKa0Me31igVRLqF5y9Y5yswY2X6ASeUHikUMxxUeweXi3I8MnoqgM0ml120Ghy/Aw3whcsa6rfbJCkD580OiwLdmrgD5McoEkwHFYLdgDgmEn2gQRpFmkvPYDmmCn2AI8hFmkrJUAdjXzhMxlWOAWqSJEKCrZRhKOk878UtfnvwH+TDYAkSXmwrRoQK94MTNPE85sRxXc6Sgdw9GFEMVv4krJb4ADKDiArwmZqjFfOcSt5LpQBrDJg+ISY4Ixu7uMCqDHrbgD5JiwgLbNkGZhh5V2HC5MiKzNNBnBiLwMVYkE/rcYW3LHJU1wAY3w13toZWxKACrHglivjRnQtOx9LLfCMt/bMQ3wAgpZarCqkBpbpFdSAYoJfdgC66BHFNZhnoEIs2Oacx9AoxbUtgDV7GSi7BQ7AATgAB+AA/JDZDv5GWl+FnRELAzRxaHmLIdOKqsgARfJpkBwrhPKCz1VBb80aaZO+9xJIkeEmttWcnOLVCybGe0qF0Rl6AAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDIwLTAyLTI5VDExOjU5OjE2KzAxOjAwq3IToAAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyMC0wMi0yOVQxMTo1OToxNiswMTowMNovqxwAAAAASUVORK5CYII=", phone: "data:image/png;base64,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", folder: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAABHNCSVQICAgIfAhkiAAAAAlwSFlzAAALEwAACxMBAJqcGAAAAfxJREFUeJzt279rFEEYxvHPxROjxmBppYKNEAsLGwsLBWs70cI2bXrBVlQEEUv/C2ttBK0EbRIQAqKlREGMmESysdjdcyyCcLu37507X3iZ2ebleZ+dGfbHDJlMrxkk/TmcxpExc32uYia5gnXsNYgCj3GgY+2NuYgdzYpP41G38psxwEtcwgbu4vuYeVawVF1fxfM2BHZBffefNMyzlORaN/5a0ilDHKz62w1zreIe7uAM7uO20pBpYqeKEfXcfdhC8kN4q731ZBJR4AXOTcIAOIUPU1Dov+Ibzg5bKjrlI87jFk5MIH9TFrCMRTyg/REwCzxV1rw5F60kiB9VO99XA0b03oB0EVzADcwHaemKLRytLwb+PKgU+jMidpUvbbupAX2kSKfAV1zDZpCYrjiGZziu+h5SPwe8CRTVNe9Udadzvk9TYVRrXxa9fckGRAuIJhsQLSCabEC0gGiyAdECoskGRAuIJhuQ9Hv/MrQaICSKtaotUgO+RCgJYqNq9/IaEC0gmmxAtIBosgHRAqLJBkQLiCYbEC0gmmxAtIBosgHRAqLJBkQLiCYbEC0gmmyAcssY5Vb3vlDvhfw1xGvlkZmb+GS8IzOzxCKuV/1XlIemtsXv3+86tnChduUy3k+BqK5iTTnq/zo4OcBJHPZ/81M51fv0JyyT2Y/f3pw3gUxucUoAAAAASUVORK5CYII=", -}; \ No newline at end of file + textbox: "data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAxIDEiPjwvc3ZnPg==" +}; diff --git a/front/src/static/images/textbox.svg b/front/src/static/images/textbox.svg new file mode 100644 index 00000000..65027f80 --- /dev/null +++ b/front/src/static/images/textbox.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/front/src/static/netfront.js b/front/src/static/netfront.js index d1a9f436..0ab6409a 100644 --- a/front/src/static/netfront.js +++ b/front/src/static/netfront.js @@ -98,6 +98,33 @@ $('#network_scheme').droppable({ interface: [], }); } + else if (type === 'textbox'){ + let node_id = TextboxUid(); + nodes.push( + { + data: { + id: node_id, + label: "Новое текстовое поле", + }, + position: { + x: CalculateDropOffset(ui.position.left, ui.position.top).x, + y: CalculateDropOffset(ui.position.left, ui.position.top).y, + }, + classes: ["textbox"], + config: { + type: 'textbox', + label: "Новое текстовое поле", + width: 150, + height: 80, + tb_fontsize: 12, + color: 'black', + fontweight: 'normal', + fontstyle: 'normal', + }, + interface: [], + }); + + } else { return; } diff --git a/front/src/static/netfront_f.js b/front/src/static/netfront_f.js index bea70aac..92a808b6 100644 --- a/front/src/static/netfront_f.js +++ b/front/src/static/netfront_f.js @@ -22,6 +22,23 @@ const uid = function(){ return Date.now().toString(36) + Math.random().toString(36).substr(2); } +const TextboxUid = function(){ + let textbox_name = "textbox_"; + + let textbox; + for (let textbox_number = 1; textbox_number < 100; textbox_number++) { + textbox = textbox_name + textbox_number; + + let t = nodes.find(t => t.data.id === textbox); + + if (!t) { + return textbox; + } + } + + return "text_" + uid(); +} + const HostUid = function(){ let host_name = "host_"; @@ -131,6 +148,25 @@ const ActionWithInterface = function (n, i, fun) { } +const ShowTextboxConfig = function(n, shared = 0) { + + let textbox_name = n.config.label; + let textbox_fontsize = n.config.tb_fontsize; + let textbox_font_color = n.config.color + let textbox_font_style = n.config.fontstyle + let textbox_font_weight = n.config.fontweight + textbox_name = textbox_name || n.data.id + + if (shared) { + SharedConfigTextboxForm(n.data.id); + } else { + ConfigTextboxForm(n.data.id) + } + ConfigTextboxFontColor(textbox_font_color) + ConfigTextboxFontControls(textbox_fontsize, textbox_font_style, textbox_font_weight); + ConfigTextboxContent(textbox_name); +} + const ShowHostConfig = function(n, shared = 0){ // Exit edit mode when switching to different device @@ -501,6 +537,10 @@ const AddEdge = function(source_id, target_id){ return; } + if (source_node.config.type === 'textbox' || target_node.config.type === 'textbox') { + return; + } + // Save the network state. SaveNetworkObject(); @@ -519,9 +559,9 @@ const AddEdge = function(source_id, target_id){ if (source_node.config.type === 'host' || source_node.config.type === 'router' || source_node.config.type === 'server'){ let iface_id = InterfaceUid(); source_node.interface.push({ - id: iface_id, - name: iface_id, - connect: edge_id, + id: iface_id, + name: iface_id, + connect: edge_id, }); } @@ -808,6 +848,10 @@ const prepareStylesheet = function() { return label; } + if (n.config.type === 'textbox') { + return n.config.text || label; + } + $.each(n.interface, function (i) { let ip_addr = n.interface[i].ip; @@ -919,30 +963,30 @@ const prepareStylesheet = function() { 'source-arrow-color': 'blue' }) - .selector('.eh-ghost-edge') - .css({ - 'background-color': 'blue', - 'line-color': 'blue', - 'target-arrow-color': 'blue', - 'source-arrow-color': 'blue' - }) - - .selector('node[name]') - .css({ - 'content': 'data(name)' - }) - - .selector('node[type="packet"]') - .css({ - 'content': 'data(label)', - 'text-valign': 'top', - 'text-align': 'center', - 'height': '5px', - 'width': '5px', - 'border-opacity': '0', - 'border-width': '0px', - 'text-wrap': 'wrap' - }) + .selector('node[type="packet"]') + .css({ + content: "data(label)", + "text-valign": "top", + "text-align": "center", + height: "5px", + width: "5px", + "border-opacity": "0", + "border-width": "0px", + "text-wrap": "wrap", + }) + + .selector(".hidden") + .css({ + display: "none", + }) + + .selector(".eh-ghost-edge") + .css({ + "background-color": "blue", + "line-color": "blue", + "target-arrow-color": "blue", + "source-arrow-color": "blue", + }) .selector('.hidden') .css({ @@ -983,8 +1027,33 @@ const prepareStylesheet = function() { } } + const getConfig = function(ele, property, defaultValue) { + let n = nodes.find(n => n.data.id === ele.id()); + return (n && n.config && n.config[property] !== undefined) ? n.config[property] : defaultValue; + }; + + sheet.selector('.textbox') + .css({ + 'shape': 'rectangle', + 'background-opacity': 0, + 'border-width': 0, + 'content': 'data(label)', + 'width': function (ele) { + return getConfig(ele,'width',50) + }, + 'height': function(ele) { return getConfig(ele,'height',50) }, + 'text-valign': 'center', + 'text-halign': 'center', + 'text-wrap': 'wrap', + 'text-max-width': function(ele) { return getConfig(ele,'width',50) }, + 'color': function(ele) { return getConfig(ele,'color','#000000') }, + 'font-size': function(ele) { return getConfig(ele,'tb_fontsize',12) }, + 'font-style': function(ele) {return getConfig(ele, 'fontstyle', 'normal')}, + 'font-weight': function(ele) {return getConfig(ele, 'fontweight', 'normal')} + }); + return sheet; - }; +}; const SnapNodesToGrid = function(cy_instance) { if (!cy_instance) return; @@ -1083,30 +1152,176 @@ const DrawGraph = function() { global_cy = cy; - // the default values of each option are outlined below: - let defaults = { - canConnect: function( sourceNode, targetNode ){ + if ($('#resize-style').length === 0) { + $('head').append(` + + `); + } - // whether an edge can be created between source and target - return !sourceNode.same(targetNode); // e.g. disallow loops - }, + let activeNodeId = null; - edgeParams: function( sourceNode, targetNode ){ + const hideResizeFrame = () => { + $('#resize-frame').remove(); + activeNodeId = null; + }; - // for edges between the specified source and target - // return element object to be passed to cy.add() for edge - return {}; - }, + $(document).off('mousedown.resizeHide').on('mousedown.resizeHide', function(e) { + if ($(e.target).closest('#network_scheme').length > 0) { + return; + } + + if ($(e.target).closest('.resize-frame').length > 0 || $(e.target).hasClass('resize-handle')) { + return; + } + + hideResizeFrame(); + }); + + const updateResizeFrame = () => { + if (!activeNodeId) return; + const node = cy.getElementById(activeNodeId); + if (node.empty() || node.removed()) { hideResizeFrame(); return; } - hoverDelay: 150, // time spent hovering over a target node before it is considered selected - snap: false, // when enabled, the edge can be drawn by just moving close to a target node (can be confusing on compound graphs) - snapThreshold: 50, // the target node must be less than or equal to this many pixels away from the cursor/finger - snapFrequency: 15, // the number of times per second (Hz) that snap checks done (lower is less expensive) - noEdgeEventsInDraw: true, // set events:no to edges during draws, prevents mouseouts on compounds - disableBrowserGestures: true // during an edge drawing gesture, disable browser gestures such as two-finger trackpad swipe and pinch-to-zoom + const bb = node.renderedBoundingBox({ includeLabels: false }); + const containerOffset = $(cy.container()).offset(); + + let frame = $('#resize-frame'); + if (frame.length === 0) return; + + frame.css({ + top: containerOffset.top + bb.y1, + left: containerOffset.left + bb.x1, + width: bb.w, + height: bb.h + }); }; - global_eh = cy.edgehandles(defaults); + const initResizeFrame = (node) => { + hideResizeFrame(); + activeNodeId = node.id(); + + let n = nodes.find(n => n.data.id === node.id()); + if (!n || !n.config) return; + + const frame = $('
'); + $('body').append(frame); + + const handles = [ + { d: 'nw', x: 0, y: 0, c: 'nw-resize' }, { d: 'n', x: 50, y: 0, c: 'n-resize' }, + { d: 'ne', x: 100, y: 0, c: 'ne-resize' }, { d: 'e', x: 100, y: 50, c: 'e-resize' }, + { d: 'se', x: 100, y: 100, c: 'se-resize' }, { d: 's', x: 50, y: 100, c: 's-resize' }, + { d: 'sw', x: 0, y: 100, c: 'sw-resize' }, { d: 'w', x: 0, y: 50, c: 'w-resize' } + ]; + + handles.forEach(h => { + const handle = $('
'); + handle.css({ left: h.x + '%', top: h.y + '%', cursor: h.c }); + + handle.on('mousedown', function(e) { + e.stopPropagation(); + e.preventDefault(); + + const startX = e.pageX; + const startY = e.pageY; + const startW = n.config.width || 100; + const startH = n.config.height || 50; + const startPos = {x: node.position().x, y: node.position().y}; + const zoom = cy.zoom(); + + node.ungrabify(); + + $(document).on('mousemove.resizing', function(ev) { + const dx = (ev.pageX - startX) / zoom; + const dy = (ev.pageY - startY) / zoom; + + let newW = startW; + let newH = startH; + let newX = startPos.x; + let newY = startPos.y; + + // Horizontal Resize + if (h.d.includes('e')) { + newW = Math.max(30, startW + dx); + newX += (newW - startW) / 2; // Shift center right + } + if (h.d.includes('w')) { + newW = Math.max(30, startW - dx); + newX -= (newW - startW) / 2; // Shift center left + } + + // Vertical Resize + if (h.d.includes('s')) { + newH = Math.max(30, startH + dy); + newY += (newH - startH) / 2; // Shift center down + } + if (h.d.includes('n')) { + newH = Math.max(30, startH - dy); + newY -= (newH - startH) / 2; // Shift center up + } + + n.config.width = newW; + n.config.height = newH; + + node.style('width', newW); + node.style('height', newH); + node.style('text-max-width', newW); + + node.position({x: newX, y: newY}); + + updateResizeFrame(); + }); + + $(document).on('mouseup.resizing', function() { + $(document).off('.resizing'); + node.grabify(); + TakeGraphPictureAndUpdate(); + MoveNodes(); + }); + }); + + frame.append(handle); + }); + + updateResizeFrame(); + }; + + cy.on('tap', '.textbox', (e) => initResizeFrame(e.target)); + cy.on('tap', (e) => { if (e.target === cy) hideResizeFrame(); }); + cy.on('zoom pan position', updateResizeFrame); + + let allowEdges = function(src, tgt) { + const sNode = nodes.find(n => n.data.id === src.id()); + const tNode = nodes.find(n => n.data.id === tgt.id()); + + if (sNode && sNode.config && sNode.config.type === 'textbox') return false; + if (tNode && tNode.config && tNode.config.type === 'textbox') return false; + + return !src.same(tgt); + } + + let customDefaults = { + handleNodes: '.host, .l2_switch, .l1_hub, .l3_router, .server', + canConnect: (src, tgt) => allowEdges(src, tgt), + + edgeParams: (src, tgt) => allowEdges(src, tgt) ? {} : null, + + hoverDelay: 150, + snap: false, + snapThreshold: 50, + snapFrequency: 15, + noEdgeEventsInDraw: true, + disableBrowserGestures: true, + }; + + global_eh = cy.edgehandles(customDefaults); cy.minZoom(0.5); cy.maxZoom(2); @@ -1183,7 +1398,8 @@ const DrawGraph = function() { n.position.x = posX; n.position.y = posY; - MoveNodes(); + MoveNodes(); // --- RESIZE UI LOGIC END --- + TakeGraphPictureAndUpdate(); }); @@ -1229,12 +1445,15 @@ const DrawGraph = function() { ShowRouterConfig(n); } else if (n.config.type === 'server'){ ShowServerConfig(n); + } else if (n.config.type === 'textbox'){ + ShowTextboxConfig(n); } }); // Add edge to the edges[] and then save it to the server. cy.on('ehcomplete', (event, sourceNode, targetNode, addedEdge) => { AddEdge(sourceNode._private.data.id, targetNode._private.data.id); + DrawGraph(); PostNodesEdges(); TakeGraphPictureAndUpdate(); @@ -1249,6 +1468,9 @@ const DrawGraph = function() { } if (e.keyCode == 46 && selecteed_node_id) { + if (activeNodeId === selecteed_node_id) { + hideResizeFrame(); + } // Save the network state. SaveNetworkObject(); @@ -1322,6 +1544,34 @@ const DrawGraph = function() { }); + + cy.on('tap', '.textbox', (e) => { + e.originalEvent.stopPropagation(); + initResizeFrame(e.target); + }); + + cy.on('tap', (e) => { + if (e.target === cy) { + hideResizeFrame(); + return; + } + + if (e.target.isNode && e.target.isNode()) { + let n = nodes.find(n => n.data.id === e.target.id()); + if (n && n.config && n.config.type !== 'textbox') { + hideResizeFrame(); + } + } + }); + + cy.on('zoom pan', updateResizeFrame); + + cy.on('dragstart', (e) => { + if (e.target.id() !== activeNodeId) { + hideResizeFrame(); + } + }); + // Initialize grid initGrid(cy); } @@ -2070,6 +2320,44 @@ const UpdateHubConfiguration = function (data, hub_id) }); } +const UpdateTextboxConfiguration = function (data, textbox_id) { + SetNetworkPlayerState(-1); + + $.ajax({ + type: "POST", + url: "/host/textbox_save_config", + data: data, + success: function (data, textStatus, xhr) { + if (xhr.status === 200) { + + nodes = data.nodes; + DrawGraph(); + + let n = nodes.find((n) => n.data.id === textbox_id); + + if (!n) { + ClearConfigForm("Нет такого текстового блока"); + return; + } + + if (n.config.type === "textbox") { + ShowTextboxConfig(n); + } else { + ClearConfigForm("Нет такого текстового блока"); + return; + } + + } + }, + error: function(xhr) { + console.log("Cannot update textbox config"); + console.log(xhr); + }, + dataType: 'json' + + }); +}; + // Update Switch configuration const UpdateSwitchConfiguration = function (data, switch_id) { diff --git a/front/src/templates/network.html b/front/src/templates/network.html index eaf2bc99..daa6f491 100644 --- a/front/src/templates/network.html +++ b/front/src/templates/network.html @@ -79,9 +79,14 @@ {% endif %} -
-
Устройства
-
+
+
+ + Устройства + +
+ +
@@ -120,6 +125,16 @@
+ +
@@ -176,6 +191,7 @@
+
{% endblock %} {% block network %} @@ -228,6 +244,23 @@ } + function ToggleSideMenu() { + var devicesPanel = document.getElementById("panel_devices"); + var toolsPanel = document.getElementById("panel_tools"); + var menuTitle = document.getElementById("side_menu_title"); + + if (devicesPanel.style.display === "none") { + // Show Devices + devicesPanel.style.display = "block"; + toolsPanel.style.display = "none"; + menuTitle.innerText = "Устройства"; + } else { + // Show Tools + devicesPanel.style.display = "none"; + toolsPanel.style.display = "block"; + menuTitle.innerText = "Инструменты"; + } + } {% endblock %}