From b9aff159d86cd5ad39a9bc2a35d3e55977a9b821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adolfo=20G=C3=B3mez?= Date: Sun, 8 Dec 2013 06:41:57 +0000 Subject: [PATCH] Improved Service providers section over administration client implementation. js now shows logs for providers & for services --- server/.project | 5 + .../org.eclipse.wst.validation.prefs | 8 ++ server/src/uds/REST/methods/authenticators.py | 1 - server/src/uds/REST/methods/services.py | 14 ++- server/src/uds/REST/model.py | 42 +++++-- server/src/uds/static/adm/js/api.js | 45 ++++++-- .../src/uds/static/adm/js/gui-definition.js | 109 +++++++++++++++--- server/src/uds/static/adm/js/gui-element.js | 100 +++++++++++++--- server/src/uds/static/adm/js/gui-tools.js | 22 ++++ server/src/uds/static/adm/js/gui.js | 20 +--- .../uds/admin/tmpl/authenticators.html | 8 +- .../templates/uds/admin/tmpl/providers.html | 23 +++- 12 files changed, 316 insertions(+), 81 deletions(-) create mode 100644 server/.settings/org.eclipse.wst.validation.prefs diff --git a/server/.project b/server/.project index a9d009ba..675c81b7 100644 --- a/server/.project +++ b/server/.project @@ -15,6 +15,11 @@ + + org.eclipse.wst.validation.validationbuilder + + + org.python.pydev.pythonNature diff --git a/server/.settings/org.eclipse.wst.validation.prefs b/server/.settings/org.eclipse.wst.validation.prefs new file mode 100644 index 00000000..314d1472 --- /dev/null +++ b/server/.settings/org.eclipse.wst.validation.prefs @@ -0,0 +1,8 @@ +DELEGATES_PREFERENCE=delegateValidatorList +USER_BUILD_PREFERENCE=enabledBuildValidatorList +USER_MANUAL_PREFERENCE=enabledManualValidatorList +USER_PREFERENCE=overrideGlobalPreferencesfalse +eclipse.preferences.version=1 +override=false +suspend=false +vf.version=3 diff --git a/server/src/uds/REST/methods/authenticators.py b/server/src/uds/REST/methods/authenticators.py index 297dfda5..f9abd4bd 100644 --- a/server/src/uds/REST/methods/authenticators.py +++ b/server/src/uds/REST/methods/authenticators.py @@ -36,7 +36,6 @@ from django.utils.translation import ugettext_lazy as _ from uds.models import Authenticator from uds.core import auths - from users_groups import Users, Groups from uds.REST import NotFound from uds.REST.model import ModelHandler diff --git a/server/src/uds/REST/methods/services.py b/server/src/uds/REST/methods/services.py index c2f37251..f156063f 100644 --- a/server/src/uds/REST/methods/services.py +++ b/server/src/uds/REST/methods/services.py @@ -34,8 +34,10 @@ from __future__ import unicode_literals from django.utils.translation import ugettext as _ + from uds.models import Service +from uds.core.util import log from uds.core.Environment import Environment from uds.REST.model import DetailHandler from uds.REST import NotFound, ResponseError, RequestError @@ -90,7 +92,7 @@ class Services(DetailHandler): service.data = service.getInstance(self._params).serialize() service.save() except Service.DoesNotExist: - raise NotFound('Item not found') + self.invalidItemException() except IntegrityError: # Duplicate key probably raise RequestError('Element already exists (duplicate key error)') except Exception: @@ -108,7 +110,7 @@ class Services(DetailHandler): service.delete() except: - raise NotFound('service not found') + self.invalidItemException() return 'deleted' @@ -155,3 +157,11 @@ class Services(DetailHandler): except Exception as e: logger.exception('getGui') raise ResponseError(unicode(e)) + + def getLogs(self, parent, item): + try: + item = parent.services.get(pk=item) + logger.debug('Getting logs for {0}'.format(item)) + return log.getLogs(item) + except: + self.invalidItemException() \ No newline at end of file diff --git a/server/src/uds/REST/model.py b/server/src/uds/REST/model.py index 17a92730..fc19cac0 100644 --- a/server/src/uds/REST/model.py +++ b/server/src/uds/REST/model.py @@ -37,6 +37,8 @@ from django.utils.translation import ugettext as _ from django.db import IntegrityError from uds.REST.handlers import Handler +from uds.core.util import log + import logging logger = logging.getLogger(__name__) @@ -46,6 +48,7 @@ OVERVIEW = 'overview' TYPES = 'types' TABLEINFO = 'tableinfo' GUI = 'gui' +LOG = 'log' # Base for Gui Related mixins class BaseModelHandler(Handler): @@ -151,7 +154,13 @@ class BaseModelHandler(Handler): # Exceptions def invalidRequestException(self): - raise RequestError('Invalid Request') + raise RequestError(_('Invalid Request')) + + def invalidMethodException(self): + raise NotFound(_('Method not found')) + + def invalidItemException(self): + raise NotFound(_('Item not found')) # Details do not have types at all # so, right now, we only process details petitions for Handling & tables info @@ -208,6 +217,8 @@ class DetailHandler(BaseModelHandler): return sorted(gui, key=lambda f: f['gui']['order']) elif self._args[0] == TYPES: return self.getTypes(parent, self._args[1]) + elif self._args[1] == LOG: + return self.getLogs(parent, self._args[0]) return self.fallbackGet() @@ -252,6 +263,7 @@ class DetailHandler(BaseModelHandler): def fallbackGet(self): raise self.invalidRequestException() + # Override this to provide functionality # Default (as sample) getItems def getItems(self, parent, item): if item is None: # Returns ALL detail items @@ -279,6 +291,9 @@ class DetailHandler(BaseModelHandler): def getTypes(self, parent, forType): return [] # Default is that details do not have types + + def getLogs(self, parent, item): + self.invalidMethodException() class ModelHandler(BaseModelHandler): ''' @@ -335,6 +350,11 @@ class ModelHandler(BaseModelHandler): logger.debug('Found type {0}'.format(v)) return found + # log related + def getLogs(self, item): + logger.debug('Default getLogs invoked') + return log.getLogs(item) + # gui related def getGui(self, type_): self.invalidRequestException() @@ -357,7 +377,7 @@ class ModelHandler(BaseModelHandler): detail = detailCls(self, path, self._params, *args, parent = item) method = getattr(detail, self._operation) except AttributeError: - raise NotFound('method not found') + self.invalidMethodException() return method() @@ -394,25 +414,33 @@ class ModelHandler(BaseModelHandler): self.fillIntanceFields(val, res) return res except: - raise NotFound('item not found') + self.invalidItemException() # nArgs > 1 # Request type info or gui, or detail if self._args[0] == TYPES: if nArgs != 2: - raise RequestError('invalid request') + self.invalidRequestException() return self.getType(self._args[1]) elif self._args[0] == GUI: if nArgs != 2: - raise RequestError('invalid request') + self.invalidRequestException() gui = self.getGui(self._args[1]) return sorted(gui, key=lambda f: f['gui']['order']) + elif self._args[1] == LOG: + if nArgs != 2: + self.invalidRequestException() + try: + item = self.model.objects.filter(pk=self._args[0])[0] + except: + self.invalidItemException() + return self.getLogs(item) # If has detail and is requesting detail if self.detail is not None: return self.processDetail() - raise RequestError('invalid request') + self.invalidRequestException() def post(self): # right now @@ -421,7 +449,7 @@ class ModelHandler(BaseModelHandler): if self._args[0] == 'test': return 'tested' - raise NotFound('Method not found') + self.invalidMethodException() def put(self): logger.debug('method PUT for {0}, {1}'.format(self.__class__.__name__, self._args)) diff --git a/server/src/uds/static/adm/js/api.js b/server/src/uds/static/adm/js/api.js index 68e0068c..23c6c21e 100644 --- a/server/src/uds/static/adm/js/api.js +++ b/server/src/uds/static/adm/js/api.js @@ -169,6 +169,7 @@ function BasicModelRest(path, options) { // Requests paths this.path = path; this.getPath = options.getPath || path; + this.logPath = options.logPath || path; this.putPath = options.putPath || path; this.testPath = options.testPath || (path + '/test'); this.delPath = options.delPath || path; @@ -248,6 +249,19 @@ BasicModelRest.prototype = { fail: fail_fnc }); + }, + // ------------- + // Log methods + // ------------- + getLogs: function(itemId, success_fnc, fail_fnc) { + "use strict"; + var path = this.logPath + '/' + itemId + '/' + 'log'; + return this._requestPath(path, { + cacheKey: '.', + success: success_fnc, + fail: fail_fnc + }); + }, // ------------- @@ -374,6 +388,25 @@ DetailModelRestApi.prototype = { "use strict"; return this.base.get(success_fnc, fail_fnc); }, + list: function(success_fnc, fail_fnc) { // This is "almost" an alias for get + "use strict"; + return this.base.list(success_fnc, fail_fnc); + }, + overview: function(success_fnc, fail_fnc) { + "use strict"; + return this.base.overview(success_fnc, fail_fnc); + }, + item: function(itemId, success_fnc, fail_fnc) { + "use strict"; + return this.base.item(itemId, success_fnc, fail_fnc); + }, + // ------------- + // Log methods + // ------------- + getLogs: function(itemId, success_fnc, fail_fnc) { + "use strict"; + return this.base.getLogs(itemId, success_fnc, fail_fnc); + }, put: function(data, options) { "use strict"; return this.base.put(data, options); @@ -412,18 +445,6 @@ DetailModelRestApi.prototype = { "use strict"; return this.base.tableInfo(success_fnc, fail_fnc); }, - list: function(success_fnc, fail_fnc) { // This is "almost" an alias for get - "use strict"; - return this.base.list(success_fnc, fail_fnc); - }, - overview: function(success_fnc, fail_fnc) { - "use strict"; - return this.base.overview(success_fnc, fail_fnc); - }, - item: function(itemId, success_fnc, fail_fnc) { - "use strict"; - return this.base.item(itemId, success_fnc, fail_fnc); - }, types: function(success_fnc, fail_fnc) { "use strict"; if( this.options.types ) { diff --git a/server/src/uds/static/adm/js/gui-definition.js b/server/src/uds/static/adm/js/gui-definition.js index f7e811b3..d7b8ffe3 100644 --- a/server/src/uds/static/adm/js/gui-definition.js +++ b/server/src/uds/static/adm/js/gui-definition.js @@ -38,13 +38,39 @@ gui.providers.link = function(event) { }, }; + var serviceLogTable; var prevTables = []; + var clearDetails = function() { + gui.doLog('Clearing details'); + $.each(prevTables, function(undefined, tbl){ + var $tbl = $(tbl).dataTable(); + $tbl.fnClearTable(); + $tbl.fnDestroy(); + }); + + if( serviceLogTable ) { + var $tbl = $(serviceLogTable).dataTable(); + $tbl.fnClearTable(); + $tbl.fnDestroy(); + $('#services-log-placeholder').empty(); + serviceLogTable = undefined; + } + + prevTables = []; + $('#services-placeholder').empty(); + $('#logs-placeholder').empty(); + $('#services-log-placeholder').empty(); + + $('#detail-placeholder').addClass('hidden'); + }; api.templates.get('providers', function(tmpl) { gui.clearWorkspace(); gui.appendToWorkspace(api.templates.evaluate(tmpl, { providers : 'providers-placeholder', services : 'services-placeholder', + services_log : 'services-log-placeholder', + logs: 'logs-placeholder', })); gui.setLinksEvents(); @@ -61,17 +87,14 @@ gui.providers.link = function(event) { }*/ return true; }, + onRowDeselect: function() { + clearDetails(); + }, onRowSelect : function(selected) { gui.tools.blockUI(); - gui.doLog(selected[0]); - $.each(prevTables, function(undefined, tbl){ - var $tbl = $(tbl).dataTable(); - $tbl.fnClearTable(); - $tbl.fnDestroy(); - }); - prevTables = []; - $('#services-placeholder').empty(); + clearDetails(); + $('#detail-placeholder').removeClass('hidden'); var id = selected[0].id; // Giving the name compossed with type, will ensure that only styles will be reattached once @@ -80,6 +103,33 @@ gui.providers.link = function(event) { var servicesTable = services.table({ container : 'services-placeholder', rowSelect : 'single', + onRowSelect: function(sselected) { + gui.tools.blockUI(); + var sId = sselected[0].id; + + if( serviceLogTable ) { + var $tbl = $(serviceLogTable).dataTable(); + $tbl.fnClearTable(); + $tbl.fnDestroy(); + $('#services-log-placeholder').empty(); + } + + serviceLogTable = services.logTable(sId, { + container: 'services-log-placeholder', + onLoad: function() { + gui.tools.unblockUI(); + } + }); + }, + onRowDeselect : function() { + if( serviceLogTable ) { + var $tbl = $(serviceLogTable).dataTable(); + $tbl.fnClearTable(); + $tbl.fnDestroy(); + $('#services-log-placeholder').empty(); + } + serviceLogTable = undefined; + }, onCheck: function(check, items) { if( check == 'delete' ) { for( var i in items ) { @@ -100,7 +150,12 @@ gui.providers.link = function(event) { }, }); + var logTable = gui.providers.logTable(id, { + container : 'logs-placeholder', + }); + prevTables.push(servicesTable); + prevTables.push(logTable); }, buttons : [ 'new', 'edit', 'delete', 'xls' ], onNew : gui.methods.typedNew(gui.providers, gettext('New provider'), gettext('Error creating provider'), testButton), @@ -129,6 +184,21 @@ gui.authenticators.link = function(event) { }; var prevTables = []; + var clearDetails = function() { + $.each(prevTables, function(undefined, tbl){ + var $tbl = $(tbl).dataTable(); + $tbl.fnClearTable(); + $tbl.fnDestroy(); + }); + + $('#users-placeholder').empty(); + $('#groups-placeholder').empty(); + $('#logs-placeholder').empty(); + + $('#detail-placeholder').addClass('hidden'); + + prevTables = []; + }; gui.doLog('enter auths'); api.templates.get('authenticators', function(tmpl) { @@ -137,28 +207,24 @@ gui.authenticators.link = function(event) { auths : 'auths-placeholder', users : 'users-placeholder', groups: 'groups-placeholder', + logs: 'logs-placeholder', })); gui.setLinksEvents(); - gui.authenticators.table({ + var tableId = gui.authenticators.table({ container : 'auths-placeholder', rowSelect : 'single', buttons : [ 'new', 'edit', 'delete', 'xls' ], + onRowDeselect: function() { + clearDetails(); + }, onRowSelect : function(selected) { // We can have lots of users, so memory can grow up rapidly if we do not keep thins clena // To do so, we empty previous table contents before storing new table contents // Anyway, TabletTools will keep "leaking" memory, but we can handle a little "leak" that will be fixed as soon as we change the section - $.each(prevTables, function(undefined, tbl){ - var $tbl = $(tbl).dataTable(); - $tbl.fnClearTable(); - $tbl.fnDestroy(); - }); - - $('#users-placeholder').empty(); - $('#groups-placeholder').empty(); - - prevTables = []; + clearDetails(); + $('#detail-placeholder').removeClass('hidden'); gui.tools.blockUI(); var id = selected[0].id; @@ -184,9 +250,14 @@ gui.authenticators.link = function(event) { }, }); + var logTable = gui.authenticators.logTable(id, { + container : 'logs-placeholder', + }); + // So we can destroy the tables beforing adding new ones prevTables.push(grpTable); prevTables.push(usrTable); + prevTables.push(logTable); return false; }, diff --git a/server/src/uds/static/adm/js/gui-element.js b/server/src/uds/static/adm/js/gui-element.js index 6c7ea16e..8c89891d 100644 --- a/server/src/uds/static/adm/js/gui-element.js +++ b/server/src/uds/static/adm/js/gui-element.js @@ -50,7 +50,7 @@ GuiElement.prototype = { // buttons: array of visible buttons (strings), valid are [ 'new', 'edit', 'refresh', 'delete', 'xls' ], // rowSelect: type of allowed row selection, valid values are 'single' and 'multi' // scrollToTable: if True, will scroll page to show table - // deferedRender: if True, datatable will be created with "bDeferRender": true, that will improve a lot creation + // deferedRender: if True, datatable will be created with "bDeferRender": true, that will improve a lot creation of huge tables // // onLoad: Event (function). If defined, will be invoked when table is fully loaded. // Receives 1 parameter, that is the gui element (GuiElement) used to render table @@ -85,7 +85,7 @@ GuiElement.prototype = { // 4.- the DataTable that raised the event table : function(options) { "use strict"; - gui.doLog('Types: ', this.types); + options = options || {}; gui.doLog('Composing table for ' + this.name); var tableId = this.name + '-table'; @@ -102,13 +102,6 @@ GuiElement.prototype = { return data; }; - // Datetime renderer (with specified format) - var renderDate = function(format) { - return function(data, type, full) { - return api.tools.strftime(format, new Date(data*1000)); - }; - }; - // Icon renderer, based on type (created on init methods in styles) var renderTypeIcon = function(data, type, value){ if( type == 'display' ) { @@ -160,14 +153,14 @@ GuiElement.prototype = { switch(opts.type) { case 'date': column.sType = 'date'; - column.mRender = renderDate(api.tools.djangoFormat(get_format('SHORT_DATE_FORMAT'))); + column.mRender = gui.tools.renderDate(api.tools.djangoFormat(get_format('SHORT_DATE_FORMAT'))); break; case 'datetime': column.sType = 'date'; - column.mRender = renderDate(api.tools.djangoFormat(get_format('SHORT_DATETIME_FORMAT'))); + column.mRender = gui.tools.renderDate(api.tools.djangoFormat(get_format('SHORT_DATETIME_FORMAT'))); break; case 'time': - column.mRender = renderDate(api.tools.djangoFormat(get_format('TIME_FORMAT'))); + column.mRender = gui.tools.renderDate(api.tools.djangoFormat(get_format('TIME_FORMAT'))); break; case 'iconType': //columnt.sType = 'html'; // html is default, so this is not needed @@ -192,7 +185,7 @@ GuiElement.prototype = { }); // Responsive style for tables, using tables.css and this code generates the "titles" for vertical display on small sizes $('#style-' + tableId).remove(); // Remove existing style for table before adding new one - $(api.templates.evaluate('tmpl_responsive_table', { + $(api.templates.evaluate('tmpl_comp_responsive_table', { tableId: tableId, columns: columns, })).appendTo('head'); @@ -392,19 +385,19 @@ GuiElement.prototype = { // Initializes oTableTools var oTableTools = { "aButtons" : btns, - "sRowSelect": options.rowSelect || 'single', + "sRowSelect": options.rowSelect || 'none', }; if (options.onRowSelect) { var rowSelectedFnc = options.onRowSelect; oTableTools.fnRowSelected = function() { - rowSelectedFnc(this.fnGetSelectedData(), $('#' + tableId).dataTable(), this); + rowSelectedFnc(this.fnGetSelectedData(), $('#' + tableId).dataTable(), self); }; } if (options.onRowDeselect) { var rowDeselectedFnc = options.onRowDeselect; oTableTools.fnRowDeselected = function() { - rowDeselectedFnc(this.fnGetSelectedData(), $('#' + tableId).dataTable(), this); + rowDeselectedFnc(this.fnGetSelectedData(), $('#' + tableId).dataTable(), self); }; } @@ -425,6 +418,7 @@ GuiElement.prototype = { $('#' + tableId + '_filter input').addClass('form-control'); // Add refresh action to panel $(table.refreshSelector).click(refreshFnc); + // Add tooltips to "new" buttons $('.DTTT_dropdown [data-toggle="tooltip"]').tooltip({ container:'body', @@ -443,6 +437,80 @@ GuiElement.prototype = { }); // End Overview data }); // End Tableinfo data + return '#' + tableId; + }, + logTable: function(itemId, options) { + "use strict"; + options = options || {}; + gui.doLog('Composing log for ' + this.name); + var tableId = this.name + '-table-log'; + var self = this; // Store this for child functions + + // Renderers for columns + var columns = [ + { + "mData" : 'date', + "sTitle" : gettext('Date'), + "mRender" : gui.tools.renderDate(api.tools.djangoFormat(get_format('SHORT_DATE_FORMAT') + ' ' + get_format('TIME_FORMAT'))), + "bSortable" : true, + "bSearchable" : true, + }, + { + "mData" : 'level', + "sTitle" : gettext('level'), + "mRender" : gui.tools.renderLogLovel(), + "sWidth" : "5em", + "bSortable" : true, + "bSearchable" : true, + }, + { + "mData" : 'source', + "sTitle" : gettext('source'), + "sWidth" : "5em", + "bSortable" : true, + "bSearchable" : true, + }, + { + "mData" : 'message', + "sTitle" : gettext('message'), + "bSortable" : true, + "bSearchable" : true, + }, + ]; + + var table = gui.table(options.title || gettext('Logs'), tableId); + if (options.container === undefined) { + gui.appendToWorkspace('
' + table.text + '
'); + } else { + $('#' + options.container).empty(); + $('#' + options.container).append(table.text); + } + + // Responsive style for tables, using tables.css and this code generates the "titles" for vertical display on small sizes + $('#style-' + tableId).remove(); // Remove existing style for table before adding new one + $(api.templates.evaluate('tmpl_comp_responsive_table', { + tableId: tableId, + columns: columns, + })).appendTo('head'); + + self.rest.getLogs(itemId, function(data){ + gui.doLog(data); + + $('#' + tableId).dataTable({ + "aaData" : data, + "oTableTools" : {"aButtons" : [],}, + "aoColumns" : columns, + "oLanguage" : gui.config.dataTablesLanguage, + "sDom" : "<'row'<'col-xs-8'T><'col-xs-4'f>r>t<'row'<'col-xs-5'i><'col-xs-7'p>>", + "bDeferRender": options.deferedRender || false, + }); + + // if table rendered event + if( options.onLoad ) { + options.onLoad(self); + } + }); + return '#' + tableId; }, }; diff --git a/server/src/uds/static/adm/js/gui-tools.js b/server/src/uds/static/adm/js/gui-tools.js index 06da69ff..7a27a68d 100644 --- a/server/src/uds/static/adm/js/gui-tools.js +++ b/server/src/uds/static/adm/js/gui-tools.js @@ -47,6 +47,28 @@ }); }); }, + // Datetime renderer (with specified format) + renderDate : function(format) { + return function(data, type, full) { + return api.tools.strftime(format, new Date(data*1000)); + }; + }, + // Log level rendererer + renderLogLovel : function() { + var levels = { + 10000 : 'OTHER', + 20000 : 'DEBUG', + 30000 : 'INFO', + 40000 : 'WARN', + 50000 : 'ERROR', + 60000 : 'FATAL' + }; + + return function(data, type, full) { + return levels[data] || 'OTHER'; + } + }, + }; }(window.gui = window.gui || {}, jQuery)); \ No newline at end of file diff --git a/server/src/uds/static/adm/js/gui.js b/server/src/uds/static/adm/js/gui.js index 4a612e1e..5929a755 100644 --- a/server/src/uds/static/adm/js/gui.js +++ b/server/src/uds/static/adm/js/gui.js @@ -45,10 +45,6 @@ text: ' ' + gettext('Delete') + '', css: 'disabled btn3d-default btn3d btn3d-tables', }, - 'refresh': { - text: ' ' + gettext('Refresh') + '', - css: 'btn3d-primary btn3d btn3d-tables', - }, 'xls': { text: ' ' + gettext('Xls') + '', css: 'btn3d-info btn3d btn3d-tables', @@ -61,7 +57,7 @@ var panelId = 'panel-' + table_id; return { - text: api.templates.evaluate('tmpl_table', { + text: api.templates.evaluate('tmpl_comp_table', { panelId: panelId, icon: options.icon || 'table', size: options.size || 12, @@ -85,21 +81,9 @@ return '
"; }; - gui.minimizePanel = function(panelId) { - var title = $(panelId).attr('data-minimized'); - $(panelId).hide('slow', function(){ - $(' ' + title + '') - .appendTo('#minimized') - .click(function(){ - this.remove(); - $(panelId).show('slow'); - }); - }); - }; - gui.modal = function(id, title, content, options) { options = options || {}; - return api.templates.evaluate('tmpl_modal', { + return api.templates.evaluate('tmpl_comp_modal', { id: id, title: title, content: content, diff --git a/server/src/uds/templates/uds/admin/tmpl/authenticators.html b/server/src/uds/templates/uds/admin/tmpl/authenticators.html index 328b071b..c395465c 100644 --- a/server/src/uds/templates/uds/admin/tmpl/authenticators.html +++ b/server/src/uds/templates/uds/admin/tmpl/authenticators.html @@ -15,14 +15,14 @@ diff --git a/server/src/uds/templates/uds/admin/tmpl/providers.html b/server/src/uds/templates/uds/admin/tmpl/providers.html index cae5787b..7334be20 100644 --- a/server/src/uds/templates/uds/admin/tmpl/providers.html +++ b/server/src/uds/templates/uds/admin/tmpl/providers.html @@ -12,7 +12,26 @@
-
-
+ + {% endverbatim %} \ No newline at end of file