From 60b6d9797c230f597133f378f2fef4dd3beac4d9 Mon Sep 17 00:00:00 2001
From: Czémán Arnold <czeman.arnold@cloud.bme.hu>
Date: Fri, 1 Dec 2017 01:14:52 +0100
Subject: [PATCH] network, settings: add basic network topology editor, eleminate pipeline cache, add ES6 compiler

---
 circle/bower.json                            |   3 ++-
 circle/circle/settings/base.py               | 160 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------------------------------------------------------------
 circle/circle/settings/local.py              |   1 +
 circle/network/static/network/editor.es6     | 400 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 circle/network/templates/network/editor.html |  53 +++++++++++++++++++++++++++++++++++++++++++++++++++++
 circle/network/urls.py                       |   6 +++++-
 circle/network/views.py                      | 193 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---
 7 files changed, 741 insertions(+), 75 deletions(-)
 create mode 100644 circle/network/static/network/editor.es6
 create mode 100644 circle/network/templates/network/editor.html

diff --git a/circle/bower.json b/circle/bower.json
index a0695bd..8463de2 100644
--- a/circle/bower.json
+++ b/circle/bower.json
@@ -22,6 +22,7 @@
     "favico.js": "~0.3.5",
     "datatables": "~1.10.4",
     "chart.js": "2.3.0",
-    "clipboard": "~1.6.1"
+    "clipboard": "~1.6.1",
+    "jsPlumb": "2.5.7"
   }
 }
diff --git a/circle/circle/settings/base.py b/circle/circle/settings/base.py
index ab6d3fe..45b1864 100644
--- a/circle/circle/settings/base.py
+++ b/circle/circle/settings/base.py
@@ -165,92 +165,112 @@ p = normpath(join(SITE_ROOT, '../../site-circle/static'))
 if exists(p):
     STATICFILES_DIRS.append(p)
 
-STATICFILES_STORAGE = 'pipeline.storage.PipelineCachedStorage'
+STATICFILES_STORAGE = 'pipeline.storage.PipelineStorage'
 
 PIPELINE = {
-    'COMPILERS' : ('pipeline.compilers.less.LessCompiler',),
+    'COMPILERS' : ('pipeline.compilers.less.LessCompiler',
+                   'pipeline.compilers.es6.ES6Compiler', ),
     'LESS_ARGUMENTS': u'--include-path={}'.format(':'.join(STATICFILES_DIRS)),
     'CSS_COMPRESSOR': 'pipeline.compressors.yuglify.YuglifyCompressor',
+    'BABEL_ARGUMENTS': u'--presets env',
     'JS_COMPRESSOR': None,
     'DISABLE_WRAPPER': True,
     'STYLESHEETS': {
-        "all": {"source_filenames": (
-            "compile_bootstrap.less",
-            "bootstrap/dist/css/bootstrap-theme.css",
-            "fontawesome/css/font-awesome.css",
-            "jquery-simple-slider/css/simple-slider.css",
-            "intro.js/introjs.css",
-            "template.less",
-            "dashboard/dashboard.less",
-            "network/network.less",
-            "autocomplete_light/vendor/select2/dist/css/select2.css",
-            "autocomplete_light/select2.css",
-        ),
+        "all": {
+            "source_filenames": (
+                "compile_bootstrap.less",
+                "bootstrap/dist/css/bootstrap-theme.css",
+                "fontawesome/css/font-awesome.css",
+                "jquery-simple-slider/css/simple-slider.css",
+                "intro.js/introjs.css",
+                "template.less",
+                "dashboard/dashboard.less",
+                "network/network.less",
+                "autocomplete_light/vendor/select2/dist/css/select2.css",
+                "autocomplete_light/select2.css",
+            ),
             "output_filename": "all.css",
-        }
+        },
+        "network-editor": {
+            "source_filenames": (
+                "network/editor.css",
+            ),
+            "output_filename": "network-editor.css",
+        },
     },
     'JAVASCRIPT': {
-        "all": {"source_filenames": (
-            # "jquery/dist/jquery.js",  # included separately
-            "bootbox/bootbox.js",
-            "bootstrap/dist/js/bootstrap.js",
-            "intro.js/intro.js",
-            "jquery-knob/dist/jquery.knob.min.js",
-            "jquery-simple-slider/js/simple-slider.js",
-            "favico.js/favico.js",
-            "datatables/media/js/jquery.dataTables.js",
-            "autocomplete_light/jquery.init.js",
-            "autocomplete_light/autocomplete.init.js",
-            "autocomplete_light/vendor/select2/dist/js/select2.js",
-            "autocomplete_light/select2.js",
-            "dashboard/dashboard.js",
-            "dashboard/activity.js",
-            "dashboard/group-details.js",
-            "dashboard/group-list.js",
-            "dashboard/js/stupidtable.min.js",  # no bower file
-            "dashboard/node-create.js",
-            "dashboard/node-details.js",
-            "dashboard/node-list.js",
-            "dashboard/profile.js",
-            "dashboard/store.js",
-            "dashboard/template-list.js",
-            "dashboard/vm-common.js",
-            "dashboard/vm-create.js",
-            "dashboard/vm-list.js",
-            "dashboard/help.js",
-            "js/host.js",
-            "js/network.js",
-            "js/switch-port.js",
-            "js/host-list.js",
-        ),
+        "all": {
+            "source_filenames": (
+                # "jquery/dist/jquery.js",  # included separately
+                "bootbox/bootbox.js",
+                "bootstrap/dist/js/bootstrap.js",
+                "intro.js/intro.js",
+                "jquery-knob/dist/jquery.knob.min.js",
+                "jquery-simple-slider/js/simple-slider.js",
+                "favico.js/favico.js",
+                "datatables/media/js/jquery.dataTables.js",
+                "autocomplete_light/jquery.init.js",
+                "autocomplete_light/autocomplete.init.js",
+                "autocomplete_light/vendor/select2/dist/js/select2.js",
+                "autocomplete_light/select2.js",
+                "jsPlumb/dist/js/dom.jsPlumb-1.7.5-min.js",
+                "dashboard/dashboard.js",
+                "dashboard/activity.js",
+                "dashboard/group-details.js",
+                "dashboard/group-list.js",
+                "dashboard/js/stupidtable.min.js",  # no bower file
+                "dashboard/node-create.js",
+                "dashboard/node-details.js",
+                "dashboard/node-list.js",
+                "dashboard/profile.js",
+                "dashboard/store.js",
+                "dashboard/template-list.js",
+                "dashboard/vm-common.js",
+                "dashboard/vm-create.js",
+                "dashboard/vm-list.js",
+                "dashboard/help.js",
+                "js/host.js",
+                "js/network.js",
+                "js/switch-port.js",
+                "js/host-list.js",
+            ),
             "output_filename": "all.js",
         },
-        "vm-detail": {"source_filenames": (
-            "clipboard/dist/clipboard.min.js",
-            "dashboard/vm-details.js",
-            "no-vnc/include/util.js",
-            "no-vnc/include/webutil.js",
-            "no-vnc/include/base64.js",
-            "no-vnc/include/websock.js",
-            "no-vnc/include/des.js",
-            "no-vnc/include/keysym.js",
-            "no-vnc/include/keysymdef.js",
-            "no-vnc/include/keyboard.js",
-            "no-vnc/include/input.js",
-            "no-vnc/include/display.js",
-            "no-vnc/include/jsunzip.js",
-            "no-vnc/include/rfb.js",
-            "dashboard/vm-console.js",
-            "dashboard/vm-tour.js",
-        ),
+        "vm-detail": {
+            "source_filenames": (
+                "clipboard/dist/clipboard.min.js",
+                "dashboard/vm-details.js",
+                "no-vnc/include/util.js",
+                "no-vnc/include/webutil.js",
+                "no-vnc/include/base64.js",
+                "no-vnc/include/websock.js",
+                "no-vnc/include/des.js",
+                "no-vnc/include/keysym.js",
+                "no-vnc/include/keysymdef.js",
+                "no-vnc/include/keyboard.js",
+                "no-vnc/include/input.js",
+                "no-vnc/include/display.js",
+                "no-vnc/include/jsunzip.js",
+                "no-vnc/include/rfb.js",
+                "dashboard/vm-console.js",
+                "dashboard/vm-tour.js",
+            ),
             "output_filename": "vm-detail.js",
         },
-        "datastore": {"source_filenames": (
-            "chart.js/dist/Chart.min.js",
-            "dashboard/datastore-details.js"
-        ),
+        "datastore": {
+            "source_filenames": (
+                "chart.js/dist/Chart.min.js",
+                "dashboard/datastore-details.js"
+            ),
             "output_filename": "datastore.js",
         },
+        "network-editor": {
+            "source_filenames": (
+                "jsPlumb/dist/js/jsplumb.min.js",
+                "network/editor.es6",
+            ),
+            "output_filename": "network-editor.js",
+        },
     },
 }
 
diff --git a/circle/circle/settings/local.py b/circle/circle/settings/local.py
index cc5b2ae..788fb3f 100644
--- a/circle/circle/settings/local.py
+++ b/circle/circle/settings/local.py
@@ -112,6 +112,7 @@ if DEBUG:
 
 PIPELINE["COMPILERS"] = (
     'dashboard.compilers.DummyLessCompiler',
+    'pipeline.compilers.es6.ES6Compiler',
 )
 
 ADMIN_ENABLED = True
diff --git a/circle/network/static/network/editor.es6 b/circle/network/static/network/editor.es6
new file mode 100644
index 0000000..1ae3c5d
--- /dev/null
+++ b/circle/network/static/network/editor.es6
@@ -0,0 +1,400 @@
+/* jshint esversion: 6 */
+
+function renderListElement(elem){
+  return `
+<div class="unused-element"
+     type="${ elem.type }"
+     description="${ elem.description }"
+     id="${ elem.id }"
+     icon="${ elem.icon }"
+     name="${ elem.name }"
+     free_port_num="${ elem.free_port_num }">
+  <i class="fa ${ elem.type == 'vm' ? 'fa-desktop' : 'fa-sitemap' }"></i>
+  ${ elem.name }
+</div>`;
+}
+
+function renderBoardElement(elem){
+  return `
+<div class="element"
+     name="${ elem.name }"
+     type="${ elem.type }"
+     id="${ elem.id }"
+     description="${ elem.description }"
+     icon="${ elem.icon }"
+     free_port_num="${ elem.free_port_num }"
+     ondragstart="return false;">
+  <i class="fa ${ elem.type == 'vm' ? 'fa-desktop' : 'fa-sitemap'}"></i> 
+  ${ elem.name }
+</div>`;
+}
+
+
+var add_interfaces = [];
+var remove_interfaces = [];
+var old_connections = [];
+
+var add_nodes = new Set();
+var remove_nodes = new Set();
+var old_nodes = new Set();
+
+function convertConnection(connection) {
+    var con = {
+        source: connection.source.id,
+        target: connection.target.id,
+        equals: function(other) {
+            return this.source === other.source &&
+                   this.target === other.target;
+        }
+    };
+
+    if(con.source.startsWith('net')){
+        var tmp = con.source;
+        con.source = con.target;
+        con.target = tmp;
+    }
+    
+    con.source = con.source.slice(3);
+    con.target = con.target.slice(4);
+
+    return con;
+}
+
+function uniqueAddToList(list, value){
+    var hit = list.find((val) => {
+        return val.equals(value); 
+    });
+    if(hit) return;
+    list.push(value);
+}
+
+function removeFromList(list, value){
+    var index = list.findIndex((val) => {
+        return val.equals(value); 
+    });
+    if(index === -1) return;
+    list.splice(index, 1);
+}
+
+function isOldConnection(connection){
+  return old_connections.find((val) => {
+    return val.equals(connection); 
+  });
+}
+
+function cleanListElements(list){
+  return list.map((value) => {
+    return {
+      source: value.source,
+      target: value.target
+    };
+  });
+}
+
+function addInterface(connection) {
+    var con = convertConnection(connection);
+    if(!isOldConnection(con))
+      uniqueAddToList(add_interfaces, con);
+    removeFromList(remove_interfaces, con);
+}
+
+function removeInterface(connection) {
+    var con = convertConnection(connection);
+    if(isOldConnection(con))
+      uniqueAddToList(remove_interfaces, con);
+    removeFromList(add_interfaces, con);
+}
+
+function getNodeId(node) {
+  return node.id;
+}
+
+function isOldNode(id) {
+  return old_nodes.has(id);
+}
+
+function addNode(node) {
+    var id = getNodeId(node);
+    add_nodes.add(id);
+    remove_nodes.delete(id);
+}
+
+function removeNode(node) {
+    var id = getNodeId(node);
+    if(isOldNode(id))
+      remove_nodes.add(id);
+    add_nodes.delete(id);
+}
+
+function checkLoopback(connection) {
+  return connection.target !== connection.source;
+}
+
+function checkCompatibility(connection) {
+  var target_type = $(connection.target).attr('type');
+  var source_type = $(connection.source).attr('type');
+
+  return target_type !== source_type;
+}
+
+// Before the connection is established
+function beforeDrop(info) {
+  return checkCompatibility(info.connection);
+}
+
+function onConnect(info) {
+  addInterface(info.connection);
+}
+
+function onDetach(info) {
+  removeInterface(info.connection);
+}
+
+function FakeConnection(sourceId, targetId) {
+  this.source = {id: sourceId};
+  this.target = {id: targetId};
+  return this;
+}
+
+function onConnectionMoved(info) {
+  var fakeOrigCon = new FakeConnection(info.originalSourceId,
+                                   info.originalTargetId);
+  removeInterface(fakeOrigCon);
+  var fakeNewCon = new FakeConnection(info.newSourceId,
+                                   info.newTargetId);
+  addInterface(fakeNewCon);
+}
+
+function onConnectionAborted(connection) {
+  addInterface(connection);
+}
+
+function randInt(from, to) {
+  if(from > to){
+    var tmp = to;
+    to = from;
+    from = tmp;
+  }
+  var size = to - from;
+  return Math.floor((Math.random() * size) + from);
+}
+
+function convertElement(elem) {
+  return {
+    id: elem.attr('id'),
+    type: elem.attr('type'),
+    description: elem.attr('description'),
+    name: elem.attr('name'),
+    icon: elem.attr('icon'),
+    free_port_num: elem.attr('free_port_num')
+  };
+}
+
+function convertListForSaving(list) {
+  const getId = (id, type) => 
+    (type === 'vm') ?  id.slice(3) : id.slice(4); 
+  var retv = [];
+  list.forEach( (i, id) => {
+    var e=$('#' + id);
+    retv.push({
+      id: getId(e.attr('id'), e.attr('type')),
+      type: e.attr('type'),
+      x: e.css('left').replace('px', ''),
+      y: e.css('top').replace('px', ''),
+      free_port_num: e.attr('free_port_num')
+    });
+  });
+  return retv;
+}
+
+
+class SwitchButton {
+  constructor(id, checked) {
+    this.element = $('#' + id);
+    this.element.css('cursor', 'pointer');
+    this.afterChanged(() => {});
+    this.setClass(checked);
+  }
+
+  setClass(checked){
+    this.element.removeClass('fa');
+    this.element.removeClass('fa-toggle-on');
+    this.element.removeClass('fa-toggle-off');
+    this.element.addClass('fa');
+    this.element.addClass( checked ? 'fa-toggle-on' : 'fa-toggle-off');
+  }
+
+  isChecked() {
+    return this.element.hasClass('fa-toggle-on');
+  }
+  
+  afterChanged(func) {
+    this.element.off('click');
+    var thiz = this;
+    this.element.click(function() {
+      thiz.setClass(!thiz.isChecked());
+      func();
+    });
+  }
+}
+
+
+jsPlumb.ready(() => {
+  var endpointOptions = {
+    anchor: 'Continuous',
+    isSource: true,
+    isTarget: true,
+    maxConnections: 1
+  };
+  var jsPlumbInstance = jsPlumb;
+
+  jsPlumbInstance.setContainer('#dropContainer');
+  jsPlumbInstance.bind('beforeDrop', beforeDrop);
+  jsPlumbInstance.bind('connection', onConnect);
+  jsPlumbInstance.bind('connectionDetached', onDetach);
+  jsPlumbInstance.bind('connectionMoved', onConnectionMoved);
+  jsPlumbInstance.bind('connectionAborted', onConnectionAborted);
+
+  function connectEndpoints(connection) {
+    return jsPlumbInstance.connect(connection, {
+      allowLoopback: false,
+      newConnection: true,
+      anchor: 'Continuous',
+      deleteEndpointsOnDetach: true
+    });
+  }
+
+  function addEndpoint(elem){
+    jsPlumbInstance.addEndpoint(elem.id, endpointOptions);
+  }
+
+  function generatePosition(){
+    var dc = $('#dropContainer');
+    var width = dc.css("width").replace('px', '');
+    var height = dc.css("height").replace('px', '');
+    return {
+      x: randInt(0, width),
+      y: randInt(0, height)
+    };
+  }
+
+  function addElementToBoard(element, random) {
+    var pos;
+    if(random)
+      pos = generatePosition();
+    else
+      pos = {
+        x: element.x,
+        y: element.y
+      };
+
+    var newe = $(renderBoardElement(element))
+      .css('top', pos.y + 'px')
+      .css('left', pos.x + 'px')[0];
+
+      $('#dropContainer').append(newe);
+
+    for(var i = 0; i < element.free_port_num; ++i){
+      addEndpoint(newe);
+    }
+    jsPlumbInstance.draggable(newe.id, {containment: true});
+    jsPlumbInstance.repaint(newe.id);
+
+    $(newe).bind('contextmenu', removeElement);
+    jsPlumbInstance.repaintEverything();
+  }
+
+  function selectElementFromList(ev) {
+    var elem = $(ev.target);
+    var obj = convertElement(elem);
+    elem.detach();
+    addElementToBoard(obj, true);
+    addNode(obj);
+  }
+
+  function addElementToList(elem) {
+    $('#dragContainer').append(renderListElement(elem));
+    $('#' + elem.id).click(selectElementFromList);
+  }
+
+  function removeElement(ev) {
+    var elem = $(ev.target);
+    var obj = convertElement(elem);
+    jsPlumbInstance.removeAllEndpoints(elem.attr('id'));
+    elem.detach();
+    addElementToList(obj);
+    removeNode(obj);
+  }
+
+  function clearWorkspace() {
+    jsPlumbInstance.deleteEveryConnection();
+    jsPlumbInstance.deleteEveryEndpoint();
+    $('.element').detach();
+    $('.unused-element').detach();
+  }
+
+  function initialize(result){
+    clearWorkspace();
+
+    old_nodes = new Set();
+    add_nodes = new Set();
+    remove_nodes = new Set();
+
+    $.each(result.elements, (i, element) => {
+      addElementToBoard(element);
+      old_nodes.add(element.id);
+      add_nodes.add(element.id);
+    });
+    $.each(result.nongraph_elements, (i, element) => {
+      addElementToBoard(element, true);
+      add_nodes.add(element.id);
+    });
+    $.each(result.unused_elements, (i, element) => {
+      addElementToList(element);
+    });
+    old_connections = [];
+    $.each(result.connections, (i, connection) => {
+      var con = connectEndpoints(connection);
+      old_connections.push(convertConnection(con));
+    });
+    add_interfaces = []; // Because of a 'connect' event,
+                         // connections are added to this list,
+                         // but this is not necessary.
+    remove_interfaces = [];
+  }
+
+  function save() {
+    var data = {
+      add_interfaces: cleanListElements(add_interfaces),
+      remove_interfaces: cleanListElements(remove_interfaces),
+      add_nodes: convertListForSaving(add_nodes),
+      remove_nodes: convertListForSaving(remove_nodes)
+    };
+    $.post('', JSON.stringify(data), initialize, 'json');
+  }
+
+  $.get('', initialize);
+  $("#saveButton").click(save);
+
+  $('#searchField').on('keyup', filter);
+
+  var vmFilter = new SwitchButton('vm-filter', true);
+  vmFilter.afterChanged(filter);
+  var netFilter = new SwitchButton('net-filter', true);
+  netFilter.afterChanged(filter);
+
+  function filter() {
+    $(".unused-element").each((i, elem) => {
+      elem = $(elem);
+      elem.hide();
+      var key = $("#searchField").val().toLowerCase();
+      var type = elem.attr('type');
+      var network_on = netFilter.isChecked();
+      var vm_on = vmFilter.isChecked();
+      if(elem.attr("name").toLowerCase().indexOf(key) >= 0 &&
+         ((type === "network" && network_on) || (type === "vm" && vm_on)))
+        elem.show();
+    });
+  }
+ 
+});
diff --git a/circle/network/templates/network/editor.html b/circle/network/templates/network/editor.html
new file mode 100644
index 0000000..8e317e9
--- /dev/null
+++ b/circle/network/templates/network/editor.html
@@ -0,0 +1,53 @@
+{% extends "dashboard/base.html" %}
+{% load staticfiles %}
+{% load i18n %}
+{% load pipeline %}
+
+{% block title-page %}{% trans 'Network Editor' %}{% endblock %}
+
+{% block extra_css %}
+  {% stylesheet "network-editor" %}
+{% endblock %}
+
+{% block content %}
+
+<div class="flex-container" id="workspace">
+
+  <div class="panel panel-default text-center" id="dragPanel">
+    <div class="panel-heading">
+      <div class="row">
+        <div class="col-md-9 text-left">
+          <h3 class="no-margin"><i class="fa fa-sitemap"></i> {% trans 'Editor' %}</h3>
+        </div>
+        <div class="col-md-3 text-left">
+          <button class="btn btn-success btn-xs" id="saveButton"><i class="fa fa-floppy-o"></i></button>
+        </div>
+      </div>
+    </div>
+    <div class="panel-heading text-center">
+      <div id="filterConatiner">
+        <div class="row">
+          <input type="text" class="form-control" id="searchField" placeholder="{% trans 'Search' %}"/><br />
+        </div>
+        <div class="row">
+          <div class="col-md-6">
+            <i id="vm-filter"></i> <i class="fa fa-desktop"></i> vm
+          </div>
+          <div class="col-md-6">
+            <i id="net-filter"></i> <i class="fa fa-sitemap"></i> net
+          </div>
+        </div>
+      </div>
+    </div>
+    <div class="panel-body" id="dragContainer">
+    </div>
+  </div>
+
+  <div class="" id="dropContainer" oncontextmenu="return false;"></div>
+
+</div>
+{% endblock %}
+
+{% block extra_js %}
+  {% javascript "network-editor" %}
+{% endblock %}
diff --git a/circle/network/urls.py b/circle/network/urls.py
index 71e0c32..1dd5931 100644
--- a/circle/network/urls.py
+++ b/circle/network/urls.py
@@ -31,7 +31,7 @@ from .views import (
     FirewallList, FirewallDetail, FirewallCreate, FirewallDelete,
     remove_host_group, add_host_group,
     remove_switch_port_device, add_switch_port_device,
-    VlanAclUpdateView
+    VlanAclUpdateView, NetworkEditorView
 )
 
 urlpatterns = [
@@ -135,6 +135,10 @@ urlpatterns = [
     url('^vxlans/delete/(?P<vni>\d+)/$', VxlanDelete.as_view(),
         name="network.vxlan-delete"),
 
+    # editor
+    url('^editor/$', NetworkEditorView.as_view(),
+        name="network.editor"),
+
     # non class based views
     url('^hosts/(?P<pk>\d+)/remove/(?P<group_pk>\d+)/$', remove_host_group,
         name='network.remove_host_group'),
diff --git a/circle/network/views.py b/circle/network/views.py
index afc7741..cf0a89e 100644
--- a/circle/network/views.py
+++ b/circle/network/views.py
@@ -17,11 +17,12 @@
 
 import logging
 import random
+import json
 from collections import OrderedDict
 
 from netaddr import IPNetwork
 from django.views.generic import (
-    TemplateView, UpdateView, DeleteView, CreateView
+    TemplateView, UpdateView, DeleteView, CreateView,
 )
 from django.core.exceptions import (
     ValidationError, PermissionDenied, ImproperlyConfigured
@@ -38,8 +39,8 @@ from firewall.models import (
     Host, Vlan, Domain, Group, Record, BlacklistItem, Rule, VlanGroup,
     SwitchPort, EthernetDevice, Firewall
 )
-from network.models import Vxlan
-from vm.models import Interface
+from network.models import Vxlan, EditorElement
+from vm.models import Interface, Instance
 from common.views import CreateLimitedResourceMixin
 from acl.views import CheckedObjectMixin
 from .tables import (
@@ -1107,6 +1108,192 @@ class VxlanDelete(LoginRequiredMixin, CheckedObjectMixin, DeleteView):
         return context
 
 
+class NetworkEditorView(LoginRequiredMixin, TemplateView):
+    template_name = 'network/editor.html'
+
+    def get(self, *args, **kwargs):
+        if self.request.is_ajax():
+            connections = self._get_connections()
+
+            ngelements = self._get_nongraph_elements(connections)
+            ngelements = self._serialize_elements(ngelements)
+
+            connections = map(lambda con: {
+               'source': 'vm-%s' % con['source'].pk,
+               'target': 'net-%s' % con['target'].vni,
+            }, connections['connections'])
+
+            unused_elements = self._get_unused_elements()
+            unused_elements = self._serialize_elements(unused_elements)
+
+            return JsonResponse({
+                'elements': map(lambda e: e.as_data(),
+                                EditorElement.objects.filter(
+                                    owner=self.request.user)),
+                'nongraph_elements': ngelements,
+                'unused_elements': unused_elements,
+                'connections': connections,
+            })
+        return super(NetworkEditorView, self).get(*args, **kwargs)
+
+    def post(self, *args, **kwargs):
+        data = json.loads(self.request.body)
+        add_ifs = data.get('add_interfaces', [])
+        remove_ifs = data.get('remove_interfaces', [])
+        add_nodes = data.get('add_nodes', [])
+        remove_nodes = data.get('remove_nodes', [])
+
+        # Add editor element
+        self._element_list_operation(add_nodes, self._update_element)
+        # Remove editor element
+        self._element_list_operation(remove_nodes, self._remove_element)
+
+        # Add interface
+        self._interface_list_operation(add_ifs, self._add_interface)
+        # Remove interface
+        self._interface_list_operation(remove_ifs, self._remove_interface)
+
+        return self.get(*args, **kwargs)
+
+    def _max_port_num_helper(self, model, attr_name):
+        if not hasattr(self, attr_name):
+            value = model.get_objects_with_level(
+                'user', self.request.user).count()
+            setattr(self, attr_name, value)
+        return getattr(self, attr_name)
+
+    @property
+    def vm_max_port_num(self):
+        return self._max_port_num_helper(Vxlan, '_vm_max_port_num')
+
+    @property
+    def vxlan_max_port_num(self):
+        return self._max_port_num_helper(Instance, '_vxlan_max_port_num')
+
+    def _vm_serializer(self, vm):
+        max_port_num = self.vm_max_port_num
+        vxlans = Vxlan.get_objects_with_level(
+            'user', self.request.user).values_list('pk', flat=True)
+        free_port_num = max_port_num - vm.interface_set.filter(
+            vxlan__pk__in=vxlans).count()
+        return {
+            'name': unicode(vm),
+            'id': 'vm-%s' % vm.pk,
+            'description': vm.description,
+            'type': 'vm',
+            'icon': 'fa-desktop',
+            'free_port_num': free_port_num,
+        }
+
+    def _vxlan_serializer(self, vxlan):
+        max_port_num = self.vxlan_max_port_num
+        vms = Instance.get_objects_with_level(
+            'user', self.request.user).values_list('pk', flat=True)
+        free_port_num = max_port_num - Interface.objects.filter(
+            vxlan=vxlan, instance__pk__in=vms).count()
+        return {
+            'name': vxlan.name,
+            'id': 'net-%s' % vxlan.vni,
+            'description': vxlan.description,
+            'type': 'network',
+            'icon': 'fa-sitemap',
+            'free_port_num': free_port_num,
+        }
+
+    def _get_unused_elements(self):
+        connections = self._get_connections()
+        vms = map(lambda vm: vm.id, connections['vms'])
+        vxlans = map(lambda vxlan: vxlan.vni, connections['vxlans'])
+        eelems = EditorElement.objects.filter(owner=self.request.user)
+
+        vm_query = Q(pk__in=vms) | Q(editor_elements__in=eelems)
+        vms = Instance.get_objects_with_level(
+            'user', self.request.user).exclude(vm_query)
+        vxlan_query = Q(vni__in=vms) | Q(editor_elements__in=eelems)
+        vxlans = Vxlan.get_objects_with_level(
+            'user', self.request.user).exclude(vxlan_query)
+        return {
+            'vms': vms,
+            'vxlans': vxlans,
+        }
+
+    def _get_nongraph_elements(self, connections):
+        return {
+            'vms': filter(lambda v: not v.editor_elements.exists(),
+                          connections['vms']),
+            'vxlans': filter(lambda v: not v.editor_elements.exists(),
+                             connections['vxlans']),
+        }
+
+    def _get_connections(self):
+        """ Returns connections and theirs participants. """
+        vms = Instance.get_objects_with_level('user', self.request.user)
+        connections = []
+        vm_set = set()
+        vxlan_set = set()
+        for vm in vms:
+            for intf in vm.interface_set.filter(vxlan__isnull=False):
+                vm_set.add(vm)
+                vxlan_set.add(intf.vxlan)
+                connections.append({
+                    'source': vm,
+                    'target': intf.vxlan,
+                })
+        return {
+            'connections': connections,
+            'vms': vm_set,
+            'vxlans': vxlan_set,
+        }
+
+    def _serialize_elements(self, elements):
+        return (map(self._vm_serializer, elements['vms']) +
+                map(self._vxlan_serializer, elements['vxlans']))
+
+    def _get_modifiable_object(self, model, connection,
+                               attr_name, filter_attr):
+        value = connection.get(attr_name)
+        if value is not None:
+            value = model.get_objects_with_level(
+                'user', self.request.user).filter(
+                    **{filter_attr: value}).first()
+        return value
+
+    def _element_list_operation(self, node_list, operation):
+        for e in node_list:
+            elem = dict(e)
+            type = elem.pop('type')
+            id = elem.pop('id')
+            model = Instance if type == 'vm' else Vxlan
+            filter = {'pk': id} if type == 'vm' else {'vni': id}
+            object = model.get_objects_with_level(
+                'user', self.request.user).get(**filter)
+            operation(object.editor_elements, elem)
+
+    def _update_element(self, elements, elem):
+        elements.update_or_create(owner=self.request.user,
+                                  defaults=elem)
+
+    def _remove_element(self, elements, elem):
+        elements.filter(owner=self.request.user).delete()
+
+    def _interface_list_operation(self, if_list, operation):
+        for con in if_list:
+            vm = self._get_modifiable_object(Instance, con, 'source', 'pk')
+            vxlan = self._get_modifiable_object(Vxlan, con, 'target', 'vni')
+            if vm and vxlan:
+                operation(vm, vxlan)
+
+    def _add_interface(self, vm, vxlan):
+        vm.add_user_interface(
+            user=self.request.user, vxlan=vxlan, system=vm.system)
+
+    def _remove_interface(self, vm, vxlan):
+        intf = vm.interface_set.filter(vxlan=vxlan).first()
+        if intf:
+            vm.remove_user_interface(
+                interface=intf, user=self.request.user, system=vm.system)
+
+
 def remove_host_group(request, **kwargs):
     host = Host.objects.get(pk=kwargs['pk'])
     group = Group.objects.get(pk=kwargs['group_pk'])
--
libgit2 0.26.0