matthewalanpenning 2 سال پیش
والد
کامیت
bdc878e1b6
38فایلهای تغییر یافته به همراه815 افزوده شده و 394 حذف شده
  1. 7 0
      CHANGELOG.md
  2. 2 0
      lxconsole/api/__init__.py
  3. 24 0
      lxconsole/api/access_controls.py
  4. 118 0
      lxconsole/api/cluster_groups.py
  5. 63 0
      lxconsole/api/cluster_members.py
  6. 5 0
      lxconsole/routes.py
  7. 0 338
      lxconsole/static/js/serializejson.js
  8. 3 0
      lxconsole/static/js/sidebar.js
  9. 2 2
      lxconsole/templates/access-controls.html
  10. 1 1
      lxconsole/templates/certificates.html
  11. 266 0
      lxconsole/templates/cluster-groups.html
  12. 60 12
      lxconsole/templates/cluster-members.html
  13. 1 1
      lxconsole/templates/container.html
  14. 8 1
      lxconsole/templates/containers.html
  15. 2 2
      lxconsole/templates/groups.html
  16. 1 1
      lxconsole/templates/images.html
  17. 0 1
      lxconsole/templates/login.html
  18. 1 2
      lxconsole/templates/main.html
  19. 171 0
      lxconsole/templates/modals/cluster-groups.html
  20. 45 11
      lxconsole/templates/modals/cluster-members.html
  21. 2 1
      lxconsole/templates/modals/containers.html
  22. 1 1
      lxconsole/templates/modals/main.html
  23. 2 1
      lxconsole/templates/modals/virtual-machines.html
  24. 1 1
      lxconsole/templates/network-acl.html
  25. 1 1
      lxconsole/templates/network-acls.html
  26. 1 1
      lxconsole/templates/networks.html
  27. 1 1
      lxconsole/templates/profiles.html
  28. 1 1
      lxconsole/templates/projects.html
  29. 0 1
      lxconsole/templates/register.html
  30. 1 1
      lxconsole/templates/roles.html
  31. 8 0
      lxconsole/templates/sidebar.html
  32. 1 1
      lxconsole/templates/simplestreams.html
  33. 1 1
      lxconsole/templates/storage-pools.html
  34. 1 1
      lxconsole/templates/storage-volumes.html
  35. 4 4
      lxconsole/templates/users.html
  36. 1 1
      lxconsole/templates/virtual-machine.html
  37. 8 1
      lxconsole/templates/virtual-machines.html
  38. 0 3
      roadmap.txt

+ 7 - 0
CHANGELOG.md

@@ -1,3 +1,10 @@
+# 0.2.0
+- Added cluster groups
+- Added evacuate and restore options for cluster members
+- Added cluster members roles to tables
+- Added cluster members actions
+- Removed serializeJSON library in favor of jquery serialize
+
 # 0.1.0
 - Corrected image unit size, changing GiB to MiB
 - Added option filter for storage volumes by adding filter=custom to URL

+ 2 - 0
lxconsole/api/__init__.py

@@ -2,6 +2,7 @@ from flask import Flask, Blueprint
 from . import server
 from . import servers
 from . import certificates
+from . import cluster_groups
 from . import cluster_members
 from . import images
 from . import container
@@ -34,6 +35,7 @@ api.add_url_rule('/containers/<endpoint>', view_func=containers.api_containers_e
 api.add_url_rule('/servers/<endpoint>', view_func=servers.api_servers_endpoint, methods=['GET', 'POST'])
 api.add_url_rule('/server/<endpoint>', view_func=server.api_server_endpoint)
 api.add_url_rule('/certificates/<endpoint>', view_func=certificates.api_certificates_endpoint, methods=['GET', 'POST'])
+api.add_url_rule('/cluster-groups/<endpoint>', view_func=cluster_groups.api_cluster_groups_endpoint, methods=['GET', 'POST'])
 api.add_url_rule('/cluster-members/<endpoint>', view_func=cluster_members.api_cluster_members_endpoint, methods=['GET', 'POST'])
 api.add_url_rule('/images/<endpoint>', view_func=images.api_images_endpoint, methods=['GET', 'POST'])
 api.add_url_rule('/networks/<endpoint>', view_func=networks.api_networks_endpoint, methods=['GET', 'POST'])

+ 24 - 0
lxconsole/api/access_controls.py

@@ -11,6 +11,7 @@ def privilege_check(privilege, server_id = 0):
     'Auditor' : [
       #'add_access_control',
       #'add_certificate',
+      #'add_cluster_group',
       #'add_cluster_member',
       #'add_group',
       #'add_server',
@@ -32,6 +33,7 @@ def privilege_check(privilege, server_id = 0):
       #'add_storage_volume',
       #'add_user',
       #'attach_instance_profile',
+      #'change_cluster_member_state',
       #'change_instance_state',
       #'copy_instance',
       #'create_instance_backup',
@@ -39,6 +41,7 @@ def privilege_check(privilege, server_id = 0):
       #'create_instance_snapshot',
       #'delete_access_control',
       #'delete_certificate',
+      #'delete_cluster_group',
       #'delete_cluster_member',
       #'delete_group',
       #'delete_image',
@@ -88,6 +91,7 @@ def privilege_check(privilege, server_id = 0):
       'is_cluster_member_enabled',
       'list_access_controls',
       'list_certificates',
+      'list_cluster_groups',
       'list_cluster_members',
       'list_groups',
       'list_servers',
@@ -110,6 +114,7 @@ def privilege_check(privilege, server_id = 0):
       'list_storage_volumes',
       'list_users',
       'load_certificate',
+      'load_cluster_group',
       'load_cluster_member',
       'load_image',
       'load_instance',
@@ -129,6 +134,7 @@ def privilege_check(privilege, server_id = 0):
       #'restore_instance_snapshot',
       #'update_access_control',
       #'update_certificate',
+      #'update_cluster_group',
       #'update_cluster_member',
       #'update_group',
       #'update_server',
@@ -147,6 +153,7 @@ def privilege_check(privilege, server_id = 0):
     'User': [
       #'add_access_control',
       #'add_certificate',
+      #'add_cluster_group',
       #'add_cluster_member',
       #'add_group',
       #'add_server',
@@ -168,6 +175,7 @@ def privilege_check(privilege, server_id = 0):
       #'add_storage_volume',
       #'add_user',
       #'attach_instance_profile',
+      'change_cluster_member_state',
       'change_instance_state',
       'copy_instance',
       'create_instance_backup',
@@ -175,6 +183,7 @@ def privilege_check(privilege, server_id = 0):
       'create_instance_snapshot',
       #'delete_access_control',
       #'delete_certificate',
+      #'delete_cluster_group',
       #'delete_cluster_member',
       #'delete_group',
       #'delete_image',
@@ -224,6 +233,7 @@ def privilege_check(privilege, server_id = 0):
       'is_cluster_member_enabled',
       'list_access_controls',
       'list_certificates',
+      'list_cluster_groups',
       'list_cluster_members',
       'list_groups',
       'list_servers',
@@ -246,6 +256,7 @@ def privilege_check(privilege, server_id = 0):
       'list_storage_volumes',
       'list_users',
       'load_certificate',
+      'load_cluster_group',
       'load_cluster_member',
       'load_image',
       'load_instance',
@@ -265,6 +276,7 @@ def privilege_check(privilege, server_id = 0):
       'restore_instance_snapshot',
       #'update_access_control',
       #'update_certificate',
+      #'update_cluster_group',
       #'update_cluster_member',
       #'update_group',
       #'update_server',
@@ -283,6 +295,7 @@ def privilege_check(privilege, server_id = 0):
     'Operator': [
       #'add_access_control',
       'add_certificate',
+      'add_cluster_group',
       'add_cluster_member',
       #'add_group',
       'add_server',
@@ -304,6 +317,7 @@ def privilege_check(privilege, server_id = 0):
       'add_storage_volume',
       #'add_user',
       'attach_instance_profile',
+      'change_cluster_member_state',
       'change_instance_state',
       'copy_instance',
       'create_instance_backup',
@@ -311,6 +325,7 @@ def privilege_check(privilege, server_id = 0):
       'create_instance_snapshot',
       #'delete_access_control',
       'delete_certificate',
+      'delete_cluster_group',
       'delete_cluster_member',
       #'delete_group',
       #'delete_image',
@@ -360,6 +375,7 @@ def privilege_check(privilege, server_id = 0):
       'is_cluster_member_enabled',
       'list_access_controls',
       'list_certificates',
+      'list_cluster_groups',
       'list_cluster_members',
       'list_groups',
       'list_servers',
@@ -382,6 +398,7 @@ def privilege_check(privilege, server_id = 0):
       'list_storage_volumes',
       'list_users',
       'load_certificate',
+      'load_cluster_group',
       'load_cluster_member',
       'load_image',
       'load_instance',
@@ -401,6 +418,7 @@ def privilege_check(privilege, server_id = 0):
       'restore_instance_snapshot',
       #'update_access_control',
       'update_certificate',
+      'update_cluster_group',
       'update_cluster_member',
       #'update_group',
       'update_server',
@@ -419,6 +437,7 @@ def privilege_check(privilege, server_id = 0):
     'Administrator': [
       'add_access_control',
       'add_certificate',
+      'add_cluster_group',
       'add_cluster_member',
       'add_group',
       'add_server',
@@ -440,6 +459,7 @@ def privilege_check(privilege, server_id = 0):
       'add_storage_volume',
       'add_user',
       'attach_instance_profile',
+      'change_cluster_member_state',
       'change_instance_state',
       'copy_instance',
       'create_instance_backup',
@@ -447,6 +467,7 @@ def privilege_check(privilege, server_id = 0):
       'create_instance_snapshot',
       'delete_access_control',
       'delete_certificate',
+      'delete_cluster_group',
       'delete_cluster_member',
       'delete_group',
       'delete_image',
@@ -496,6 +517,7 @@ def privilege_check(privilege, server_id = 0):
       'is_cluster_member_enabled',
       'list_access_controls',
       'list_certificates',
+      'list_cluster_groups',
       'list_cluster_members',
       'list_groups',
       'list_servers',
@@ -518,6 +540,7 @@ def privilege_check(privilege, server_id = 0):
       'list_storage_volumes',
       'list_users',
       'load_certificate',
+      'load_cluster_group',
       'load_cluster_member',
       'load_image',
       'load_instance',
@@ -537,6 +560,7 @@ def privilege_check(privilege, server_id = 0):
       'restore_instance_snapshot',
       'update_access_control',
       'update_certificate',
+      'update_cluster_group',
       'update_cluster_member',
       'update_group',
       'update_server',

+ 118 - 0
lxconsole/api/cluster_groups.py

@@ -0,0 +1,118 @@
+from flask import jsonify, request
+import requests
+from lxconsole import db
+from lxconsole.models import Server
+from flask_login import login_required
+from lxconsole.api.access_controls import privilege_check
+
+
+def get_client_crt():
+  return 'certs/client.crt'
+
+def get_client_key():
+  return 'certs/client.key'
+
+@login_required
+def api_cluster_groups_endpoint(endpoint):
+
+  if not privilege_check(endpoint, request.args.get('id')):
+    return jsonify({'data': [], 'metadata':[], 'error': 'not authorized', 'error_code': 403})
+
+
+  if endpoint == 'add_cluster_group':
+    id = request.args.get('id')
+    project = request.args.get('project')
+    server = Server.query.filter_by(id=id).first()
+    url = 'https://' + server.addr + ':' + str(server.port) + '/1.0/cluster/groups?project=' + project
+    client_cert = get_client_crt()
+    client_key = get_client_key()
+
+    if request.form.get('json'):
+      data = request.form.get('json')
+      results = requests.post(url, verify=server.ssl_verify, cert=(client_cert, client_key), data=data)
+      return jsonify(results.json())
+
+    data = {}
+    data.update({'name': request.form.get('name')})
+    data.update({'description': request.form.get('description')})
+    data.update({'members': request.form.getlist('members')})
+    results = requests.post(url, verify=server.ssl_verify, cert=(client_cert, client_key), json=data)
+    
+    return jsonify(results.json())
+
+
+  if endpoint == 'delete_cluster_group':
+    id = request.args.get('id')
+    project = request.args.get('project')
+    name = request.form.get('name')
+    server = Server.query.filter_by(id=id).first()
+    url = 'https://' + server.addr + ':' + str(server.port) + '/1.0/cluster/groups/' + name + '?project=' + project
+    client_cert = get_client_crt()
+    client_key = get_client_key()
+    results = requests.delete(url, verify=server.ssl_verify, cert=(client_cert, client_key))
+    return jsonify(results.json())
+  
+
+  if endpoint == 'is_cluster_member_enabled':
+    id = request.args.get('id')
+    project = request.args.get('project')
+    server = Server.query.filter_by(id=id).first()
+    url = 'https://' + server.addr + ':' + str(server.port) + '/1.0/cluster?project=' + project
+    client_cert = get_client_crt()
+    client_key = get_client_key()
+    results = requests.get(url, verify=server.ssl_verify, cert=(client_cert, client_key))
+    data = results.json()['metadata']
+    return str(data['enabled'])
+
+
+  if endpoint == 'list_cluster_groups':
+    if api_cluster_groups_endpoint('is_cluster_member_enabled') == 'True':
+      id = request.args.get('id')
+      project = request.args.get('project')
+      server = Server.query.filter_by(id=id).first()
+      recursion = request.args.get('recursion')
+      if recursion == '1':
+        url = 'https://' + server.addr + ':' + str(server.port) + '/1.0/cluster/groups?recursion=1&project=' + project
+      else:
+        url = 'https://' + server.addr + ':' + str(server.port) + '/1.0/cluster/groups?project=' + project
+      client_cert = get_client_crt()
+      client_key = get_client_key()
+      results = requests.get(url, verify=server.ssl_verify, cert=(client_cert, client_key))
+      return jsonify(results.json())
+    data = { "metadata": []}
+    return jsonify(data)
+  
+
+  if endpoint == 'load_cluster_group':
+    id = request.args.get('id')
+    project = request.args.get('project')
+    name = request.form.get('name')
+    server = Server.query.filter_by(id=id).first()
+    url = 'https://' + server.addr + ':' + str(server.port) + '/1.0/cluster/groups/' + name + '?project=' + project
+    client_cert = get_client_crt()
+    client_key = get_client_key()
+    results = requests.get(url, verify=server.ssl_verify, cert=(client_cert, client_key))
+    return jsonify(results.json())
+  
+
+  if endpoint == 'update_cluster_group':
+    id = request.args.get('id')
+    project = request.args.get('project')
+    name = request.args.get('name')
+    server = Server.query.filter_by(id=id).first()
+    url = 'https://' + server.addr + ':' + str(server.port) + '/1.0/cluster/groups/' + name + '?project=' + project
+    client_cert = get_client_crt()
+    client_key = get_client_key()
+
+    if request.form.get('json'):
+      data = request.form.get('json')
+      results = requests.put(url, verify=server.ssl_verify, cert=(client_cert, client_key), data=data)
+      return jsonify(results.json())
+
+    if request.form.get('name'):
+      data = {}
+      data.update({'name': request.form.get('name')})
+      results = requests.post(url, verify=server.ssl_verify, cert=(client_cert, client_key), json=data)
+      return jsonify(results.json())
+    return False
+  

+ 63 - 0
lxconsole/api/cluster_members.py

@@ -18,6 +18,36 @@ def api_cluster_members_endpoint(endpoint):
   if not privilege_check(endpoint, request.args.get('id')):
     return jsonify({'data': [], 'metadata':[], 'error': 'not authorized', 'error_code': 403})
 
+
+  if endpoint == 'change_cluster_member_state':
+    id = request.args.get('id')
+    project = request.args.get('project')
+    name = request.form.get('name')
+    server = Server.query.filter_by(id=id).first()
+    url = 'https://' + server.addr + ':' + str(server.port) + '/1.0/cluster/members/' + name + '/state?project=' + project
+    client_cert = get_client_crt()
+    client_key = get_client_key()
+    data = {}
+    data.update({'action': request.form.get('action')})
+    results = requests.post(url, verify=server.ssl_verify, cert=(client_cert, client_key), json=data)
+    return jsonify(results.json())
+
+
+  if endpoint == 'delete_cluster_member':
+    id = request.args.get('id')
+    project = request.args.get('project')
+    name = request.form.get('name')
+    server = Server.query.filter_by(id=id).first()
+    if request.form.get('force') == 'true':
+      url = 'https://' + server.addr + ':' + str(server.port) + '/1.0/cluster/members/' + name + '?force=1&project=' + project
+    else:
+      url = 'https://' + server.addr + ':' + str(server.port) + '/1.0/cluster/members/' + name + '?project=' + project
+    client_cert = get_client_crt()
+    client_key = get_client_key()
+    results = requests.delete(url, verify=server.ssl_verify, cert=(client_cert, client_key))
+    return jsonify(results.json())
+  
+
   if endpoint == 'is_cluster_member_enabled':
     id = request.args.get('id')
     project = request.args.get('project')
@@ -47,3 +77,36 @@ def api_cluster_members_endpoint(endpoint):
     data = { "metadata": []}
     return jsonify(data)
 
+
+  if endpoint == 'load_cluster_member':
+    id = request.args.get('id')
+    project = request.args.get('project')
+    name = request.form.get('name')
+    server = Server.query.filter_by(id=id).first()
+    url = 'https://' + server.addr + ':' + str(server.port) + '/1.0/cluster/members/' + name + '?project=' + project
+    client_cert = get_client_crt()
+    client_key = get_client_key()
+    results = requests.get(url, verify=server.ssl_verify, cert=(client_cert, client_key))
+    return jsonify(results.json())
+
+
+  if endpoint == 'update_cluster_member':
+    id = request.args.get('id')
+    project = request.args.get('project')
+    name = request.args.get('name')
+    server = Server.query.filter_by(id=id).first()
+    url = 'https://' + server.addr + ':' + str(server.port) + '/1.0/cluster/members/' + name + '?project=' + project
+    client_cert = get_client_crt()
+    client_key = get_client_key()
+
+    if request.form.get('json'):
+      data = request.form.get('json')
+      results = requests.put(url, verify=server.ssl_verify, cert=(client_cert, client_key), data=data)
+      return jsonify(results.json())
+
+    if request.form.get('server_name'):
+      data = {}
+      data.update({'server_name': request.form.get('server_name')})
+      results = requests.post(url, verify=server.ssl_verify, cert=(client_cert, client_key), json=data)
+      return jsonify(results.json())
+    return False

+ 5 - 0
lxconsole/routes.py

@@ -49,6 +49,11 @@ def home():
 def certificates():
   return render_template('certificates.html', page_title='Certificates', page_user_id=current_user.id, page_username=current_user.username,)
 
+@app.route("/cluster-groups")
+@login_required
+def cluster_groups():
+  return render_template('cluster-groups.html', page_title='Cluster Groups', page_user_id=current_user.id, page_username=current_user.username,)
+
 @app.route("/cluster-members")
 @login_required
 def cluster_members():

+ 0 - 338
lxconsole/static/js/serializejson.js

@@ -1,338 +0,0 @@
-/*!
-  SerializeJSON jQuery plugin.
-  https://github.com/marioizquierdo/jquery.serializeJSON
-  version 3.2.1 (Feb, 2021)
-
-  Copyright (c) 2012-2021 Mario Izquierdo
-  Dual licensed under the MIT (http://www.opensource.org/licenses/mit-license.php)
-  and GPL (http://www.opensource.org/licenses/gpl-license.php) licenses.
-*/
-(function (factory) {
-    /* global define, require, module */
-    if (typeof define === "function" && define.amd) { // AMD. Register as an anonymous module.
-        define(["jquery"], factory);
-    } else if (typeof exports === "object") { // Node/CommonJS
-        var jQuery = require("jquery");
-        module.exports = factory(jQuery);
-    } else { // Browser globals (zepto supported)
-        factory(window.jQuery || window.Zepto || window.$); // Zepto supported on browsers as well
-    }
-
-}(function ($) {
-    "use strict";
-
-    var rCRLF = /\r?\n/g;
-    var rsubmitterTypes = /^(?:submit|button|image|reset|file)$/i;
-    var rsubmittable = /^(?:input|select|textarea|keygen)/i;
-    var rcheckableType = /^(?:checkbox|radio)$/i;
-
-    $.fn.serializeJSON = function (options) {
-        var f = $.serializeJSON;
-        var $form = this; // NOTE: the set of matched elements is most likely a form, but it could also be a group of inputs
-        var opts = f.setupOpts(options); // validate options and apply defaults
-        var typeFunctions = $.extend({}, opts.defaultTypes, opts.customTypes);
-
-        // Make a list with {name, value, el} for each input element
-        var serializedArray = f.serializeArray($form, opts);
-
-        // Convert the serializedArray into a serializedObject with nested keys
-        var serializedObject = {};
-        $.each(serializedArray, function (_i, obj) {
-
-            var nameSansType = obj.name;
-            var type = $(obj.el).attr("data-value-type");
-
-            if (!type && !opts.disableColonTypes) { // try getting the type from the input name
-                var p = f.splitType(obj.name); // "foo:string" => ["foo", "string"]
-                nameSansType = p[0];
-                type = p[1];
-            }
-            if (type === "skip") {
-                return; // ignore fields with type skip
-            }
-            if (!type) {
-                type = opts.defaultType; // "string" by default
-            }
-
-            var typedValue = f.applyTypeFunc(obj.name, obj.value, type, obj.el, typeFunctions); // Parse type as string, number, etc.
-
-            if (!typedValue && f.shouldSkipFalsy(obj.name, nameSansType, type, obj.el, opts)) {
-                return; // ignore falsy inputs if specified in the options
-            }
-
-            var keys = f.splitInputNameIntoKeysArray(nameSansType);
-            f.deepSet(serializedObject, keys, typedValue, opts);
-        });
-        return serializedObject;
-    };
-
-    // Use $.serializeJSON as namespace for the auxiliar functions
-    // and to define defaults
-    $.serializeJSON = {
-        defaultOptions: {}, // reassign to override option defaults for all serializeJSON calls
-
-        defaultBaseOptions: { // do not modify, use defaultOptions instead
-            checkboxUncheckedValue: undefined, // to include that value for unchecked checkboxes (instead of ignoring them)
-            useIntKeysAsArrayIndex: false, // name="foo[2]" value="v" => {foo: [null, null, "v"]}, instead of {foo: ["2": "v"]}
-
-            skipFalsyValuesForTypes: [], // skip serialization of falsy values for listed value types
-            skipFalsyValuesForFields: [], // skip serialization of falsy values for listed field names
-
-            disableColonTypes: false, // do not interpret ":type" suffix as a type
-            customTypes: {}, // extends defaultTypes
-            defaultTypes: {
-                "string":  function(str) { return String(str); },
-                "number":  function(str) { return Number(str); },
-                "boolean": function(str) { var falses = ["false", "null", "undefined", "", "0"]; return falses.indexOf(str) === -1; },
-                "null":    function(str) { var falses = ["false", "null", "undefined", "", "0"]; return falses.indexOf(str) === -1 ? str : null; },
-                "array":   function(str) { return JSON.parse(str); },
-                "object":  function(str) { return JSON.parse(str); },
-                "skip":    null // skip is a special type used to ignore fields
-            },
-            defaultType: "string",
-        },
-
-        // Validate and set defaults
-        setupOpts: function(options) {
-            if (options == null) options = {};
-            var f = $.serializeJSON;
-
-            // Validate
-            var validOpts = [
-                "checkboxUncheckedValue",
-                "useIntKeysAsArrayIndex",
-
-                "skipFalsyValuesForTypes",
-                "skipFalsyValuesForFields",
-
-                "disableColonTypes",
-                "customTypes",
-                "defaultTypes",
-                "defaultType"
-            ];
-            for (var opt in options) {
-                if (validOpts.indexOf(opt) === -1) {
-                    throw new  Error("serializeJSON ERROR: invalid option '" + opt + "'. Please use one of " + validOpts.join(", "));
-                }
-            }
-
-            // Helper to get options or defaults
-            return $.extend({}, f.defaultBaseOptions, f.defaultOptions, options);
-        },
-
-        // Just like jQuery's serializeArray method, returns an array of objects with name and value.
-        // but also includes the dom element (el) and is handles unchecked checkboxes if the option or data attribute are provided.
-        serializeArray: function($form, opts) {
-            if (opts == null) { opts = {}; }
-            var f = $.serializeJSON;
-
-            return $form.map(function() {
-                var elements = $.prop(this, "elements"); // handle propHook "elements" to filter or add form elements
-                return elements ? $.makeArray(elements) : this;
-
-            }).filter(function() {
-                var $el = $(this);
-                var type = this.type;
-
-                // Filter with the standard W3C rules for successful controls: http://www.w3.org/TR/html401/interact/forms.html#h-17.13.2
-                return this.name && // must contain a name attribute
-                    !$el.is(":disabled") && // must not be disable (use .is(":disabled") so that fieldset[disabled] works)
-                    rsubmittable.test(this.nodeName) && !rsubmitterTypes.test(type) && // only serialize submittable fields (and not buttons)
-                    (this.checked || !rcheckableType.test(type) || f.getCheckboxUncheckedValue($el, opts) != null); // skip unchecked checkboxes (unless using opts)
-
-            }).map(function(_i, el) {
-                var $el = $(this);
-                var val = $el.val();
-                var type = this.type; // "input", "select", "textarea", "checkbox", etc.
-
-                if (val == null) {
-                    return null;
-                }
-
-                if (rcheckableType.test(type) && !this.checked) {
-                    val = f.getCheckboxUncheckedValue($el, opts);
-                }
-
-                if (isArray(val)) {
-                    return $.map(val, function(val) {
-                        return { name: el.name, value: val.replace(rCRLF, "\r\n"), el: el };
-                    } );
-                }
-
-                return { name: el.name, value: val.replace(rCRLF, "\r\n"), el: el };
-
-            }).get();
-        },
-
-        getCheckboxUncheckedValue: function($el, opts) {
-            var val = $el.attr("data-unchecked-value");
-            if (val == null) {
-                val = opts.checkboxUncheckedValue;
-            }
-            return val;
-        },
-
-        // Parse value with type function
-        applyTypeFunc: function(name, strVal, type, el, typeFunctions) {
-            var typeFunc = typeFunctions[type];
-            if (!typeFunc) { // quick feedback to user if there is a typo or missconfiguration
-                throw new Error("serializeJSON ERROR: Invalid type " + type + " found in input name '" + name + "', please use one of " + objectKeys(typeFunctions).join(", "));
-            }
-            return typeFunc(strVal, el);
-        },
-
-        // Splits a field name into the name and the type. Examples:
-        //   "foo"           =>  ["foo", ""]
-        //   "foo:boolean"   =>  ["foo", "boolean"]
-        //   "foo[bar]:null" =>  ["foo[bar]", "null"]
-        splitType : function(name) {
-            var parts = name.split(":");
-            if (parts.length > 1) {
-                var t = parts.pop();
-                return [parts.join(":"), t];
-            } else {
-                return [name, ""];
-            }
-        },
-
-        // Check if this input should be skipped when it has a falsy value,
-        // depending on the options to skip values by name or type, and the data-skip-falsy attribute.
-        shouldSkipFalsy: function(name, nameSansType, type, el, opts) {
-            var skipFromDataAttr = $(el).attr("data-skip-falsy");
-            if (skipFromDataAttr != null) {
-                return skipFromDataAttr !== "false"; // any value is true, except the string "false"
-            }
-
-            var optForFields = opts.skipFalsyValuesForFields;
-            if (optForFields && (optForFields.indexOf(nameSansType) !== -1 || optForFields.indexOf(name) !== -1)) {
-                return true;
-            }
-
-            var optForTypes = opts.skipFalsyValuesForTypes;
-            if (optForTypes && optForTypes.indexOf(type) !== -1) {
-                return true;
-            }
-
-            return false;
-        },
-
-        // Split the input name in programatically readable keys.
-        // Examples:
-        // "foo"              => ["foo"]
-        // "[foo]"            => ["foo"]
-        // "foo[inn][bar]"    => ["foo", "inn", "bar"]
-        // "foo[inn[bar]]"    => ["foo", "inn", "bar"]
-        // "foo[inn][arr][0]" => ["foo", "inn", "arr", "0"]
-        // "arr[][val]"       => ["arr", "", "val"]
-        splitInputNameIntoKeysArray: function(nameWithNoType) {
-            var keys = nameWithNoType.split("["); // split string into array
-            keys = $.map(keys, function (key) { return key.replace(/\]/g, ""); }); // remove closing brackets
-            if (keys[0] === "") { keys.shift(); } // ensure no opening bracket ("[foo][inn]" should be same as "foo[inn]")
-            return keys;
-        },
-
-        // Set a value in an object or array, using multiple keys to set in a nested object or array.
-        // This is the main function of the script, that allows serializeJSON to use nested keys.
-        // Examples:
-        //
-        // deepSet(obj, ["foo"], v)               // obj["foo"] = v
-        // deepSet(obj, ["foo", "inn"], v)        // obj["foo"]["inn"] = v // Create the inner obj["foo"] object, if needed
-        // deepSet(obj, ["foo", "inn", "123"], v) // obj["foo"]["arr"]["123"] = v //
-        //
-        // deepSet(obj, ["0"], v)                                   // obj["0"] = v
-        // deepSet(arr, ["0"], v, {useIntKeysAsArrayIndex: true})   // arr[0] = v
-        // deepSet(arr, [""], v)                                    // arr.push(v)
-        // deepSet(obj, ["arr", ""], v)                             // obj["arr"].push(v)
-        //
-        // arr = [];
-        // deepSet(arr, ["", v]          // arr => [v]
-        // deepSet(arr, ["", "foo"], v)  // arr => [v, {foo: v}]
-        // deepSet(arr, ["", "bar"], v)  // arr => [v, {foo: v, bar: v}]
-        // deepSet(arr, ["", "bar"], v)  // arr => [v, {foo: v, bar: v}, {bar: v}]
-        //
-        deepSet: function (o, keys, value, opts) {
-            if (opts == null) { opts = {}; }
-            var f = $.serializeJSON;
-            if (isUndefined(o)) { throw new Error("ArgumentError: param 'o' expected to be an object or array, found undefined"); }
-            if (!keys || keys.length === 0) { throw new Error("ArgumentError: param 'keys' expected to be an array with least one element"); }
-
-            var key = keys[0];
-
-            // Only one key, then it's not a deepSet, just assign the value in the object or add it to the array.
-            if (keys.length === 1) {
-                if (key === "") { // push values into an array (o must be an array)
-                    o.push(value);
-                } else {
-                    o[key] = value; // keys can be object keys (strings) or array indexes (numbers)
-                }
-                return;
-            }
-
-            var nextKey = keys[1]; // nested key
-            var tailKeys = keys.slice(1); // list of all other nested keys (nextKey is first)
-
-            if (key === "") { // push nested objects into an array (o must be an array)
-                var lastIdx = o.length - 1;
-                var lastVal = o[lastIdx];
-
-                // if the last value is an object or array, and the new key is not set yet
-                if (isObject(lastVal) && isUndefined(f.deepGet(lastVal, tailKeys))) {
-                    key = lastIdx; // then set the new value as a new attribute of the same object
-                } else {
-                    key = lastIdx + 1; // otherwise, add a new element in the array
-                }
-            }
-
-            if (nextKey === "") { // "" is used to push values into the nested array "array[]"
-                if (isUndefined(o[key]) || !isArray(o[key])) {
-                    o[key] = []; // define (or override) as array to push values
-                }
-            } else {
-                if (opts.useIntKeysAsArrayIndex && isValidArrayIndex(nextKey)) { // if 1, 2, 3 ... then use an array, where nextKey is the index
-                    if (isUndefined(o[key]) || !isArray(o[key])) {
-                        o[key] = []; // define (or override) as array, to insert values using int keys as array indexes
-                    }
-                } else { // nextKey is going to be the nested object's attribute
-                    if (isUndefined(o[key]) || !isObject(o[key])) {
-                        o[key] = {}; // define (or override) as object, to set nested properties
-                    }
-                }
-            }
-
-            // Recursively set the inner object
-            f.deepSet(o[key], tailKeys, value, opts);
-        },
-
-        deepGet: function (o, keys) {
-            var f = $.serializeJSON;
-            if (isUndefined(o) || isUndefined(keys) || keys.length === 0 || (!isObject(o) && !isArray(o))) {
-                return o;
-            }
-            var key = keys[0];
-            if (key === "") { // "" means next array index (used by deepSet)
-                return undefined;
-            }
-            if (keys.length === 1) {
-                return o[key];
-            }
-            var tailKeys = keys.slice(1);
-            return f.deepGet(o[key], tailKeys);
-        }
-    };
-
-    // polyfill Object.keys to get option keys in IE<9
-    var objectKeys = function(obj) {
-        if (Object.keys) {
-            return Object.keys(obj);
-        } else {
-            var key, keys = [];
-            for (key in obj) { keys.push(key); }
-            return keys;
-        }
-    };
-
-    var isObject =          function(obj) { return obj === Object(obj); }; // true for Objects and Arrays
-    var isUndefined =       function(obj) { return obj === void 0; }; // safe check for undefined values
-    var isValidArrayIndex = function(val) { return /^[0-9]+$/.test(String(val)); }; // 1,2,3,4 ... are valid array indexes
-    var isArray =           Array.isArray || function(obj) { return Object.prototype.toString.call(obj) === "[object Array]"; };
-}));

+ 3 - 0
lxconsole/static/js/sidebar.js

@@ -30,6 +30,7 @@ function configureNavbarForServers(){
 function configureSidebarForServers(){
   $("#clusterMembersLinkSidebar").hide()
   $("#clusterGroupsLinkSidebar").hide()
+  $("#clusterGroupsLinkSidebar").hide()
   $("#instanceSidebarLinks").hide()
   $("#coreSidebarLinks").hide()
   $("#networkSidebarLinks").hide()
@@ -38,6 +39,7 @@ function configureSidebarForServers(){
 
 function populateSidebarLinks(){
   $("#clusterMembersLinkSidebar").show()
+  $("#clusterGroupsLinkSidebar").show()
   $("#instanceSidebarLinks").show()
   $("#coreSidebarLinks").show()
   $("#networkSidebarLinks").show()
@@ -60,6 +62,7 @@ function applySidebarLinks() {
   $("#networksLinkSidebar").attr("href", "networks?id=" + encodeURI(serverId) + "&project=" + encodeURI(project));
   $("#storagePoolsLinkSidebar").attr("href", "storage-pools?id=" + encodeURI(serverId) + "&project=" + encodeURI(project));
   $("#clusterMembersLinkSidebar").attr("href", "cluster-members?id=" + encodeURI(serverId) + "&project=" + encodeURI(project));
+  $("#clusterGroupsLinkSidebar").attr("href", "cluster-groups?id=" + encodeURI(serverId) + "&project=" + encodeURI(project));
   $("#projectsLinkSidebar").attr("href", "projects?id=" + encodeURI(serverId) + "&project=" + encodeURI(project));
   $("#networkAclsLinkSidebar").attr("href", "network-acls?id=" + encodeURI(serverId) + "&project=" + encodeURI(project));
   $("#operationsLinkSidebar").attr("href", "operations?id=" + encodeURI(serverId) + "&project=" + encodeURI(project));

+ 2 - 2
lxconsole/templates/access-controls.html

@@ -162,7 +162,7 @@
     // Add access control
     function addAccessControl(){
       console.log("Info: adding new group");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/access-controls/add_access_control", data, function (data) {
         console.log(data);
         if (data.error_code >= 400){
@@ -192,7 +192,7 @@
     // Update access control
     function updateAccessControl(){
       console.log("Info: adding new access control");
-      data = $('#editForm').serializeJSON();
+      data = $('#editForm').serialize();
       $.post("../api/access-controls/update_access_control", data, function (data) {
         console.log(data);
         if (data.error_code >= 400){

+ 1 - 1
lxconsole/templates/certificates.html

@@ -168,7 +168,7 @@
 
     function addItem(){
       console.log("Info: adding new certificate");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/certificates/add_certificate?id="+serverId+"&project="+project, data, function (data) {
         console.log(data)
         if (data.error_code >= 400){

+ 266 - 0
lxconsole/templates/cluster-groups.html

@@ -0,0 +1,266 @@
+{% extends "main.html" %}
+
+{% block header %}
+  <div class="row mb-2">
+    <div class="col-sm-6">
+      <h1>{{ page_title | safe }}</h1>
+    </div>
+    <div class="col-sm-6">
+      <a class="btn btn-outline-primary float-sm-right mr-4" href="#" data-toggle="modal" data-target="#addModal" title="Add Cluster Group" aria-hidden="true">
+        <i class="fas fa-plus fa-sm fa-fw"></i> Cluster Group
+      </a>
+    </div>
+  </div>
+{% endblock header %}
+
+{% block content %}
+  <div class="col-12">
+    <div class="card">
+      <div class="card-header">
+        <h3 class="card-title">Cluster Groups</h3>
+        <div class="card-tools">
+          <button type="button" class="btn btn-tool" onclick="reloadPageContent()" title="Refresh">
+          <i class="fas fa-sync"></i>
+          </button>
+        </div>
+      </div>
+      <div class="card-body">
+        <table class="table table-hover" id="myDataTable" width="100%" cellspacing="0">
+        </table>
+      </div>
+    </div>
+  </div>
+{% endblock content %}
+
+{% block modal %}
+  {% include 'modals/cluster-groups.html' %}
+{% endblock modal %}
+
+{% block script %}
+  <script>
+    var reloadTime = 10000;
+    const queryString = window.location.search;
+    const urlParams = new URLSearchParams(queryString);
+    const serverId = urlParams.get('id');
+    const project = urlParams.get('project');
+    var editedClusterMember = ''
+    applySidebarStyles();
+    applySidebarLinks();
+    populateSidebarLinks();
+    populateNavbarLinks();
+
+    function reloadPageContent() {
+      //Clear the automatic page reload
+      clearTimeout(pageReloadTimeout);
+
+      //Reload the datatables content
+      $('#myDataTable').DataTable().ajax.reload(null, false);
+
+      //Set the automatic page reload
+      pageReloadTimeout = setTimeout(() => { reloadPageContent(); }, reloadTime);
+    }
+
+    function loadPageContent(){
+
+      //Display the current project
+      $("#selectedProject").text(project);
+
+      //Populate the Server dropdown
+      $.getJSON("../api/servers/list_servers?id="+serverId, function (data) {
+        data = data.data
+        for (var index = 0; index < data.length; index++) {
+          if (data[index].name == '')
+            optionText = data[index].addr
+          else
+            optionText = data[index].name
+          if (data[index].id == serverId)
+            $('#serverListNav').append('<option value="' + data[index].id + '" selected="selected">' + optionText + '</option>');
+          else
+            $('#serverListNav').append('<option value="' + data[index].id + '">' + optionText + '</option>');
+        }
+      })
+
+      //Populate the Project dropdown
+      $.getJSON("../api/projects/list_projects?id="+serverId+"&project="+project, function (data) {
+        data = data.metadata
+        for (var index = 0; index < data.length; index++) {
+          optionText = data[index].replace('/1.0/projects/','');
+          if (optionText == project)
+            $('#projectListNav').append('<option value="' + optionText + '" selected="selected">' + optionText + '</option>');
+          else
+            $('#projectListNav').append('<option value="' + optionText + '">' + optionText + '</option>');
+        }
+      })
+
+      // Configure Datatable
+      $('#myDataTable').DataTable({
+        ajax: {
+          url: "../api/cluster-groups/list_cluster_groups?id="+serverId+"&project=" + project + "&recursion=1",
+          dataType: "json",
+          dataSrc: "metadata",
+          contentType: "application/json"
+        },
+        columns: [
+          { title: "Name", data: function (row, type, set) {
+              if (row.hasOwnProperty('name')) {
+                if (row.name)
+                  return row.name
+              }
+              return '-'
+            },
+          },
+          { title: "Description", data: function (row, type, set) {
+              if (row.hasOwnProperty('description')) {
+                if (row.description)
+                  return row.description
+              }
+              return '-'
+            },
+          },
+          { title: "Members", data: function (row, type, set) {
+              if (row.hasOwnProperty('members')) {
+                if (Object.keys(row.members).length > 0) {
+                  arr = []
+                  for (let i = 0; i < row.members.length; i++) {
+                    arr.push(row.members[i]);
+                  }
+                  return arr.join(", ")
+                }
+              }
+              return '-'
+            },
+          },
+
+          { title: "Actions", data: function (row, type, set) {
+              links = ''
+              if (row.hasOwnProperty('name')) {
+                links += '<a href="#" onclick=editItem(\''+row.name+'\')><i class="fas fa-edit fa-lg" style="color:#ddd" title="Edit" aria-hidden="true"></i></a>' +
+                '&nbsp' + '&nbsp' +
+                '<a href="#" onclick=confirmDeleteItem(\''+row.name+'\')><i class="fas fa-trash-alt fa-lg" style="color:#ddd" title="Delete" aria-hidden="true"></i></a>'
+              }
+              return links
+            },
+          },
+
+        ],
+        order: [],
+      });
+
+      //Set reload page content
+      pageReloadTimeout = setTimeout(() => { reloadPageContent(); }, reloadTime);
+
+    }
+
+    function addItem(){
+      console.log("Info: adding new item");
+      data = $('#addForm').serialize();
+      $.post("../api/cluster-groups/add_cluster_group?id="+serverId+"&project="+project, data, function (data) {
+        console.log(data)
+        if (data.error_code >= 400){
+          alert(data.error);
+        }
+        //Sync type
+        reloadPageContent();
+      });
+    }
+
+    function confirmDeleteItem(name){
+      console.log("Info: confirming deletion of item " + name);
+      $("#deleteQuestionText").text("Are you sure you want to remove the cluster group " + name + "?");
+      $("#clusterGroup").val(name);
+      $("#deleteModal").modal('show');
+    }
+
+    function createItemUsingJSON(){
+      var json = $("#jsonCreateInput").val();
+      console.log("Info: adding new cluster group");
+      $.post("../api/cluster-groups/add_cluster_group?id="+serverId+"&project="+project, { json: json },  function (data) {
+        console.log(data);
+        if (data.error_code >= 400){
+          alert(data.error);
+        }
+        //Sync type
+        reloadPageContent();
+      });
+    }
+
+    function deleteItem(){
+      name = $("#clusterGroup").val();
+      console.log("Info: deleting item " + name);
+      $.post("../api/cluster-groups/delete_cluster_group?id=" + serverId + "&project=" + project, { name: name }, function (data) {
+        console.log(data);
+        if (data.error_code >= 400){
+          alert(data.error);
+        }
+        //Sync type
+        reloadPageContent();
+      });
+    }
+
+    function editItem(name){
+      editedClusterMember = name
+      console.log("Info: loading cluster member " + name);
+      $.post("../api/cluster-groups/load_cluster_group?id=" + serverId + "&project=" + project, { name: name }, function (data) {
+        console.log(data);
+        if (data.error_code >= 400){
+          alert(data.error);
+        }
+        $("#storagePoolNameEditInput").text("Name: " + name);
+        $("#jsonInput").val(JSON.stringify(data.metadata, null, 2));
+        $("#editModal").modal('show');
+      });
+    }
+
+    function renameItem(){
+      name = editedClusterMember
+      console.log("Info: renaming cluster group");
+      data = $('#renameForm').serialize();
+      $.post("../api/cluster-groups/update_cluster_group?id=" + serverId + "&project=" + project + "&name=" + encodeURI(name), data, function (data) {
+        console.log(data)
+        if (data.error_code >= 400){
+          alert(data.error);
+        }
+        //Sync type
+        reloadPageContent();
+      });
+    }
+
+    function updateItem(){
+      name = editedClusterMember
+      var updatedJSON = $("#jsonInput").val();
+      console.log("Info: updating cluster group");
+      $.post("../api/cluster-groups/update_cluster_group?id=" + serverId + "&project=" + project + "&name=" + encodeURI(name), { json: updatedJSON },  function (data) {
+        console.log(data);
+        if (data.error_code >= 400){
+          alert(data.error);
+        }
+        //Sync type
+        reloadPageContent();
+      });
+    }
+
+    $(document).ready(function(){
+
+      //If id or project variables are missing redirect to servers page
+      if (!serverId || !project) {
+        window.location.href = 'servers';
+      }
+      else {
+        loadPageContent()
+        operationStatusCheck()
+
+        //Populate the Cluster Members Dropdown in Add Modal
+        $.getJSON("../api/cluster-members/list_cluster_members?id="+serverId+"&project="+project, function (data) {
+          data = data.metadata
+          for (var index = 0; index < data.length; index++) {
+            optionText = data[index].replace('/1.0/cluster/members/','');
+            $('#clusterMembersInput').append('<option value="' + optionText + '">' + optionText + '</option>');
+          }
+        })
+
+      }
+      
+    });
+
+  </script>
+{% endblock script %}

+ 60 - 12
lxconsole/templates/cluster-members.html

@@ -119,6 +119,15 @@
               return '-'
             },
           },
+          { title: "Roles", data: function (row, type, set) {
+              if (row.hasOwnProperty('roles')) {
+                if (row.roles.length > 0){
+                  return row.roles.join(', <br />')
+                }
+              }
+              return '-'
+            },
+          },
           { title: "URL", data: function (row, type, set) {
               if (row.hasOwnProperty('url')) {
                 if (row.url)
@@ -144,16 +153,24 @@
             },
           },
 
-          // { title: "Actions", data: function (row, type, set) {
-          //     links = ''
-          //     if (row.hasOwnProperty('server_name')) {
-          //       links += '<a href="#" onclick=editItem(\''+row.name+'\')><i class="fas fa-edit fa-lg" style="color:#ddd" title="Edit" aria-hidden="true"></i></a>' +
-          //       '&nbsp' + '&nbsp' +
-          //       '<a href="#" onclick=confirmDeleteItem(\''+row.name+'\')><i class="fas fa-trash-alt fa-lg" style="color:#ddd" title="Delete" aria-hidden="true"></i></a>'
-          //     }
-          //     return links
-          //   },
-          // },
+          { title: "Actions", data: function (row, type, set) {
+              links = ''
+              if (row.hasOwnProperty('status')) {
+                links += '<a href="#" onclick=editItem(\''+row.server_name+'\')><i class="fas fa-edit fa-lg" style="color:#ddd" title="Edit" aria-hidden="true"></i></a>'
+                links += '&nbsp' + '&nbsp'
+                if (row.status == 'Online'){
+                  links += '<a href="#" onclick=changeItemState(\''+row.server_name+'\',\'evacuate\')><i class="fas fa-sign-out-alt fa-lg" style="color:#ddd" title="Evacuate" aria-hidden="true"></i></a>'
+                  links += '&nbsp' + '&nbsp'
+                }
+                if (row.status == 'Evacuated'){
+                  links += '<a href="#" onclick=changeItemState(\''+row.server_name+'\',\'restore\')><i class="fas fa-sign-in-alt fa-lg" style="color:#ddd" title="Restore" aria-hidden="true"></i></a>'
+                  links += '&nbsp' + '&nbsp'
+                }
+                links += '<a href="#" onclick=confirmDeleteItem(\''+row.server_name+'\')><i class="fas fa-trash-alt fa-lg" style="color:#ddd" title="Delete" aria-hidden="true"></i></a>'
+              }
+              return links
+            },
+          },
 
         ],
         order: [],
@@ -166,7 +183,7 @@
 
     function addItem(){
       console.log("Info: adding new item");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/cluster-members/add_cluster_member?id="+serverId+"&project="+project, data, function (data) {
         console.log(data)
         if (data.error_code >= 400){
@@ -177,6 +194,22 @@
       });
     }
 
+    function changeItemState(name, action){
+      console.log("Info: confirming " + action + " action of cluster member " + name);
+      if (confirm("Are you sure you want to " + action + " cluster member " + name + "?") == true) {
+        console.log("Info: " + name + " " + action + " action started");
+        $.post("../api/cluster-members/change_cluster_member_state?id=" + serverId + "&project=" + project, { name: name, action: action }, function (data) {
+          console.log(data);
+          if (data.error_code >= 400){
+            alert(data.error);
+          }
+          //Aync type
+          setTimeout(() => { reloadPageContent(); }, 2000);
+          operationStatusCheck()
+        });
+      }
+    }
+
     function confirmDeleteItem(name){
       console.log("Info: confirming deletion of item " + name);
       $("#deleteQuestionText").text("Are you sure you want to remove " + name + " from the cluster?");
@@ -228,10 +261,25 @@
       });
     }
 
+    function renameItem(){
+      name = editedClusterMember
+      console.log("Info: renaming cluster group");
+      data = $('#renameForm').serialize();
+      $.post("../api/cluster-members/update_cluster_member?id=" + serverId + "&project=" + project + "&name=" + encodeURI(name), data, function (data) {
+        console.log(data)
+        if (data.error_code >= 400){
+          alert(data.error);
+        }
+        //Sync type
+        reloadPageContent();
+      });
+    }
+
+
     function updateItem(){
       name = editedClusterMember
       var updatedJSON = $("#jsonInput").val();
-      console.log("Info: updating cluster membere");
+      console.log("Info: updating cluster member");
       $.post("../api/cluster-members/update_cluster_member?id=" + serverId + "&project=" + project + "&name=" + encodeURI(name), { json: updatedJSON },  function (data) {
         console.log(data);
         if (data.error_code >= 400){

+ 1 - 1
lxconsole/templates/container.html

@@ -3125,7 +3125,7 @@
     // Update the instance from the edit instance modal form
     function updateInstanceForm(){
       console.log("Info: updating instance " + instance);
-      data = $('#editForm').serializeJSON();
+      data = $('#editForm').serialize();
       $.post("../api/container/update_instance?id=" + serverId + "&project=" + project + "&instance=" + encodeURI(instance), data,  function (data) {
         console.log(data);
         if (data.error_code >= 400){

+ 8 - 1
lxconsole/templates/containers.html

@@ -116,6 +116,13 @@
           $('#containerLocationInput').append('<option value="' + optionText + '">' + optionText + '</option>');
         }
       })
+      $.getJSON("../api/cluster-groups/list_cluster_groups?id="+serverId+"&project="+project, function (data) {
+        data = data.metadata
+        for (var index = 0; index < data.length; index++) {
+          optionText = data[index].replace('/1.0/cluster/groups/','');
+          $('#containerLocationInput').append('<option value="@' + optionText + '">@' + optionText + '</option>');
+        }
+      })
 
       //Populate the modal Image dropdown
       $.getJSON("../api/images/list_images?id="+serverId+"&project="+project+"&recursion=1", function (data) {
@@ -264,7 +271,7 @@
     // Add instance
     function addItem(){
       console.log("Info: adding new container");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/containers/add_instance?id="+serverId+"&project="+project, data, function (data) {
         console.log(data);
         if (data.error_code >= 400){

+ 2 - 2
lxconsole/templates/groups.html

@@ -104,7 +104,7 @@
     // Add group
     function addGroup(){
       console.log("Info: adding new group");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/groups/add_group", data, function (data) {
         console.log(data);
         if (data.error_code >= 400){
@@ -133,7 +133,7 @@
     // Update group
     function updateGroup(){
       console.log("Info: adding new group");
-      data = $('#editForm').serializeJSON();
+      data = $('#editForm').serialize();
       $.post("../api/groups/update_group", data, function (data) {
         console.log(data);
         if (data.error_code >= 400){

+ 1 - 1
lxconsole/templates/images.html

@@ -254,7 +254,7 @@
 
     function addItem(){
       console.log("Info: downloading image");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/images/add_image?id="+serverId+"&project="+project, data, function (data) {
         console.log(data);
         if (data.error_code >= 400){

+ 0 - 1
lxconsole/templates/login.html

@@ -147,7 +147,6 @@
     <script src="../static/plugins/datatables-responsive/js/dataTables.responsive.min.js"></script>
     <script src="../static/plugins/datatables-responsive/js/responsive.bootstrap4.min.js"></script>
     <script src="../static/plugins/jquery-knob/jquery.knob.min.js"></script>
-    <script src="../static/js/serializejson.js"></script>
     
     <!-- AdminLTE App -->
     <script src="../static/dist/js/adminlte.min.js"></script>

+ 1 - 2
lxconsole/templates/main.html

@@ -40,7 +40,7 @@
 
       <footer class="main-footer">
         <div class="float-right d-none d-sm-block">
-          Version 0.1.0
+          Version 0.2.0
         </div>
         Copyright &copy; 2020-Present <a href="https://penninglabs.com">Penning Labs</a>. All rights reserved.
       </footer>
@@ -60,7 +60,6 @@
     <script src="../static/plugins/datatables-responsive/js/dataTables.responsive.min.js"></script>
     <script src="../static/plugins/datatables-responsive/js/responsive.bootstrap4.min.js"></script>
     <script src="../static/plugins/jquery-knob/jquery.knob.min.js"></script>
-    <script src="../static/js/serializejson.js"></script>
     
     <!-- AdminLTE App -->
     <script src="../static/dist/js/adminlte.min.js"></script>

+ 171 - 0
lxconsole/templates/modals/cluster-groups.html

@@ -0,0 +1,171 @@
+<!-- Add Modal-->
+<div class="modal fade" id="addModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">
+  <div class="modal-dialog modal-lg" role="document">
+    <div class="modal-content">
+        <div class="modal-header">
+          <h5 class="modal-title" id="exampleModalLabel">Add Cluster Group</h5>
+          <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+            <span aria-hidden="true">×</span>
+          </button>
+        </div>
+        <div class="modal-body">
+          <ul class="nav nav-tabs" id="myTab" role="tablist">
+            <li class="nav-item">
+              <a class="nav-link active" id="form-tab" data-toggle="tab" href="#form" role="tab" aria-controls="form" aria-selected="true">Form</a>
+            </li>
+            <li class="nav-item">
+              <a class="nav-link" id="json-tab" data-toggle="tab" href="#json" role="tab" aria-controls="json" aria-selected="false">JSON</a>
+            </li>
+          </ul>
+          <div class="tab-content" id="myTabContent">
+            <div class="tab-pane fade show active" id="form" role="tabpanel" aria-labelledby="form-tab">
+              <form id="addForm">
+                <br />
+                <div class="row">
+                  <label class="col-3 col-form-label text-right">Name: <span class="text-danger">*</span></label>
+                  <div class="col-7">
+                    <div class="form-group">
+                      <input type="text" class="form-control" placeholder="" name="name">
+                    </div>
+                  </div>
+                  <div class="col-1">
+                    <i class="far fa-sm fa-question-circle" title='(Required) - Enter in the name of the cluster group.'></i>
+                  </div>
+                </div>
+                <div class="row">
+                  <label class="col-3 col-form-label text-right">Description: </label>
+                  <div class="col-7">
+                    <div class="form-group">
+                      <input type="text" class="form-control" placeholder="" name="description">
+                    </div>
+                  </div>
+                  <div class="col-1">
+                    <i class="far fa-sm fa-question-circle" title='Enter in a description for the cluster group.'></i>
+                  </div>
+                </div>
+                <div class="row">
+                  <label class="col-3 col-form-label text-right">Cluster Members: </label>
+                  <div class="col-7">
+                    <div class="form-group">
+                      <select id="clusterMembersInput" class="select form-control" multiple name="members">
+                      </select>
+                    </div>
+                  </div>
+                  <div class="col-1">
+                    <i class="far fa-sm fa-question-circle" title='Select cluster members to add to this cluster group. Use Ctrl key for multiple select'></i>
+                  </div>
+                </div>
+              </form>
+              <div class="modal-footer">
+                <button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
+                <a class="btn btn-primary" href="#" onclick="addItem()" data-dismiss="modal">Submit</a>
+              </div>
+            </div>
+            <div class="tab-pane fade" id="json" role="tabpanel" aria-labelledby="json-tab">
+              <br />
+              <div class="row">
+                <div class="col-12">
+                  <div class="form-group text-right">
+                    <pre>
+                      <textarea name="json" class="form-control" id="jsonCreateInput" rows="16" placeholder="Enter JSON data"></textarea>
+                    </pre>
+                  </div>
+                </div>
+              </div>
+              <div class="modal-footer">
+                <button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
+                <a class="btn btn-primary" href="#" onclick="createItemUsingJSON()" data-dismiss="modal">Submit</a>
+              </div>
+            </div>
+          </div>
+        </div>
+    </div>
+  </div>
+</div>
+
+<!-- Edit Modal-->
+<div class="modal fade" id="editModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">
+  <div class="modal-dialog modal-lg" role="document">
+    <div class="modal-content">
+        <div class="modal-header">
+          <h5 class="modal-title" id="exampleModalLabel">Edit Cluster Group</h5>
+          <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+            <span aria-hidden="true">×</span>
+          </button>
+        </div>
+        <div class="modal-body">
+          <ul class="nav nav-tabs" id="myTab" role="tablist">
+            <li class="nav-item">
+              <a class="nav-link active" id="json-edit-tab" data-toggle="tab" href="#json-edit" role="tab" aria-controls="json-edit" aria-selected="false">JSON</a>
+            </li>
+            <li class="nav-item">
+              <a class="nav-link" id="rename-edit-tab" data-toggle="tab" href="#rename-edit" role="tab" aria-controls="rename-edit" aria-selected="true">Rename</a>
+            </li>
+          </ul>
+          <div class="tab-content" id="myTabContent">
+            <div class="tab-pane fade show active" id="json-edit" role="tabpanel" aria-labelledby="json-edit-tab">
+              <br />
+              <div class="row">
+                <label class="col-4 col-form-label" id="itemNameEditInput"></label>
+                <div class="col-12">
+                  <div class="form-group text-right">
+                    <pre>
+                      <textarea name="json" class="form-control" id="jsonInput" rows="16" ></textarea>
+                    </pre>
+                  </div>
+                </div>
+              </div>
+              <div class="modal-footer">
+                <button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
+                <a class="btn btn-primary" href="#" onclick="updateItem()" data-dismiss="modal">Submit</a>
+              </div>
+            </div>
+            <div class="tab-pane fade" id="rename-edit" role="tabpanel" aria-labelledby="rename-edit-tab">
+              <form id="renameForm">
+                <br />
+                <div class="row">
+                  <label class="col-2 col-form-label text-right">Name:</label>
+                  <div class="col-8">
+                    <div class="form-group">
+                      <input type="text" class="form-control" placeholder="" name="name">
+                    </div>
+                  </div>
+                  <div class="col-1">
+                    <i class="far fa-sm fa-question-circle" title='Enter in the new name of the cluster group.'></i>
+                  </div>
+                </div>
+              </form>
+              <div class="modal-footer">
+                <button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
+                <a class="btn btn-primary" href="#" onclick="renameItem()" data-dismiss="modal">Submit</a>
+              </div>
+            </div>
+          </div>
+        </div>
+    </div>
+  </div>
+</div>
+
+<!-- Delete Modal-->
+<div class="modal fade" id="deleteModal" tabindex="-1" role="dialog" aria-labelledby="exampleModalLabel" aria-hidden="true">
+  <div class="modal-dialog" role="document">
+    <div class="modal-content">
+      <div class="modal-header">
+        <h5 class="modal-title" id="exampleModalLabel">Delete Cluster Group</h5>
+        <button class="close" type="button" data-dismiss="modal" aria-label="Close">
+          <span aria-hidden="true">×</span>
+        </button>
+      </div>
+      <div class="modal-body">
+        <div class="mb-2" id="deleteQuestionText">
+          Are you sure you want to delete this cluster group?
+        </div>
+        <input type="hidden" id="clusterGroup" class="form-control" name="server_name">
+      </div>
+      <div class="modal-footer">
+        <button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
+        <a class="btn btn-primary" href="#" onclick="deleteItem()" data-dismiss="modal">Yes</a>
+      </div>
+    </div>
+  </div>
+</div>

+ 45 - 11
lxconsole/templates/modals/cluster-members.html

@@ -82,21 +82,55 @@
           </button>
         </div>
         <div class="modal-body">
-          <div class="row">
-            <label class="col-4 col-form-label" id="itemNameEditInput"></label>
-            <div class="col-12">
-              <div class="form-group text-right">
-                <pre>
-                  <textarea name="json" class="form-control" id="jsonInput" rows="16" ></textarea>
-                </pre>
+          <ul class="nav nav-tabs" id="myTab" role="tablist">
+            <li class="nav-item">
+              <a class="nav-link active" id="json-edit-tab" data-toggle="tab" href="#json-edit" role="tab" aria-controls="json-edit" aria-selected="false">JSON</a>
+            </li>
+            <li class="nav-item">
+              <a class="nav-link" id="rename-edit-tab" data-toggle="tab" href="#rename-edit" role="tab" aria-controls="rename-edit" aria-selected="true">Rename</a>
+            </li>
+          </ul>
+          <div class="tab-content" id="myTabContent">
+            <div class="tab-pane fade show active" id="json-edit" role="tabpanel" aria-labelledby="json-edit-tab">
+              <br />
+              <div class="row">
+                <label class="col-4 col-form-label" id="itemNameEditInput"></label>
+                <div class="col-12">
+                  <div class="form-group text-right">
+                    <pre>
+                      <textarea name="json" class="form-control" id="jsonInput" rows="16" ></textarea>
+                    </pre>
+                  </div>
+                </div>
+              </div>
+              <div class="modal-footer">
+                <button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
+                <a class="btn btn-primary" href="#" onclick="updateItem()" data-dismiss="modal">Submit</a>
+              </div>
+            </div>
+            <div class="tab-pane fade" id="rename-edit" role="tabpanel" aria-labelledby="rename-edit-tab">
+              <form id="renameForm">
+                <br />
+                <div class="row">
+                  <label class="col-2 col-form-label text-right">Name:</label>
+                  <div class="col-8">
+                    <div class="form-group">
+                      <input type="text" class="form-control" placeholder="" name="server_name">
+                    </div>
+                    <span>After renaming a cluster member, restart the LXD service on the host to clear cached name information.</span>
+                  </div>
+                  <div class="col-1">
+                    <i class="far fa-sm fa-question-circle" title='Enter in the new name of the cluster member. Host alias will apply rename changes after LXD services have restarted on host.'></i>
+                  </div>
+                </div>
+              </form>
+              <div class="modal-footer">
+                <button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
+                <a class="btn btn-primary" href="#" onclick="renameItem()" data-dismiss="modal">Submit</a>
               </div>
             </div>
           </div>
         </div>
-        <div class="modal-footer">
-          <button class="btn btn-secondary" type="button" data-dismiss="modal">Cancel</button>
-          <a class="btn btn-primary" href="#" onclick="updateItem()" data-dismiss="modal">Submit</a>
-        </div>
     </div>
   </div>
 </div>

+ 2 - 1
lxconsole/templates/modals/containers.html

@@ -257,9 +257,10 @@
                     </div>
                   </div>
                   <div class="col-1">
-                    <i class="far fa-sm fa-question-circle" title='Select the LXD cluster member to deploy this container on. Default: none'></i>
+                    <i class="far fa-sm fa-question-circle" title='Select the LXD cluster member or @group to deploy this container on. Default: none'></i>
                   </div>
                 </div>
+
                 <hr class="mb-2">
                 <nav>
                   <div class="nav nav-pills justify-content-center" id="nav-tab" role="tablist">

+ 1 - 1
lxconsole/templates/modals/main.html

@@ -13,7 +13,7 @@
           <div class="col-12">
             <p>Lxconsole is an open source management console providing a web-based user interface capable of managing multiple LXD servers from a single location.</p>
             <p>
-              <strong>Version</strong>: <span id="versionNumber">v0.1.0</span> <br />
+              <strong>Version</strong>: <span id="versionNumber">v0.2.0</span> <br />
               <strong>License</strong>: AGPL-3.0 <br />
               <strong>URL</strong>: https://lxconsole.com <br />
             </p>          

+ 2 - 1
lxconsole/templates/modals/virtual-machines.html

@@ -257,9 +257,10 @@
                     </div>
                   </div>
                   <div class="col-1">
-                    <i class="far fa-sm fa-question-circle" title='Select the LXD cluster member to deploy this instance on. Default: none'></i>
+                    <i class="far fa-sm fa-question-circle" title='Select the LXD cluster member or @group to deploy this container on. Default: none'></i>
                   </div>
                 </div>
+                
                 <hr class="mb-2">
                 <nav>
                   <div class="nav nav-pills justify-content-center" id="nav-tab" role="tablist">

+ 1 - 1
lxconsole/templates/network-acl.html

@@ -340,7 +340,7 @@
 
     function addItem(){
       console.log("Info: adding new network-acl");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/network-acl/add_network_acl?id="+serverId+"&project="+project+"&acl="+acl, data, function (data) {
         console.log(data)
         if (data.error_code >= 400){

+ 1 - 1
lxconsole/templates/network-acls.html

@@ -175,7 +175,7 @@
 
     function addItem(){
       console.log("Info: adding new network-acl");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/network-acls/add_network_acl?id="+serverId+"&project="+project, data, function (data) {
         console.log(data)
         if (data.error_code >= 400){

+ 1 - 1
lxconsole/templates/networks.html

@@ -190,7 +190,7 @@
 
     function addItem(){
       console.log("Info: adding new network");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/networks/add_network?id="+serverId+"&project="+project, data, function (data) {
         console.log(data)
         if (data.error_code >= 400){

+ 1 - 1
lxconsole/templates/profiles.html

@@ -153,7 +153,7 @@
 
     function addItem(){
       console.log("Info: adding new profile");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/profiles/add_profile?id="+serverId+"&project="+project, data, function (data) {
         console.log(data)
         if (data.error_code >= 400){

+ 1 - 1
lxconsole/templates/projects.html

@@ -207,7 +207,7 @@
 
     function addItem(){
       console.log("Info: adding new project");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/projects/add_project?id="+serverId+"&project="+project, data, function (data) {
         console.log(data)
         if (data.error_code >= 400){

+ 0 - 1
lxconsole/templates/register.html

@@ -165,7 +165,6 @@
     <script src="../static/plugins/datatables-responsive/js/dataTables.responsive.min.js"></script>
     <script src="../static/plugins/datatables-responsive/js/responsive.bootstrap4.min.js"></script>
     <script src="../static/plugins/jquery-knob/jquery.knob.min.js"></script>
-    <script src="../static/js/serializejson.js"></script>
     
     <!-- AdminLTE App -->
     <script src="../static/dist/js/adminlte.min.js"></script>

+ 1 - 1
lxconsole/templates/roles.html

@@ -108,7 +108,7 @@
     // Add user
     function addRole(){
       console.log("Info: adding new role");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/roles/add_role", data, function (data) {
         console.log(data);
         if (data.error_code >= 400){

+ 8 - 0
lxconsole/templates/sidebar.html

@@ -36,6 +36,14 @@
               </p>
             </a>
           </li>
+          <li class="nav-item">
+            <a class="nav-link" id="clusterGroupsLinkSidebar" href="cluster-groups" style="display: none;">
+              <i id="clusterGroupsIcon" class="nav-icon fas fa-layer-group"></i>
+              <p id="clusterGroupsSpan">
+                Cluster Groups
+              </p>
+            </a>
+          </li>
         </ul>
 
         <ul id="instanceSidebarLinks" class="nav nav-pills nav-sidebar flex-column user-panel py-2" style="display: none;" data-widget="treeview" role="menu" data-accordion="false">

+ 1 - 1
lxconsole/templates/simplestreams.html

@@ -135,7 +135,7 @@
 
     function addItem(){
       console.log("Info: adding new simplestreams repo");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/simplestreams/add_simplestream", data, function (data) {
         console.log(data)
         if (data.error_code >= 400){

+ 1 - 1
lxconsole/templates/storage-pools.html

@@ -174,7 +174,7 @@
 
     function addItem(){
       console.log("Info: adding new storage pool");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/storage-pools/add_storage_pool?id="+serverId+"&project="+project, data, function (data) {
         console.log(data)
         if (data.error_code >= 400){

+ 1 - 1
lxconsole/templates/storage-volumes.html

@@ -202,7 +202,7 @@
 
     function addItem(){
       console.log("Info: adding new storage volume");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/storage-volumes/add_storage_volume?id="+serverId+"&project="+project+"&pool="+pool, data, function (data) {
         console.log(data)
         if (data.error_code >= 400){

+ 4 - 4
lxconsole/templates/users.html

@@ -133,7 +133,7 @@
     // Add user
     function addUser(){
       console.log("Info: adding new user");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/users/add_user", data, function (data) {
         console.log(data);
         if (data.error_code >= 400){
@@ -182,7 +182,7 @@
     // Update user
     function updateUser(){
       console.log("Info: adding new user");
-      data = $('#editForm').serializeJSON();
+      data = $('#editForm').serialize();
       $.post("../api/users/update_user", data, function (data) {
         console.log(data);
         if (data.error_code >= 400){
@@ -196,7 +196,7 @@
     // Update user group
     function updateUserGroup(){
       console.log("Info: editing user group");
-      data = $('#editUserGroupsForm').serializeJSON();
+      data = $('#editUserGroupsForm').serialize();
       $.post("../api/users/update_user", data, function (data) {
         console.log(data);
         if (data.error_code >= 400){
@@ -210,7 +210,7 @@
     // Update user password
     function updateUserPassword(){
       console.log("Info: editing user password");
-      data = $('#editUserPasswordForm').serializeJSON();
+      data = $('#editUserPasswordForm').serialize();
       $.post("../api/users/update_user", data, function (data) {
         console.log(data);
         if (data.error_code >= 400){

+ 1 - 1
lxconsole/templates/virtual-machine.html

@@ -2832,7 +2832,7 @@
     // Update the instance from the edit instance modal form
     function updateInstanceForm(){
       console.log("Info: updating instance " + instance);
-      data = $('#editForm').serializeJSON();
+      data = $('#editForm').serialize();
       $.post("../api/virtual-machine/update_instance?id=" + serverId + "&project=" + project + "&instance=" + encodeURI(instance), data,  function (data) {
         console.log(data);
         if (data.error_code >= 400){

+ 8 - 1
lxconsole/templates/virtual-machines.html

@@ -116,6 +116,13 @@
           $('#virtualMachineLocationInput').append('<option value="' + optionText + '">' + optionText + '</option>');
         }
       })
+      $.getJSON("../api/cluster-groups/list_cluster_groups?id="+serverId+"&project="+project, function (data) {
+        data = data.metadata
+        for (var index = 0; index < data.length; index++) {
+          optionText = data[index].replace('/1.0/cluster/groups/','');
+          $('#containerLocationInput').append('<option value="@' + optionText + '">@' + optionText + '</option>');
+        }
+      })
 
       //Populate the modal Image dropdown
       $.getJSON("../api/images/list_images?id="+serverId+"&project="+project+"&recursion=1", function (data) {
@@ -260,7 +267,7 @@
     // Add instance
     function addItem(){
       console.log("Info: adding new virtual machine");
-      data = $('#addForm').serializeJSON();
+      data = $('#addForm').serialize();
       $.post("../api/virtual-machines/add_instance?id="+serverId+"&project="+project, data, function (data) {
         console.log(data);
         if (data.error_code >= 400){

+ 0 - 3
roadmap.txt

@@ -1,6 +1,3 @@
-# 0.2.0
-- Add cluster groups
-
 # 0.3.0
 - Add network-zones
 - Add network-forwards